diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000000..6ba6ae82c8 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "cake.tool": { + "version": "0.35.0", + "commands": [ + "dotnet-cake" + ] + }, + "dotnet-format": { + "version": "3.1.37601", + "commands": [ + "dotnet-format" + ] + } + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 0dd7ef8ed1..a5f7795882 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,16 +12,186 @@ trim_trailing_whitespace = true #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 -dotnet_naming_symbols.public_members.applicable_kinds = property,method,field,event,delegate -dotnet_naming_rule.public_members_pascalcase.severity = suggestion +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,delegate -dotnet_naming_rule.private_members_camelcase.severity = suggestion +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 \ No newline at end of file +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 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-issues.md b/.github/ISSUE_TEMPLATE/01-bug-issues.md similarity index 52% rename from .github/ISSUE_TEMPLATE/bug-issues.md rename to .github/ISSUE_TEMPLATE/01-bug-issues.md index c8c41e5a78..0b80ce44dd 100644 --- a/.github/ISSUE_TEMPLATE/bug-issues.md +++ b/.github/ISSUE_TEMPLATE/01-bug-issues.md @@ -8,4 +8,9 @@ about: Issues regarding encountered bugs. **osu!lazer version:** -**Logs:** +**Logs:** + diff --git a/.github/ISSUE_TEMPLATE/crash-issues.md b/.github/ISSUE_TEMPLATE/02-crash-issues.md similarity index 58% rename from .github/ISSUE_TEMPLATE/crash-issues.md rename to .github/ISSUE_TEMPLATE/02-crash-issues.md index 8ad27e9e31..ada8de73c0 100644 --- a/.github/ISSUE_TEMPLATE/crash-issues.md +++ b/.github/ISSUE_TEMPLATE/02-crash-issues.md @@ -8,6 +8,11 @@ about: Issues regarding crashes or permanent freezes. **osu!lazer version:** -**Logs:** +**Logs:** + **Computer Specifications:** diff --git a/.github/ISSUE_TEMPLATE/feature-request-issues.md b/.github/ISSUE_TEMPLATE/03-feature-request-issues.md similarity index 100% rename from .github/ISSUE_TEMPLATE/feature-request-issues.md rename to .github/ISSUE_TEMPLATE/03-feature-request-issues.md diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..69baeee60c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: osu!stable issues + url: https://github.com/ppy/osu-stable-issues + about: For issues regarding osu!stable (not osu!lazer), open them here. diff --git a/.github/ISSUE_TEMPLATE/missing-for-live-issues.md b/.github/ISSUE_TEMPLATE/missing-for-live-issues.md deleted file mode 100644 index 5822da9c65..0000000000 --- a/.github/ISSUE_TEMPLATE/missing-for-live-issues.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: Missing for Live -about: Features which are available in osu!stable but not yet in osu!lazer. ---- -**Describe the missing feature:** - -**Proposal designs of the feature:** diff --git a/.gitignore b/.gitignore index e60058ab35..d122d25054 100644 --- a/.gitignore +++ b/.gitignore @@ -10,14 +10,8 @@ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs -### Cake ### -tools/** -build/tools/** - -fastlane/report.xml - # Build results -bin/[Dd]ebug/ +[Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ @@ -104,7 +98,6 @@ $tf/ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user -inspectcode # JustCode is a .NET coding add-in .JustCode @@ -254,20 +247,92 @@ paket-files/ # FAKE - F# Make .fake/ -# JetBrains Rider -.idea/.idea.osu/.idea/*.xml -.idea/.idea.osu/.idea/codeStyles/*.xml -.idea/.idea.osu/.idea/dataSources/*.xml -.idea/.idea.osu/.idea/dictionaries/*.xml -.idea/.idea.osu/*.iml -*.sln.iml - -# CodeRush -.cr/ - # Python Tools for Visual Studio (PTVS) __pycache__/ *.pyc -Staging/ +# Cake # +/tools/** +/build/tools/** +/build/temp/** + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +.idea/modules.xml +.idea/*.iml +.idea/modules +*.iml +*.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# fastlane +fastlane/report.xml + +# inspectcode inspectcodereport.xml +inspectcode + +# BenchmarkDotNet +/BenchmarkDotNet.Artifacts + +*.GeneratedMSBuildEditorConfig.editorconfig diff --git a/.gitmodules b/.idea/.gitignore similarity index 100% rename from .gitmodules rename to .idea/.gitignore diff --git a/.idea/.idea.osu.Desktop/.idea/.name b/.idea/.idea.osu.Desktop/.idea/.name new file mode 100644 index 0000000000..12bf4aebba --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/.name @@ -0,0 +1 @@ +osu.Desktop \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/codeStyles/codeStyleConfig.xml b/.idea/.idea.osu.Desktop/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000000..a55e7a179b --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/dataSources.xml b/.idea/.idea.osu.Desktop/.idea/dataSources.xml new file mode 100644 index 0000000000..10f8c1c84d --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/dataSources.xml @@ -0,0 +1,14 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$USER_HOME$/.local/share/osu/client.db + + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/encodings.xml b/.idea/.idea.osu.Desktop/.idea/encodings.xml new file mode 100644 index 0000000000..15a15b218a --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/indexLayout.xml b/.idea/.idea.osu.Desktop/.idea/indexLayout.xml new file mode 100644 index 0000000000..27ba142e96 --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/misc.xml b/.idea/.idea.osu.Desktop/.idea/misc.xml new file mode 100644 index 0000000000..1d8c84d0af --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/modules.xml b/.idea/.idea.osu.Desktop/.idea/modules.xml new file mode 100644 index 0000000000..680312ad27 --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml new file mode 100644 index 0000000000..4bb9f4d2a0 --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/projectSettingsUpdater.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/Benchmarks.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Benchmarks.xml new file mode 100644 index 0000000000..1815c271b4 --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Benchmarks.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/runConfigurations/CatchRuleset__Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/CatchRuleset__Tests_.xml similarity index 79% rename from .idea/.idea.osu/.idea/runConfigurations/CatchRuleset__Tests_.xml rename to .idea/.idea.osu.Desktop/.idea/runConfigurations/CatchRuleset__Tests_.xml index 6463dd6ea5..512ac4393a 100644 --- a/.idea/.idea.osu/.idea/runConfigurations/CatchRuleset__Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/CatchRuleset__Tests_.xml @@ -1,8 +1,8 @@ - - \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/runConfigurations/ManiaRuleset__Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/ManiaRuleset__Tests_.xml similarity index 79% rename from .idea/.idea.osu/.idea/runConfigurations/ManiaRuleset__Tests_.xml rename to .idea/.idea.osu.Desktop/.idea/runConfigurations/ManiaRuleset__Tests_.xml index 0b63b2d966..dec1ef717f 100644 --- a/.idea/.idea.osu/.idea/runConfigurations/ManiaRuleset__Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/ManiaRuleset__Tests_.xml @@ -1,8 +1,8 @@ - - \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/runConfigurations/OsuRuleset__Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/OsuRuleset__Tests_.xml similarity index 74% rename from .idea/.idea.osu/.idea/runConfigurations/OsuRuleset__Tests_.xml rename to .idea/.idea.osu.Desktop/.idea/runConfigurations/OsuRuleset__Tests_.xml index 750ece648b..d9370d5440 100644 --- a/.idea/.idea.osu/.idea/runConfigurations/OsuRuleset__Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/OsuRuleset__Tests_.xml @@ -1,8 +1,8 @@ - - \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/runConfigurations/TaikoRuleset__Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/TaikoRuleset__Tests_.xml similarity index 79% rename from .idea/.idea.osu/.idea/runConfigurations/TaikoRuleset__Tests_.xml rename to .idea/.idea.osu.Desktop/.idea/runConfigurations/TaikoRuleset__Tests_.xml index 7b359a1ca0..def4940bb1 100644 --- a/.idea/.idea.osu/.idea/runConfigurations/TaikoRuleset__Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/TaikoRuleset__Tests_.xml @@ -1,8 +1,8 @@ - - \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/runConfigurations/Tournament.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament.xml similarity index 76% rename from .idea/.idea.osu/.idea/runConfigurations/Tournament.xml rename to .idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament.xml index 3722f3dc04..1ffa73c257 100644 --- a/.idea/.idea.osu/.idea/runConfigurations/Tournament.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament.xml @@ -1,8 +1,8 @@ - - \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/runConfigurations/Tournament__Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament__Tests_.xml similarity index 79% rename from .idea/.idea.osu/.idea/runConfigurations/Tournament__Tests_.xml rename to .idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament__Tests_.xml index e2628a1bb4..e64da796b7 100644 --- a/.idea/.idea.osu/.idea/runConfigurations/Tournament__Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/Tournament__Tests_.xml @@ -1,8 +1,8 @@ - - \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/runConfigurations/osu_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_.xml similarity index 77% rename from .idea/.idea.osu/.idea/runConfigurations/osu_.xml rename to .idea/.idea.osu.Desktop/.idea/runConfigurations/osu_.xml index 7ac6bb745d..22105e1de2 100644 --- a/.idea/.idea.osu/.idea/runConfigurations/osu_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_.xml @@ -1,8 +1,8 @@ - - \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_SDL.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_SDL.xml new file mode 100644 index 0000000000..31f1fda09d --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu_SDL.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/runConfigurations/osu___Tests_.xml b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu___Tests_.xml similarity index 75% rename from .idea/.idea.osu/.idea/runConfigurations/osu___Tests_.xml rename to .idea/.idea.osu.Desktop/.idea/runConfigurations/osu___Tests_.xml index 7fcb7c15ea..cc243f6901 100644 --- a/.idea/.idea.osu/.idea/runConfigurations/osu___Tests_.xml +++ b/.idea/.idea.osu.Desktop/.idea/runConfigurations/osu___Tests_.xml @@ -1,8 +1,8 @@ - - \ No newline at end of file diff --git a/.idea/.idea.osu.Desktop/.idea/vcs.xml b/.idea/.idea.osu.Desktop/.idea/vcs.xml new file mode 100644 index 0000000000..3de04b744c --- /dev/null +++ b/.idea/.idea.osu.Desktop/.idea/vcs.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/indexLayout.xml b/.idea/.idea.osu/.idea/indexLayout.xml new file mode 100644 index 0000000000..27ba142e96 --- /dev/null +++ b/.idea/.idea.osu/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/modules.xml b/.idea/.idea.osu/.idea/modules.xml new file mode 100644 index 0000000000..0360fdbc5e --- /dev/null +++ b/.idea/.idea.osu/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu/.idea/projectSettingsUpdater.xml new file mode 100644 index 0000000000..7515e76054 --- /dev/null +++ b/.idea/.idea.osu/.idea/projectSettingsUpdater.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/.idea.osu/.idea/vcs.xml b/.idea/.idea.osu/.idea/vcs.xml new file mode 100644 index 0000000000..94a25f7f4c --- /dev/null +++ b/.idea/.idea.osu/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ce353d9b27..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,2 +0,0 @@ -language: csharp -solution: osu.sln \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 57ff3e6b43..4e8af405a2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,20 +1,16 @@ { "version": "0.2.0", - "configurations": [{ + "configurations": [ + { "name": "osu! (Debug)", "type": "coreclr", "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Desktop/bin/Debug/netcoreapp2.2/osu!.dll" + "${workspaceRoot}/osu.Desktop/bin/Debug/netcoreapp3.1/osu!.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build osu! (Debug)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Desktop/bin/Debug/netcoreapp2.2:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -23,15 +19,10 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Desktop/bin/Release/netcoreapp2.2/osu!.dll" + "${workspaceRoot}/osu.Desktop/bin/Release/netcoreapp3.1/osu!.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build osu! (Release)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Desktop/bin/Release/netcoreapp2.2:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -40,31 +31,22 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Game.Tests/bin/Debug/netcoreapp2.2/osu.Game.Tests.dll" + "${workspaceRoot}/osu.Game.Tests/bin/Debug/netcoreapp3.1/osu.Game.Tests.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build tests (Debug)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Game.Tests/bin/Debug/netcoreapp2.2:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" - }, { + }, + { "name": "osu! (Tests, Release)", "type": "coreclr", "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Game.Tests/bin/Release/netcoreapp2.2/osu.Game.Tests.dll" + "${workspaceRoot}/osu.Game.Tests/bin/Release/netcoreapp3.1/osu.Game.Tests.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build tests (Release)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Game.Tests/bin/Release/netcoreapp2.2:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -73,16 +55,11 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Desktop/bin/Debug/netcoreapp2.2/osu!.dll", + "${workspaceRoot}/osu.Desktop/bin/Debug/netcoreapp3.1/osu!.dll", "--tournament" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build osu! (Debug)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Desktop/bin/Debug/netcoreapp2.2:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -91,16 +68,11 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Desktop/bin/Release/netcoreapp2.2/osu!.dll", + "${workspaceRoot}/osu.Desktop/bin/Release/netcoreapp3.1/osu!.dll", "--tournament" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build osu! (Release)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Desktop/bin/Release/netcoreapp2.2:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -109,16 +81,11 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Game.Tournament.Tests/bin/Debug/netcoreapp2.2/osu.Game.Tournament.Tests.dll", + "${workspaceRoot}/osu.Game.Tournament.Tests/bin/Debug/netcoreapp3.1/osu.Game.Tournament.Tests.dll", "--tournament" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build tournament tests (Debug)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Game.Tournament.Tests/bin/Debug/netcoreapp2.2:${env:LD_LIBRARY_PATH}" - } - }, "console": "internalConsole" }, { @@ -127,16 +94,24 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/osu.Game.Tournament.Tests/bin/Debug/netcoreapp2.2/osu.Game.Tournament.Tests.dll", + "${workspaceRoot}/osu.Game.Tournament.Tests/bin/Debug/netcoreapp3.1/osu.Game.Tournament.Tests.dll", "--tournament" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build tournament tests (Release)", - "linux": { - "env": { - "LD_LIBRARY_PATH": "${workspaceRoot}/osu.Game.Tournament.Tests/bin/Debug/netcoreapp2.2:${env:LD_LIBRARY_PATH}" - } - }, + "console": "internalConsole" + }, + { + "name": "Benchmark", + "type": "coreclr", + "request": "launch", + "program": "${workspaceRoot}/osu.Game.Benchmarks/bin/Release/netcoreapp3.1/osu.Game.Benchmarks.dll", + "args": [ + "--filter", + "*" + ], + "cwd": "${workspaceRoot}", + "preLaunchTask": "Build benchmarks", "console": "internalConsole" }, { @@ -154,4 +129,4 @@ "externalConsole": false } ] -} \ No newline at end of file +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index aba590f466..aa77d4f055 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,7 +2,8 @@ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", - "tasks": [{ + "tasks": [ + { "label": "Build osu! (Debug)", "type": "shell", "command": "dotnet", @@ -10,9 +11,9 @@ "build", "--no-restore", "osu.Desktop", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" @@ -25,10 +26,10 @@ "build", "--no-restore", "osu.Desktop", - "/p:Configuration=Release", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" @@ -41,9 +42,9 @@ "build", "--no-restore", "osu.Game.Tests", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" @@ -56,10 +57,10 @@ "build", "--no-restore", "osu.Game.Tests", - "/p:Configuration=Release", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" @@ -72,13 +73,14 @@ "build", "--no-restore", "osu.Game.Tournament.Tests", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" - }, { + }, + { "label": "Build tournament tests (Release)", "type": "shell", "command": "dotnet", @@ -86,21 +88,37 @@ "build", "--no-restore", "osu.Game.Tournament.Tests", - "/p:Configuration=Release", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" }, { - "label": "Restore (netcoreapp2.2)", + "label": "Build benchmarks", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "--no-restore", + "osu.Game.Benchmarks", + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" + ], + "group": "build", + "problemMatcher": "$msCompile" + }, + { + "label": "Restore (netcoreapp3.1)", "type": "shell", "command": "dotnet", "args": [ "restore", - "osu.sln" + "build/Desktop.proj" ], "problemMatcher": [] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..6c327f01b3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,137 @@ +# Contributing Guidelines + +Thank you for showing interest in the development of osu!lazer! We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience. + +These are not "official rules" *per se*, but following them will help everyone deal with things in the most efficient manner. + +## Table of contents + +1. [I would like to submit an issue!](#i-would-like-to-submit-an-issue) +2. [I would like to submit a pull request!](#i-would-like-to-submit-a-pull-request) + +## I would like to submit an issue! + +Issues, bug reports and feature suggestions are welcomed, though please keep in mind that at any point in time, hundreds of issues are open, which vary in severity and the amount of time needed to address them. As such it's not uncommon for issues to remain unresolved for a long time or even closed outright if they are deemed not important enough to fix in the foreseeable future. Issues that are required to "go live" or otherwise achieve parity with stable are prioritised the most. + +* **Before submitting an issue, try searching existing issues first.** + + For housekeeping purposes, we close issues that overlap with or duplicate other pre-existing issues - you can help us not to have to do that by searching existing issues yourself first. The issue search box, as well as the issue tag system, are tools you can use to check if an issue has been reported before. + +* **When submitting a bug report, please try to include as much detail as possible.** + + Bugs are not equal - some of them will be reproducible every time on pretty much all hardware, while others will be hard to track down due to being specific to particular hardware or even somewhat random in nature. As such, providing as much detail as possible when reporting a bug is hugely appreciated. A good starting set of information consists of: + + * 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), + * 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), + * a video or picture of the bug, if at all possible. + +* **Provide more information when asked to do so.** + + Sometimes when a bug is more elusive or complicated, none of the information listed above will pinpoint a concrete cause of the problem. In this case we will most likely ask you for additional info, such as a Windows Event Log dump or a copy of your local lazer database (`client.db`). Providing that information is beneficial to both parties - we can track down the problem better, and hopefully fix it for you at some point once we know where it is! + +* **When submitting a feature proposal, please describe it in the most understandable way you can.** + + Communicating your idea for a feature can often be hard, and we would like to avoid any misunderstandings. As such, please try to explain your idea in a short, but understandable manner - it's best to avoid jargon or terms and references that could be considered obscure. A mock-up picture (doesn't have to be good!) of the feature can also go a long way in explaining. + +* **Refrain from posting "+1" comments.** + + If an issue has already been created, saying that you also experience it without providing any additional details doesn't really help us in any way. To express support for a proposal or indicate that you are also affected by a particular bug, you can use comment reactions instead. + +* **Refrain from asking if an issue has been resolved yet.** + + As mentioned above, the issue tracker has hundreds of issues open at any given time. Currently the game is being worked on by two members of the core team, and a handful of outside contributors who offer their free time to help out. As such, it can happen that an issue gets placed on the backburner due to being less important; generally posting a comment demanding its resolution some months or years after it is reported is not very likely to increase its priority. + +* **Avoid long discussions about non-development topics.** + + GitHub is mostly a developer space, and as such isn't really fit for lengthened discussions about gameplay mechanics (which might not even be in any way confirmed for the final release) and similar non-technical matters. Such matters are probably best addressed at the osu! forums. + +## I would like to submit a pull request! + +We also welcome pull requests from unaffiliated contributors. The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues that you can work on; we also mark issues that we think would be good for newcomers with the [`good-first-issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3Agood-first-issue) label. + +However, do keep in mind that the core team is committed to bringing osu!lazer up to par with stable first and foremost, so depending on what your contribution concerns, it might not be merged and released right away. Our approach to managing issues and their priorities is described [in the wiki](https://github.com/ppy/osu/wiki/Project-management). + +Here are some key things to note before jumping in: + +* **Make sure you are comfortable with C\# and your development environment.** + + While we are accepting of all kinds of contributions, we also have a certain quality standard we'd like to uphold and limited time to review your code. Therefore, we would like to avoid providing entry-level advice, and as such if you're not very familiar with C\# as a programming language, we'd recommend that you start off with a few personal projects to get acquainted with the language's syntax, toolchain and principles of object-oriented programming first. + + In addition, please take the time to take a look at and get acquainted with the [development and testing](https://github.com/ppy/osu-framework/wiki/Development-and-Testing) procedure we have set up. + +* **Make sure you are familiar with git and the pull request workflow.** + + [git](https://git-scm.com/) is a distributed version control system that might not be very intuitive at the beginning if you're not familiar with version control. In particular, projects using git have a particular workflow for submitting code changes, which is called the pull request workflow. + + To make things run more smoothly, we recommend that you look up some online resources to familiarise yourself with the git vocabulary and commands, and practice working with forks and submitting pull requests at your own pace. A high-level overview of the process can be found in [this article by GitHub](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/proposing-changes-to-your-work-with-pull-requests). + +* **Double-check designs before starting work on new functionality.** + + When implementing new features, keep in mind that we already have a lot of the UI designed. If you wish to work on something with the intention of having it included in the official distribution, please open an issue for discussion and we will give you what you need from a design perspective to proceed. If you want to make *changes* to the design, we recommend you open an issue with your intentions before spending too much time to ensure no effort is wasted. + +* **Make sure to submit pull requests off of a topic branch.** + + As described in the article linked in the previous point, topic branches help you parallelise your work and separate it from the main `master` branch, and additionally are easier for maintainers to work with. Working with multiple `master` branches across many remotes is difficult to keep track of, and it's easy to make a mistake and push to the wrong `master` branch by accident. + +* **Refrain from making changes through the GitHub web interface.** + + Even though GitHub provides an option to edit code or replace files in the repository using the web interface, we strongly discourage using it in most scenarios. Editing files this way is inefficient and likely to introduce whitespace or file encoding changes that make it more difficult to review the code. + + Code written through the web interface will also very likely be questioned outright by the reviewers, as it is likely that it has not been properly tested or that it will fail continuous integration checks. We strongly encourage using an IDE like [Visual Studio](https://visualstudio.microsoft.com/), [Visual Studio Code](https://code.visualstudio.com/) or [JetBrains Rider](https://www.jetbrains.com/rider/) instead. + +* **Add tests for your code whenever possible.** + + Automated tests are an essential part of a quality and reliable codebase. They help to make the code more maintainable by ensuring it is safe to reorganise (or refactor) the code in various ways, and also prevent regressions - bugs that resurface after having been fixed at some point in the past. If it is viable, please put in the time to add tests, so that the changes you make can last for a (hopefully) very long time. + +* **Run tests before opening a pull request.** + + Tying into the previous point, sometimes changes in one part of the codebase can result in unpredictable changes in behaviour in other pieces of the code. This is why it is best to always try to run tests before opening a PR. + + Continuous integration will always run the tests for you (and us), too, but it is best not to rely on it, as there might be many builds queued at any time. Running tests on your own will help you be more certain that at the point of clicking the "Create pull request" button, your changes are as ready as can be. + +* **Run code style analysis before opening a pull request.** + + As part of continuous integration, we also run code style analysis, which is supposed to make sure that your code is formatted the same way as all the pre-existing code in the repository. The reason we enforce a particular code style everywhere is to make sure the codebase is consistent in that regard - having one whitespace convention in one place and another one elsewhere causes disorganisation. + +* **Make sure that the pull request is complete before opening it.** + + Whether it's fixing a bug or implementing new functionality, it's best that you make sure that the change you want to submit as a pull request is as complete as it can be before clicking the *Create pull request* button. Having to track if a pull request is ready for review or not places additional burden on reviewers. + + Draft pull requests are an option, but use them sparingly and within reason. They are best suited to discuss code changes that cannot be easily described in natural language or have a potential large impact on the future direction of the project. When in doubt, don't open drafts unless a maintainer asks you to do so. + +* **Only push code when it's ready.** + + As an extension of the above, when making changes to an already-open PR, please try to only push changes you are reasonably certain of. Pushing after every commit causes the continuous integration build queue to grow in size, slowing down work and taking up time that could be spent verifying other changes. + +* **Make sure to keep the *Allow edits from maintainers* check box checked.** + + To speed up the merging process, collaborators and team members will sometimes want to push changes to your branch themselves, to make minor code style adjustments or to otherwise refactor the code without having to describe how they'd like the code to look like in painstaking detail. Having the *Allow edits from maintainers* check box checked lets them do that; without it they are forced to report issues back to you and wait for you to address them. + +* **Refrain from continually merging the master branch back to the PR.** + + Unless there are merge conflicts that need resolution, there is no need to keep merging `master` back to a branch over and over again. One of the maintainers will merge `master` themselves before merging the PR itself anyway, and continual merge commits can cause CI to get overwhelmed due to queueing up too many builds. + +* **Refrain from force-pushing to the PR branch.** + + Force-pushing should be avoided, as it can lead to accidentally overwriting a maintainer's changes or CI building wrong commits. We value all history in the project, so there is no need to squash or amend commits in most cases. + + The cases in which force-pushing is warranted are very rare (such as accidentally leaking sensitive info in one of the files committed, adding unrelated files, or mis-merging a dependent PR). + +* **Be patient when waiting for the code to be reviewed and merged.** + + As much as we'd like to review all contributions as fast as possible, our time is limited, as team members have to work on their own tasks in addition to reviewing code. As such, work needs to be prioritised, and it can unfortunately take weeks or months for your PR to be merged, depending on how important it is deemed to be. + +* **Don't mistake criticism of code for criticism of your person.** + + As mentioned before, we are highly committed to quality when it comes to the lazer project. This means that contributions from less experienced community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please consider our comments and requests a learning experience, and don't treat it as a personal attack. + +* **Feel free to reach out for help.** + + If you're uncertain about some part of the codebase or some inner workings of the game and framework, please reach out either by leaving a comment in the relevant issue or PR thread, or by posting a message in the [development Discord server](https://discord.gg/ppy). We will try to help you as much as we can. + + When it comes to which form of communication is best, GitHub generally lends better to longer-form discussions, while Discord is better for snappy call-and-response answers. Use your best discretion when deciding, and try to keep a single discussion in one place instead of moving back and forth. diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt new file mode 100644 index 0000000000..47839608c9 --- /dev/null +++ b/CodeAnalysis/BannedSymbols.txt @@ -0,0 +1,9 @@ +M:System.Object.Equals(System.Object,System.Object)~System.Boolean;Don't use object.Equals. Use IEquatable or EqualityComparer.Default instead. +M:System.Object.Equals(System.Object)~System.Boolean;Don't use object.Equals. Use IEquatable or EqualityComparer.Default instead. +M:System.ValueType.Equals(System.Object)~System.Boolean;Don't use object.Equals(Fallbacks to ValueType). Use IEquatable or EqualityComparer.Default instead. +M:System.Nullable`1.Equals(System.Object)~System.Boolean;Use == instead. +T:System.IComparable;Don't use non-generic IComparable. Use generic version instead. +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. diff --git a/CodeAnalysis/osu.ruleset b/CodeAnalysis/osu.ruleset new file mode 100644 index 0000000000..d497365f87 --- /dev/null +++ b/CodeAnalysis/osu.ruleset @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000000..551cb75077 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,46 @@ + + + + 8.0 + true + + + $(MSBuildThisFileDirectory)app.manifest + + + + osu.licenseheader + + + + + + + + + + + + $(MSBuildThisFileDirectory)CodeAnalysis\osu.ruleset + + + true + $(NoWarn);CS1591 + + + + $(NoWarn);NU1701 + + + false + ppy Pty Ltd + MIT + https://github.com/ppy/osu + https://github.com/ppy/osu + Automated release. + ppy Pty Ltd + Copyright (c) 2020 ppy Pty Ltd + osu game + + \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 17c0db12e7..a4b49af7e4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,160 +1,179 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.0) - addressable (2.6.0) - public_suffix (>= 2.0.2, < 4.0) + CFPropertyList (3.0.2) + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) atomos (0.1.3) - babosa (1.0.2) - claide (1.0.2) + aws-eventstream (1.1.0) + aws-partitions (1.354.0) + aws-sdk-core (3.104.3) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.239.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.36.0) + aws-sdk-core (~> 3, >= 3.99.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.78.0) + aws-sdk-core (~> 3, >= 3.104.3) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.2.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.3) + claide (1.0.3) colored (1.2) colored2 (3.1.2) commander-fastlane (4.4.6) highline (~> 1.7.2) - declarative (0.0.10) + declarative (0.0.20) declarative-option (0.1.0) - digest-crc (0.4.1) - domain_name (0.5.20180417) + digest-crc (0.6.1) + rake (~> 13.0) + domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - dotenv (2.7.1) - emoji_regex (1.0.1) - excon (0.62.0) - faraday (0.15.4) + dotenv (2.7.6) + emoji_regex (3.0.0) + excon (0.76.0) + faraday (1.0.1) multipart-post (>= 1.2, < 3) faraday-cookie_jar (0.0.6) faraday (>= 0.7.4) http-cookie (~> 1.0.0) - faraday_middleware (0.13.1) - faraday (>= 0.7.4, < 1.0) - fastimage (2.1.5) - fastlane (2.117.0) + faraday_middleware (1.0.0) + faraday (~> 1.0) + fastimage (2.2.0) + fastlane (2.156.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.3, < 3.0.0) - babosa (>= 1.0.2, < 2.0.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) bundler (>= 1.12.0, < 3.0.0) colored commander-fastlane (>= 4.4.6, < 5.0.0) dotenv (>= 2.1.1, < 3.0.0) - emoji_regex (>= 0.1, < 2.0) - excon (>= 0.45.0, < 1.0.0) - faraday (~> 0.9) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 0.9) + faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-api-client (>= 0.21.2, < 0.24.0) + google-api-client (>= 0.37.0, < 0.39.0) google-cloud-storage (>= 1.15.0, < 2.0.0) highline (>= 1.7.2, < 2.0.0) json (< 3.0.0) - mini_magick (~> 4.5.1) - multi_json - multi_xml (~> 0.5) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) multipart-post (~> 2.0.0) plist (>= 3.1.0, < 4.0.0) - public_suffix (~> 2.0.0) - rubyzip (>= 1.2.2, < 2.0.0) + rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.3) simctl (~> 1.6.3) slack-notifier (>= 2.0.0, < 3.0.0) - terminal-notifier (>= 1.6.2, < 2.0.0) + terminal-notifier (>= 2.0.0, < 3.0.0) terminal-table (>= 1.4.5, < 2.0.0) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) - xcodeproj (>= 1.6.0, < 2.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) - fastlane-plugin-clean_testflight_testers (0.2.0) - fastlane-plugin-souyuz (0.8.1) - souyuz (>= 0.8.1) + fastlane-plugin-clean_testflight_testers (0.3.0) + fastlane-plugin-souyuz (0.9.1) + souyuz (= 0.9.1) fastlane-plugin-xamarin (0.6.3) gh_inspector (1.1.3) - google-api-client (0.23.9) + google-api-client (0.38.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.5, < 0.7.0) + googleauth (~> 0.9) httpclient (>= 2.8.1, < 3.0) - mime-types (~> 3.0) + mini_mime (~> 1.0) representable (~> 3.0) retriable (>= 2.0, < 4.0) - signet (~> 0.9) - google-cloud-core (1.3.0) + signet (~> 0.12) + google-cloud-core (1.5.0) google-cloud-env (~> 1.0) - google-cloud-env (1.0.5) - faraday (~> 0.11) - google-cloud-storage (1.16.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.3.3) + faraday (>= 0.17.3, < 2.0) + google-cloud-errors (1.0.1) + google-cloud-storage (1.27.0) + addressable (~> 2.5) digest-crc (~> 0.4) - google-api-client (~> 0.23) + google-api-client (~> 0.33) google-cloud-core (~> 1.2) - googleauth (>= 0.6.2, < 0.10.0) - googleauth (0.6.7) - faraday (~> 0.12) + googleauth (~> 0.9) + mini_mime (~> 1.0) + googleauth (0.13.1) + faraday (>= 0.17.3, < 2.0) jwt (>= 1.4, < 3.0) memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) - signet (~> 0.7) + signet (~> 0.14) highline (1.7.10) http-cookie (1.0.3) domain_name (~> 0.5) httpclient (2.8.3) - json (2.2.0) - jwt (2.1.0) - memoist (0.16.0) - mime-types (3.2.2) - mime-types-data (~> 3.2015) - mime-types-data (3.2018.0812) - mini_magick (4.5.1) + jmespath (1.4.0) + json (2.3.1) + jwt (2.2.1) + memoist (0.16.2) + mini_magick (4.10.1) + mini_mime (1.0.2) mini_portile2 (2.4.0) - multi_json (1.13.1) - multi_xml (0.6.0) + multi_json (1.15.0) multipart-post (2.0.0) - nanaimo (0.2.6) + nanaimo (0.3.0) naturally (2.2.0) - nokogiri (1.10.1) + nokogiri (1.10.10) mini_portile2 (~> 2.4.0) - os (1.0.0) + os (1.1.1) plist (3.5.0) - public_suffix (2.0.5) + public_suffix (4.0.5) + rake (13.0.1) representable (3.0.4) declarative (< 0.1.0) declarative-option (< 0.2.0) uber (< 0.2.0) retriable (3.1.2) rouge (2.0.7) - rubyzip (1.2.2) + rubyzip (2.3.0) security (0.1.3) - signet (0.11.0) + signet (0.14.0) addressable (~> 2.3) - faraday (~> 0.9) + faraday (>= 0.17.3, < 2.0) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) - simctl (1.6.5) + simctl (1.6.8) CFPropertyList naturally slack-notifier (2.3.2) - souyuz (0.8.1) - fastlane (>= 2.29.0) + souyuz (0.9.1) + fastlane (>= 1.103.0) highline (~> 1.7) nokogiri (~> 1.7) - terminal-notifier (1.8.0) + terminal-notifier (2.0.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) - tty-cursor (0.6.1) - tty-screen (0.6.5) - tty-spinner (0.9.0) - tty-cursor (~> 0.6.0) + tty-cursor (0.7.1) + tty-screen (0.8.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) uber (0.1.0) unf (0.1.4) unf_ext - unf_ext (0.0.7.5) - unicode-display_width (1.4.1) + unf_ext (0.0.7.7) + unicode-display_width (1.7.0) word_wrap (1.0.0) - xcodeproj (1.8.1) + xcodeproj (1.18.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.2.6) + nanaimo (~> 0.3.0) xcpretty (0.3.0) rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.0) diff --git a/InspectCode.ps1 b/InspectCode.ps1 new file mode 100644 index 0000000000..6ed935fdbb --- /dev/null +++ b/InspectCode.ps1 @@ -0,0 +1,27 @@ +[CmdletBinding()] +Param( + [string]$Target, + [string]$Configuration, + [ValidateSet("Quiet", "Minimal", "Normal", "Verbose", "Diagnostic")] + [string]$Verbosity, + [switch]$ShowDescription, + [Alias("WhatIf", "Noop")] + [switch]$DryRun, + [Parameter(Position = 0, Mandatory = $false, ValueFromRemainingArguments = $true)] + [string[]]$ScriptArgs +) + +# Build Cake arguments +$cakeArguments = ""; +if ($Target) { $cakeArguments += "-target=$Target" } +if ($Configuration) { $cakeArguments += "-configuration=$Configuration" } +if ($Verbosity) { $cakeArguments += "-verbosity=$Verbosity" } +if ($ShowDescription) { $cakeArguments += "-showdescription" } +if ($DryRun) { $cakeArguments += "-dryrun" } +if ($Experimental) { $cakeArguments += "-experimental" } +$cakeArguments += $ScriptArgs + +dotnet tool restore +dotnet cake ./build/InspectCode.cake --bootstrap +dotnet cake ./build/InspectCode.cake $cakeArguments +exit $LASTEXITCODE \ No newline at end of file diff --git a/LICENCE b/LICENCE index 21c6a7090f..2435c23545 100644 --- a/LICENCE +++ b/LICENCE @@ -1,4 +1,4 @@ -Copyright (c) 2019 ppy Pty Ltd . +Copyright (c) 2020 ppy Pty Ltd . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 52fc29cb98..86c42dae12 100644 --- a/README.md +++ b/README.md @@ -4,41 +4,57 @@ # osu! -[![Build status](https://ci.appveyor.com/api/projects/status/u2p01nx7l6og8buh?svg=true)](https://ci.appveyor.com/project/peppy/osu) [![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu) [![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy) +[![Build status](https://ci.appveyor.com/api/projects/status/u2p01nx7l6og8buh?svg=true)](https://ci.appveyor.com/project/peppy/osu) +[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)]() +[![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu) +[![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy) -Rhythm is just a *click* away. The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Commonly known by the codename "osu!lazer". Pew pew. +A free-to-win rhythm game. Rhythm is just a *click* away! + +The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Commonly known by the codename *osu!lazer*. Pew pew. ## Status -This project is still heavily under development, but is in a state where users are encouraged to try it out and keep it installed alongside the stable osu! client. It will continue to evolve over the coming months and hopefully bring some new unique features to the table. +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. -We are accepting bug reports (please report with as much detail as possible). Feature requests are welcome as long as you read and understand the contribution guidelines listed below. +**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. -Detailed changelogs are published on the [official osu! site](https://osu.ppy.sh/home/changelog). +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: -## Requirements - -- A desktop platform with the [.NET Core SDK 2.2](https://www.microsoft.com/net/learn/get-started) or higher installed. -- When working with the codebase, we recommend using an IDE with intellisense and syntax highlighting, such as [Visual Studio 2017+](https://visualstudio.microsoft.com/vs/), [Jetbrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). -- Note that there are **[additional requirements for Windows 7 and Windows 8.1](https://docs.microsoft.com/en-us/dotnet/core/windows-prerequisites?tabs=netcore2x)** which you may need to manually install if your operating system is not up-to-date. +- Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer). +- You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management). +- Read peppy's [latest blog post](https://blog.ppy.sh/a-definitive-lazer-faq/) exploring where lazer is currently and the roadmap going forward. ## Running osu! -### Releases - -If you are not interested in developing the game, you can still consume our [binary releases](https://github.com/ppy/osu/releases). +If you are looking to install or test osu! without setting up a development environment, you can consume our [binary releases](https://github.com/ppy/osu/releases). Handy links below will download the latest version for your operating system of choice: **Latest build:** -| [Windows (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.12+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | -| ------------- | ------------- | +| [Windows (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.12+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS(iOS 10+)](https://osu.ppy.sh/home/testflight) | [Android (5+)](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) +| ------------- | ------------- | ------------- | ------------- | ------------- | -- **Linux** users are recommended to self-compile until we have official deployment in place. -- **iOS** users can join the [TestFlight beta program](https://t.co/PasE1zrHhw) (note that due to high demand this is regularly full). -- **Android** users can self-compile, and expect a public beta soon. +- The iOS testflight link may fill up (Apple has a hard limit of 10,000 users). We reset it occasionally when this happens. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements of link resets. + +- When running on Windows 7 or 8.1, **[additional prerequisites](https://docs.microsoft.com/en-us/dotnet/core/install/dependencies?tabs=netcore31&pivots=os-windows)** may be required to correctly run .NET Core applications if your operating system is not up-to-date with the latest service packs. If your platform is not listed above, there is still a chance you can manually build it by following the instructions below. +## 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). + +You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/issues/5852). + +## Developing osu! + +Please make sure you have the following prerequisites: + +- A desktop platform with the [.NET Core 3.1 SDK](https://dotnet.microsoft.com/download) or higher installed. +- When developing with mobile, [Xamarin](https://docs.microsoft.com/en-us/xamarin/) is required, which is shipped together with Visual Studio or [Visual Studio for Mac](https://visualstudio.microsoft.com/vs/mac/). +- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as [Visual Studio 2019+](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). +- When running on Linux, please have a system-wide FFmpeg installation available to support video decoding. + ### Downloading the source code Clone the repository: @@ -56,31 +72,22 @@ git pull ### Building -Build configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `VisualTests` project/configuration. More information on this provided [below](#contributing). +Build configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `VisualTests` project/configuration. More information on this is provided [below](#contributing). -> Visual Studio Code users must run the `Restore` task before any build attempt. +- Visual Studio / Rider users should load the project via one of the platform-specific `.slnf` files, rather than the main `.sln.` This will allow access to template run configurations. +- Visual Studio Code users must run the `Restore` task before any build attempt. -You can also build and run osu! from the command-line with a single command: +You can also build and run *osu!* from the command-line with a single command: ```shell dotnet run --project osu.Desktop ``` -If you are not interested in debugging osu!, you can add `-c Release` to gain performance. In this case, you must replace `Debug` with `Release` in any commands mentioned in this document. +If you are not interested in debugging *osu!*, you can add `-c Release` to gain performance. In this case, you must replace `Debug` with `Release` in any commands mentioned in this document. -If the build fails, try to restore nuget packages with `dotnet restore`. +If the build fails, try to restore NuGet packages with `dotnet restore`. -#### A note for Linux users - -On Linux, the environment variable `LD_LIBRARY_PATH` must point to the build directory, located at `osu.Desktop/bin/Debug/$NETCORE_VERSION`. - -`$NETCORE_VERSION` is the version of the targeted .NET Core SDK. You can check it by running `grep TargetFramework osu.Desktop/osu.Desktop.csproj | sed -r 's/.*>(.*)<\/.*/\1/'`. - -For example, you can run osu! with the following command: - -```shell -LD_LIBRARY_PATH="$(pwd)/osu.Desktop/bin/Debug/netcoreapp2.2" dotnet run --project osu.Desktop -``` +_Due to a historical feature gap between .NET Core and Xamarin, running `dotnet` CLI from the root directory will not work for most commands. This can be resolved by specifying a target `.csproj` or the helper project at `build/Desktop.proj`. Configurations have been provided to work around this issue for all supported IDEs mentioned above._ ### Testing with resource/framework modifications @@ -88,23 +95,23 @@ Sometimes it may be necessary to cross-test changes in [osu-resources](https://g ### Code analysis -Code analysis can be run with `powershell ./build.ps1` or `build.sh`. This is currently only supported under windows due to [resharper cli shortcomings](https://youtrack.jetbrains.com/issue/RSRP-410004). Alternatively, you can install resharper or use rider to get inline support in your IDE of choice. +Before committing your code, please run a code formatter. This can be achieved by running `dotnet format` in the command line, or using the `Format code` command in your IDE. + +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. ## Contributing -We welcome all contributions, but keep in mind that we already have a lot of the UI designed. If you wish to work on something with the intention on having it included in the official distribution, please open an issue for discussion and we will give you what you need from a design perspective to proceed. If you want to make *changes* to the design, we recommend you open an issue with your intentions before spending too much time, to ensure no effort is wasted. +When it comes to contributing to the project, the two main things you can do to help out are reporting issues and submitting pull requests. Based on past experiences, we have prepared a [list of contributing guidelines](CONTRIBUTING.md) that should hopefully ease you into our collaboration process and answer the most frequently-asked questions. -If you're unsure of what you can help with, check out the [list of open issues](https://github.com/ppy/osu/issues) (especially those with the ["good first issue"](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%22good+first+issue%22) label). +Note that while we already have certain standards in place, nothing is set in stone. If you have an issue with the way code is structured, with any libraries we are using, or with any processes involved with contributing, *please* bring it up. We welcome all feedback so we can make contributing to this project as painless as possible. -Before starting, please make sure you are familiar with the [development and testing](https://github.com/ppy/osu-framework/wiki/Development-and-Testing) procedure we have set up. New component development, and where possible, bug fixing and debugging existing components **should always be done under VisualTests**. - -Note that while we already have certain standards in place, nothing is set in stone. If you have an issue with the way code is structured; with any libraries we are using; with any processes involved with contributing, *please* bring it up. We welcome all feedback so we can make contributing to this project as pain-free as possible. - -For those interested, we love to reward quality contributions via [bounties](https://docs.google.com/spreadsheets/d/1jNXfj_S3Pb5PErA-czDdC9DUu4IgUbe1Lt8E7CYUJuE/view?&rm=minimal#gid=523803337), paid out via paypal or osu! supporter tags. Don't hesitate to [request a bounty](https://docs.google.com/forms/d/e/1FAIpQLSet_8iFAgPMG526pBZ2Kic6HSh7XPM3fE8xPcnWNkMzINDdYg/viewform) for your work on this project. +For those interested, we love to reward quality contributions via [bounties](https://docs.google.com/spreadsheets/d/1jNXfj_S3Pb5PErA-czDdC9DUu4IgUbe1Lt8E7CYUJuE/view?&rm=minimal#gid=523803337), paid out via PayPal or osu!supporter tags. Don't hesitate to [request a bounty](https://docs.google.com/forms/d/e/1FAIpQLSet_8iFAgPMG526pBZ2Kic6HSh7XPM3fE8xPcnWNkMzINDdYg/viewform) for your work on this project. ## Licence -The osu! client code and framework are licensed under the [MIT licence](https://opensource.org/licenses/MIT). Please see [the licence file](LICENCE) for more information. [tl;dr](https://tldrlegal.com/license/mit-license) you can do whatever you want as long as you include the original copyright and license notice in any copy of the software/source. +*osu!*'s code and framework are licensed under the [MIT licence](https://opensource.org/licenses/MIT). Please see [the licence file](LICENCE) for more information. [tl;dr](https://tldrlegal.com/license/mit-license) you can do whatever you want as long as you include the original copyright and license notice in any copy of the software/source. Please note that this *does not cover* the usage of the "osu!" or "ppy" branding in any software, resources, advertising or promotion, as this is protected by trademark law. diff --git a/appveyor.yml b/appveyor.yml index 4dcaa7b45e..a4a0cedc66 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,8 +1,27 @@ clone_depth: 1 version: '{branch}-{build}' -image: Previous Visual Studio 2017 -test: off -install: - - cmd: git submodule update --init --recursive --depth=5 -build_script: - - cmd: PowerShell -Version 2.0 .\build.ps1 +image: Visual Studio 2019 +dotnet_csproj: + patch: true + file: 'osu.Game\osu.Game.csproj' # Use wildcard when it's able to exclude Xamarin projects + version: '0.0.{build}' +cache: + - '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml' +before_build: + - ps: dotnet --info # Useful when version mismatch between CI and local + - ps: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects +build: + project: osu.sln + parallel: true + verbosity: minimal + publish_nuget: true +after_build: + - ps: dotnet tool restore + - ps: dotnet format --dry-run --check + - ps: .\InspectCode.ps1 +test: + assemblies: + except: + - '**\*Android*' + - '**\*iOS*' + - 'build\**\*' diff --git a/appveyor_deploy.yml b/appveyor_deploy.yml new file mode 100644 index 0000000000..bb4482f501 --- /dev/null +++ b/appveyor_deploy.yml @@ -0,0 +1,21 @@ +clone_depth: 1 +version: '{build}' +image: Visual Studio 2019 +dotnet_csproj: + patch: true + file: 'osu.Game\osu.Game.csproj' # Use wildcard when it's able to exclude Xamarin projects + version: $(APPVEYOR_REPO_TAG_NAME) +before_build: + - ps: dotnet --info # Useful when version mismatch between CI and local + - ps: nuget restore -verbosity quiet # Only nuget.exe knows both new (.NET Core) and old (Xamarin) projects +test: off +skip_non_tags: true +configuration: Release +build: + project: build\Desktop.proj # Skipping Xamarin Release that's slow and covered by fastlane + parallel: true + verbosity: minimal + publish_nuget: true +deploy: + - provider: Environment + name: nuget diff --git a/assets/lazer-nuget.png b/assets/lazer-nuget.png new file mode 100644 index 0000000000..c2a587fdc2 Binary files /dev/null and b/assets/lazer-nuget.png differ diff --git a/build.ps1 b/build.ps1 deleted file mode 100644 index c6a0bf6d4a..0000000000 --- a/build.ps1 +++ /dev/null @@ -1,82 +0,0 @@ -########################################################################## -# This is a customized Cake bootstrapper script for PowerShell. -########################################################################## - -<# - -.SYNOPSIS -This is a Powershell script to bootstrap a Cake build. - -.DESCRIPTION -This Powershell script restores NuGet tools (including Cake) -and execute your Cake build script with the parameters you provide. - -.PARAMETER Script -The build script to execute. -.PARAMETER Target -The build script target to run. -.PARAMETER Configuration -The build configuration to use. -.PARAMETER Verbosity -Specifies the amount of information to be displayed. -.PARAMETER ShowDescription -Shows description about tasks. -.PARAMETER DryRun -Performs a dry run. -.PARAMETER ScriptArgs -Remaining arguments are added here. - -.LINK -https://cakebuild.net - -#> - -[CmdletBinding()] -Param( - [string]$Script = "build.cake", - [string]$Target, - [string]$Configuration, - [ValidateSet("Quiet", "Minimal", "Normal", "Verbose", "Diagnostic")] - [string]$Verbosity, - [switch]$ShowDescription, - [Alias("WhatIf", "Noop")] - [switch]$DryRun, - [Parameter(Position = 0, Mandatory = $false, ValueFromRemainingArguments = $true)] - [string[]]$ScriptArgs -) - -Write-Host "Preparing to run build script..." - -# Determine the script root for resolving other paths. -if(!$PSScriptRoot) { - $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent -} - -# Resolve the paths for resources used for debugging. -$BUILD_DIR = Join-Path $PSScriptRoot "build" -$TOOLS_DIR = Join-Path $BUILD_DIR "tools" -$CAKE_CSPROJ = Join-Path $BUILD_DIR "cakebuild.csproj" - -# Install the required tools locally. -Write-Host "Restoring cake tools..." -Invoke-Expression "dotnet restore `"$CAKE_CSPROJ`" --packages `"$TOOLS_DIR`"" | Out-Null - -# Find the Cake executable -$CAKE_EXECUTABLE = (Get-ChildItem -Path "$TOOLS_DIR/cake.coreclr/" -Filter Cake.dll -Recurse).FullName - -# Build Cake arguments -$cakeArguments = @("$Script"); -if ($Target) { $cakeArguments += "-target=$Target" } -if ($Configuration) { $cakeArguments += "-configuration=$Configuration" } -if ($Verbosity) { $cakeArguments += "-verbosity=$Verbosity" } -if ($ShowDescription) { $cakeArguments += "-showdescription" } -if ($DryRun) { $cakeArguments += "-dryrun" } -if ($Experimental) { $cakeArguments += "-experimental" } -$cakeArguments += $ScriptArgs - -# Start Cake -Write-Host "Running build script..." -Push-Location -Path $BUILD_DIR -Invoke-Expression "dotnet `"$CAKE_EXECUTABLE`" $cakeArguments" -Pop-Location -exit $LASTEXITCODE diff --git a/build.sh b/build.sh deleted file mode 100755 index 8f1ef5b455..0000000000 --- a/build.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash - -########################################################################## -# This is a customized Cake bootstrapper script for Shell. -########################################################################## - -echo "Preparing to run build script..." - -cd build -SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) -TOOLS_DIR=$SCRIPT_DIR/tools -CAKE_BINARY_PATH=$TOOLS_DIR/"cake.coreclr" - -SCRIPT="build.cake" -CAKE_CSPROJ=$SCRIPT_DIR/"cakebuild.csproj" - -# Parse arguments. -CAKE_ARGUMENTS=() -for i in "$@"; do - case $1 in - -s|--script) SCRIPT="$2"; shift ;; - --) shift; CAKE_ARGUMENTS+=("$@"); break ;; - *) CAKE_ARGUMENTS+=("$1") ;; - esac - shift -done - -# Install the required tools locally. -echo "Restoring cake tools..." -dotnet restore $CAKE_CSPROJ --packages $TOOLS_DIR > /dev/null 2>&1 - -# Search for the CakeBuild binary. -CAKE_BINARY=$(find $CAKE_BINARY_PATH -name "Cake.dll") - -# Start Cake -echo "Running build script..." - -dotnet "$CAKE_BINARY" $SCRIPT "${CAKE_ARGUMENTS[@]}" diff --git a/build/Desktop.proj b/build/Desktop.proj new file mode 100644 index 0000000000..b1c6b065e8 --- /dev/null +++ b/build/Desktop.proj @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build/build.cake b/build/InspectCode.cake similarity index 57% rename from build/build.cake rename to build/InspectCode.cake index 1d2588de49..c8f4f37c94 100644 --- a/build/build.cake +++ b/build/InspectCode.cake @@ -1,5 +1,5 @@ -#addin "nuget:?package=CodeFileSanity&version=0.0.21" -#addin "nuget:?package=JetBrains.ReSharper.CommandLineTools&version=2019.1.1" +#addin "nuget:?package=CodeFileSanity&version=0.0.36" +#addin "nuget:?package=JetBrains.ReSharper.CommandLineTools&version=2020.1.3" #tool "nuget:?package=NVika.MSBuild&version=1.0.1" var nVikaToolPath = GetFiles("./tools/NVika.MSBuild.*/tools/NVika.exe").First(); @@ -7,43 +7,29 @@ var nVikaToolPath = GetFiles("./tools/NVika.MSBuild.*/tools/NVika.exe").First(); // ARGUMENTS /////////////////////////////////////////////////////////////////////////////// -var target = Argument("target", "Build"); +var target = Argument("target", "CodeAnalysis"); var configuration = Argument("configuration", "Release"); var rootDirectory = new DirectoryPath(".."); -var solution = rootDirectory.CombineWithFilePath("osu.sln"); +var sln = rootDirectory.CombineWithFilePath("osu.sln"); +var desktopSlnf = rootDirectory.CombineWithFilePath("osu.Desktop.slnf"); /////////////////////////////////////////////////////////////////////////////// // TASKS /////////////////////////////////////////////////////////////////////////////// -Task("Compile") - .Does(() => { - DotNetCoreBuild(solution.FullPath, new DotNetCoreBuildSettings { - Configuration = configuration, - }); - }); - -Task("Test") - .IsDependentOn("Compile") - .Does(() => { - var testAssemblies = GetFiles(rootDirectory + "/**/*.Tests/bin/**/*.Tests.dll"); - - DotNetCoreVSTest(testAssemblies, new DotNetCoreVSTestSettings { - Logger = AppVeyor.IsRunningOnAppVeyor ? "Appveyor" : $"trx", - Parallel = true, - ToolTimeout = TimeSpan.FromMinutes(10), - }); - }); - -// windows only because both inspectcore and nvika depend on net45 +// windows only because both inspectcode and nvika depend on net45 Task("InspectCode") .WithCriteria(IsRunningOnWindows()) - .IsDependentOn("Compile") .Does(() => { - InspectCode(solution, new InspectCodeSettings { + InspectCode(desktopSlnf, new InspectCodeSettings { CachesHome = "inspectcode", OutputFile = "inspectcodereport.xml", + ArgumentCustomization = arg => { + if (AppVeyor.IsRunningOnAppVeyor) // Don't flood CI output + arg.Append("--verbosity:WARN"); + return arg; + }, }); int returnCode = StartProcess(nVikaToolPath, $@"parsereport ""inspectcodereport.xml"" --treatwarningsaserrors"); @@ -59,9 +45,8 @@ Task("CodeFileSanity") }); }); -Task("Build") +Task("CodeAnalysis") .IsDependentOn("CodeFileSanity") - .IsDependentOn("InspectCode") - .IsDependentOn("Test"); + .IsDependentOn("InspectCode"); RunTarget(target); \ No newline at end of file diff --git a/build/cakebuild.csproj b/build/cakebuild.csproj deleted file mode 100644 index 8ccce35e26..0000000000 --- a/build/cakebuild.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - Exe - true - netcoreapp2.0 - - - - - - \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 48c16caf0f..8c278604aa 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,22 +1,84 @@ update_fastlane -default_platform(:ios) +platform :android do +desc 'Deploy to play store' + lane :beta do |options| -platform :ios do - lane :testflight_prune_dry do - clean_testflight_testers(days_of_inactivity:45, dry_run: true) + update_version( + version: options[:version], + build: options[:build], + ) + + build(options) + + supply( + apk: './osu.Android/bin/Release/sh.ppy.osulazer-Signed.apk', + package_name: 'sh.ppy.osulazer', + track: 'alpha', # upload to alpha, we can promote it later + json_key: options[:json_key], + ) end - # Specify a custom number for what's "inactive" - lane :testflight_prune do - clean_testflight_testers(days_of_inactivity: 45) # 120 days, so about 4 months + desc 'Deploy to github release' + lane :build_github do |options| + + update_version( + version: options[:version], + build: options[:build], + ) + + build(options) + + client = HTTPClient.new + changelog = client.get_content 'https://gist.githubusercontent.com/peppy/aaa2ec1a323554b619671cac6dbbb776/raw' + changelog.gsub!('$BUILD_ID', options[:build]) + + set_github_release( + repository_name: "ppy/osu", + api_token: ENV["GITHUB_TOKEN"], + name: options[:build], + tag_name: options[:build], + is_draft: true, + description: changelog, + commitish: "master", + upload_assets: ["osu.Android/bin/Release/sh.ppy.osulazer.apk"] + ) + + end + + desc 'Compile the project' + lane :build do |options| + nuget_restore( + project_path: 'osu.sln' + ) + + souyuz( + build_configuration: 'Release', + solution_path: 'osu.sln', + platform: "android", + output_path: "osu.Android/bin/Release/", + keystore_path: options[:keystore_path], + keystore_alias: options[:keystore_alias], + keystore_password: ENV["KEYSTORE_PASSWORD"] + ) end lane :update_version do |options| - options[:plist_path] = '../osu.iOS/Info.plist' - app_version(options) + + split = options[:build].split('.') + split[1] = split[1].to_s.rjust(4, '0') + android_build = split.join('') + + app_version( + solution_path: 'osu.sln', + version: options[:version], + build: android_build, + ) end +end + +platform :ios do desc 'Deploy to testflight' lane :beta do |options| update_version(options) @@ -35,8 +97,10 @@ platform :ios do changelog.gsub!('$BUILD_ID', options[:build]) pilot( - wait_processing_interval: 1800, + wait_processing_interval: 900, changelog: changelog, + groups: ['osu! supporters', 'public'], + distribute_external: true, ipa: './osu.iOS/bin/iPhone/Release/osu.iOS.ipa' ) end @@ -44,13 +108,12 @@ platform :ios do desc 'Compile the project' lane :build do nuget_restore( - project_path: 'osu.iOS.sln' + project_path: 'osu.sln' ) souyuz( platform: "ios", - build_target: "osu_iOS", - plist_path: "../osu.iOS/Info.plist" + plist_path: "osu.iOS/Info.plist" ) end @@ -62,4 +125,17 @@ platform :ios do match(options) end + + lane :update_version do |options| + options[:plist_path] = 'osu.iOS/Info.plist' + app_version(options) + end + + lane :testflight_prune_dry do + clean_testflight_testers(days_of_inactivity:45, dry_run: true) + end + + lane :testflight_prune do + clean_testflight_testers(days_of_inactivity: 45) + end end diff --git a/fastlane/README.md b/fastlane/README.md index 53bbc62cae..a400ed9516 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -15,22 +15,31 @@ Install _fastlane_ using or alternatively using `brew cask install fastlane` # Available Actions +## Android +### android beta +``` +fastlane android beta +``` +Deploy to play store +### android build_github +``` +fastlane android build_github +``` +Deploy to github release +### android build +``` +fastlane android build +``` +Compile the project +### android update_version +``` +fastlane android update_version +``` + + +---- + ## iOS -### ios testflight_prune_dry -``` -fastlane ios testflight_prune_dry -``` - -### ios testflight_prune -``` -fastlane ios testflight_prune -``` - -### ios update_version -``` -fastlane ios update_version -``` - ### ios beta ``` fastlane ios beta @@ -46,6 +55,21 @@ Compile the project fastlane ios provision ``` Install provisioning profiles using match +### ios update_version +``` +fastlane ios update_version +``` + +### ios testflight_prune_dry +``` +fastlane ios testflight_prune_dry +``` + +### ios testflight_prune +``` +fastlane ios testflight_prune +``` + ---- diff --git a/global.json b/global.json new file mode 100644 index 0000000000..10b61047ac --- /dev/null +++ b/global.json @@ -0,0 +1,10 @@ +{ + "sdk": { + "allowPrerelease": false, + "rollForward": "minor", + "version": "3.1.100" + }, + "msbuild-sdks": { + "Microsoft.Build.Traversal": "2.2.3" + } +} \ No newline at end of file diff --git a/osu.Android.props b/osu.Android.props index 6744590f0d..bbe8426316 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -1,57 +1,46 @@ + 8.0 bin\$(Configuration) 4 2.0 false false - default Library 512 Off True Xamarin.Android.Net.AndroidClientHandler - v9.0 + v10.0 false + true + armeabi-v7a;x86;arm64-v8a + true + cjk,mideast,other,rare,west + SdkOnly + prompt - + True portable False DEBUG;TRACE - prompt - false false - SdkOnly true false - cjk,mideast,other,rare,west - true - armeabi-v7a;x86;arm64-v8a - true - + false None True - prompt - true false - SdkOnly False true - cjk,mideast,other,rare,west - true - armeabi-v7a;x86;arm64-v8a - true osu.licenseheader - - Always - @@ -62,7 +51,7 @@ - - + + diff --git a/osu.Android.sln b/osu.Android.sln deleted file mode 100644 index ebf2c55cb4..0000000000 --- a/osu.Android.sln +++ /dev/null @@ -1,126 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.28516.95 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game", "osu.Game\osu.Game.csproj", "{2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.Osu", "osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj", "{C92A607B-1FDD-4954-9F92-03FF547D9080}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.Catch", "osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj", "{58F6C80C-1253-4A0E-A465-B8C85EBEADF3}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.Taiko", "osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj", "{F167E17A-7DE6-4AF5-B920-A5112296C695}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.Mania", "osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj", "{48F4582B-7687-4621-9CBE-5C24197CB536}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Android", "osu.Android\osu.Android.csproj", "{D1D5F9A8-B40B-40E6-B02F-482D03346D3D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Catch.Tests.Android", "osu.Game.Rulesets.Catch.Tests.Android\osu.Game.Rulesets.Catch.Tests.Android.csproj", "{C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Mania.Tests.Android", "osu.Game.Rulesets.Mania.Tests.Android\osu.Game.Rulesets.Mania.Tests.Android.csproj", "{531F1092-DB27-445D-AA33-2A77C7187C99}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Osu.Tests.Android", "osu.Game.Rulesets.Osu.Tests.Android\osu.Game.Rulesets.Osu.Tests.Android.csproj", "{90CAB706-39CB-4B93-9629-3218A6FF8E9B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Taiko.Tests.Android", "osu.Game.Rulesets.Taiko.Tests.Android\osu.Game.Rulesets.Taiko.Tests.Android.csproj", "{3701A0A1-8476-42C6-B5C4-D24129B4A484}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Tests.Android", "osu.Game.Tests.Android\osu.Game.Tests.Android.csproj", "{5CC222DC-5716-4499-B897-DCBDDA4A5CF9}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.Build.0 = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.Build.0 = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.Build.0 = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.Build.0 = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|Any CPU.Build.0 = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.ActiveCfg = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.Build.0 = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.Build.0 = Release|Any CPU - {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.Deploy.0 = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.Build.0 = Release|Any CPU - {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.Deploy.0 = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.Build.0 = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.ActiveCfg = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.Build.0 = Release|Any CPU - {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.Deploy.0 = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.Build.0 = Release|Any CPU - {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.Deploy.0 = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.Build.0 = Release|Any CPU - {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.Deploy.0 = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.Deploy.0 = Debug|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.Build.0 = Release|Any CPU - {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.Deploy.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - 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 -EndGlobal diff --git a/osu.Android.sln.DotSettings b/osu.Android.sln.DotSettings deleted file mode 100644 index 3f5bd9d34d..0000000000 --- a/osu.Android.sln.DotSettings +++ /dev/null @@ -1,815 +0,0 @@ - - True - True - True - True - ExplicitlyExcluded - ExplicitlyExcluded - SOLUTION - HINT - WARNING - - True - WARNING - WARNING - HINT - HINT - HINT - HINT - WARNING - WARNING - WARNING - HINT - WARNING - HINT - SUGGESTION - HINT - HINT - HINT - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - HINT - WARNING - WARNING - HINT - WARNING - WARNING - DO_NOT_SHOW - HINT - WARNING - DO_NOT_SHOW - WARNING - HINT - HINT - HINT - ERROR - HINT - HINT - HINT - WARNING - WARNING - HINT - DO_NOT_SHOW - HINT - HINT - HINT - HINT - WARNING - WARNING - WARNING - WARNING - WARNING - HINT - HINT - HINT - HINT - HINT - WARNING - WARNING - WARNING - WARNING - WARNING - HINT - DO_NOT_SHOW - DO_NOT_SHOW - DO_NOT_SHOW - 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 - HINT - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - HINT - DO_NOT_SHOW - DO_NOT_SHOW - DO_NOT_SHOW - WARNING - WARNING - HINT - WARNING - HINT - HINT - HINT - HINT - HINT - HINT - HINT - - HINT - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - HINT - WARNING - WARNING - HINT - HINT - 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) - True - True - True - True - True - True - True - True - NEXT_LINE - NEXT_LINE - True - NEVER - NEVER - False - NEVER - False - True - False - False - True - True - False - CHOP_IF_LONG - True - 200 - CHOP_IF_LONG - False - False - AABB - API - BPM - GC - GL - GLSL - HID - HUD - ID - IP - IPC - LTRB - MD5 - NS - OS - RGB - RNG - SHA - SRGB - TK - SS - PP - GMT - QAT - BNG - UI - 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 - 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 diff --git a/osu.Android.slnf b/osu.Android.slnf new file mode 100644 index 0000000000..7d90f97eb9 --- /dev/null +++ b/osu.Android.slnf @@ -0,0 +1,19 @@ +{ + "solution": { + "path": "osu.sln", + "projects": [ + "osu.Android\\osu.Android.csproj", + "osu.Game.Rulesets.Catch.Tests.Android\\osu.Game.Rulesets.Catch.Tests.Android.csproj", + "osu.Game.Rulesets.Catch\\osu.Game.Rulesets.Catch.csproj", + "osu.Game.Rulesets.Mania.Tests.Android\\osu.Game.Rulesets.Mania.Tests.Android.csproj", + "osu.Game.Rulesets.Mania\\osu.Game.Rulesets.Mania.csproj", + "osu.Game.Rulesets.Osu.Tests.Android\\osu.Game.Rulesets.Osu.Tests.Android.csproj", + "osu.Game.Rulesets.Osu\\osu.Game.Rulesets.Osu.csproj", + "osu.Game.Rulesets.Taiko.Tests.Android\\osu.Game.Rulesets.Taiko.Tests.Android.csproj", + "osu.Game.Rulesets.Taiko\\osu.Game.Rulesets.Taiko.csproj", + "osu.Game.Tests.Android\\osu.Game.Tests.Android.csproj", + "osu.Game.Tests\\osu.Game.Tests.csproj", + "osu.Game\\osu.Game.csproj" + ] + } +} \ No newline at end of file diff --git a/osu.Android/GameplayScreenRotationLocker.cs b/osu.Android/GameplayScreenRotationLocker.cs new file mode 100644 index 0000000000..25bd659a5d --- /dev/null +++ b/osu.Android/GameplayScreenRotationLocker.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 Android.Content.PM; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game; + +namespace osu.Android +{ + public class GameplayScreenRotationLocker : Component + { + private Bindable localUserPlaying; + + [Resolved] + private OsuGameActivity gameActivity { get; set; } + + [BackgroundDependencyLoader] + private void load(OsuGame game) + { + localUserPlaying = game.LocalUserPlaying.GetBoundCopy(); + localUserPlaying.BindValueChanged(updateLock, true); + } + + private void updateLock(ValueChangedEvent userPlaying) + { + gameActivity.RunOnUiThread(() => + { + gameActivity.RequestedOrientation = userPlaying.NewValue ? ScreenOrientation.Locked : ScreenOrientation.FullUser; + }); + } + } +} diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 762a9c418d..7e250dce0e 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -9,13 +9,18 @@ using osu.Framework.Android; namespace osu.Android { - [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullSensor, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = true)] + [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false)] public class OsuGameActivity : AndroidGameActivity { - protected override Framework.Game CreateGame() => new OsuGameAndroid(); + protected override Framework.Game CreateGame() => new OsuGameAndroid(this); protected override void OnCreate(Bundle savedInstanceState) { + // The default current directory on android is '/'. + // On some devices '/' maps to the app data directory. On others it maps to the root of the internal storage. + // In order to have a consistent current directory on all devices the full path of the app data directory is set as the current directory. + System.Environment.CurrentDirectory = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Personal); + base.OnCreate(savedInstanceState); Window.AddFlags(WindowManagerFlags.Fullscreen); diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index d9bdd9c0c2..21d6336b2c 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -3,12 +3,74 @@ using System; using Android.App; +using Android.OS; +using osu.Framework.Allocation; using osu.Game; +using osu.Game.Updater; namespace osu.Android { public class OsuGameAndroid : OsuGame { - public override Version AssemblyVersion => new Version(Application.Context.ApplicationContext.PackageManager.GetPackageInfo(Application.Context.ApplicationContext.PackageName, 0).VersionName); + [Cached] + private readonly OsuGameActivity gameActivity; + + public OsuGameAndroid(OsuGameActivity activity) + : base(null) + { + gameActivity = activity; + } + + public override Version AssemblyVersion + { + get + { + var packageInfo = Application.Context.ApplicationContext.PackageManager.GetPackageInfo(Application.Context.ApplicationContext.PackageName, 0); + + try + { + // We store the osu! build number in the "VersionCode" field to better support google play releases. + // If we were to use the main build number, it would require a new submission each time (similar to TestFlight). + // In order to do this, we should split it up and pad the numbers to still ensure sequential increase over time. + // + // We also need to be aware that older SDK versions store this as a 32bit int. + // + // Basic conversion format (as done in Fastfile): 2020.606.0 -> 202006060 + + // https://stackoverflow.com/questions/52977079/android-sdk-28-versioncode-in-packageinfo-has-been-deprecated + string versionName = string.Empty; + + if (Build.VERSION.SdkInt >= BuildVersionCodes.P) + { + versionName = packageInfo.LongVersionCode.ToString(); + // ensure we only read the trailing portion of long (the part we are interested in). + versionName = versionName.Substring(versionName.Length - 9); + } + else + { +#pragma warning disable CS0618 // Type or member is obsolete + // this is required else older SDKs will report missing method exception. + versionName = packageInfo.VersionCode.ToString(); +#pragma warning restore CS0618 // Type or member is obsolete + } + + // undo play store version garbling (as mentioned above). + return new Version(int.Parse(versionName.Substring(0, 4)), int.Parse(versionName.Substring(4, 4)), int.Parse(versionName.Substring(8, 1))); + } + catch + { + } + + return new Version(packageInfo.VersionName); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + LoadComponentAsync(new GameplayScreenRotationLocker(), Add); + } + + protected override UpdateManager CreateUpdateManager() => new SimpleUpdateManager(); } } diff --git a/osu.Android/Properties/AndroidManifest.xml b/osu.Android/Properties/AndroidManifest.xml index acd21f9587..770eaf2222 100644 --- a/osu.Android/Properties/AndroidManifest.xml +++ b/osu.Android/Properties/AndroidManifest.xml @@ -1,6 +1,6 @@  - + diff --git a/osu.Android/lib/arm64-v8a/libbass.so b/osu.Android/lib/arm64-v8a/libbass.so deleted file mode 100644 index d5c24b3e4b..0000000000 Binary files a/osu.Android/lib/arm64-v8a/libbass.so and /dev/null differ diff --git a/osu.Android/lib/arm64-v8a/libbass_fx.so b/osu.Android/lib/arm64-v8a/libbass_fx.so deleted file mode 100644 index e498b10117..0000000000 Binary files a/osu.Android/lib/arm64-v8a/libbass_fx.so and /dev/null differ diff --git a/osu.Android/lib/armeabi-v7a/libbass.so b/osu.Android/lib/armeabi-v7a/libbass.so deleted file mode 100644 index f7d23b9052..0000000000 Binary files a/osu.Android/lib/armeabi-v7a/libbass.so and /dev/null differ diff --git a/osu.Android/lib/armeabi-v7a/libbass_fx.so b/osu.Android/lib/armeabi-v7a/libbass_fx.so deleted file mode 100644 index 006e2feb30..0000000000 Binary files a/osu.Android/lib/armeabi-v7a/libbass_fx.so and /dev/null differ diff --git a/osu.Android/lib/x86/libbass.so b/osu.Android/lib/x86/libbass.so deleted file mode 100644 index b0f758a42b..0000000000 Binary files a/osu.Android/lib/x86/libbass.so and /dev/null differ diff --git a/osu.Android/lib/x86/libbass_fx.so b/osu.Android/lib/x86/libbass_fx.so deleted file mode 100644 index 526dca39ca..0000000000 Binary files a/osu.Android/lib/x86/libbass_fx.so and /dev/null differ diff --git a/osu.Android/osu.Android.csproj b/osu.Android/osu.Android.csproj index ac3905a372..a2638e95c8 100644 --- a/osu.Android/osu.Android.csproj +++ b/osu.Android/osu.Android.csproj @@ -13,6 +13,7 @@ osu.Android Properties\AndroidManifest.xml armeabi-v7a;x86;arm64-v8a + false cjk;mideast;other;rare;west @@ -20,6 +21,7 @@ r8 + diff --git a/osu.Desktop.slnf b/osu.Desktop.slnf new file mode 100644 index 0000000000..d2c14d321a --- /dev/null +++ b/osu.Desktop.slnf @@ -0,0 +1,21 @@ +{ + "solution": { + "path": "osu.sln", + "projects": [ + "osu.Desktop\\osu.Desktop.csproj", + "osu.Game.Benchmarks\\osu.Game.Benchmarks.csproj", + "osu.Game.Rulesets.Catch.Tests\\osu.Game.Rulesets.Catch.Tests.csproj", + "osu.Game.Rulesets.Catch\\osu.Game.Rulesets.Catch.csproj", + "osu.Game.Rulesets.Mania.Tests\\osu.Game.Rulesets.Mania.Tests.csproj", + "osu.Game.Rulesets.Mania\\osu.Game.Rulesets.Mania.csproj", + "osu.Game.Rulesets.Osu.Tests\\osu.Game.Rulesets.Osu.Tests.csproj", + "osu.Game.Rulesets.Osu\\osu.Game.Rulesets.Osu.csproj", + "osu.Game.Rulesets.Taiko.Tests\\osu.Game.Rulesets.Taiko.Tests.csproj", + "osu.Game.Rulesets.Taiko\\osu.Game.Rulesets.Taiko.csproj", + "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" + ] + } +} \ No newline at end of file diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs new file mode 100644 index 0000000000..26d7402a5b --- /dev/null +++ b/osu.Desktop/DiscordRichPresence.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.Text; +using DiscordRPC; +using DiscordRPC.Message; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Game.Online.API; +using osu.Game.Rulesets; +using osu.Game.Users; +using LogLevel = osu.Framework.Logging.LogLevel; +using User = osu.Game.Users.User; + +namespace osu.Desktop +{ + internal class DiscordRichPresence : Component + { + private const string client_id = "367827983903490050"; + + private DiscordRpcClient client; + + [Resolved] + private IBindable ruleset { get; set; } + + private Bindable user; + + private readonly IBindable status = new Bindable(); + private readonly IBindable activity = new Bindable(); + + private readonly RichPresence presence = new RichPresence + { + Assets = new Assets { LargeImageKey = "osu_logo_lazer", } + }; + + [BackgroundDependencyLoader] + private void load(IAPIProvider provider) + { + client = new DiscordRpcClient(client_id) + { + SkipIdenticalPresence = false // handles better on discord IPC loss, see updateStatus call in onReady. + }; + + client.OnReady += onReady; + + // safety measure for now, until we performance test / improve backoff for failed connections. + client.OnConnectionFailed += (_, __) => client.Deinitialize(); + + client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network); + + (user = provider.LocalUser.GetBoundCopy()).BindValueChanged(u => + { + status.UnbindBindings(); + status.BindTo(u.NewValue.Status); + + activity.UnbindBindings(); + activity.BindTo(u.NewValue.Activity); + }, true); + + ruleset.BindValueChanged(_ => updateStatus()); + status.BindValueChanged(_ => updateStatus()); + activity.BindValueChanged(_ => updateStatus()); + + client.Initialize(); + } + + private void onReady(object _, ReadyMessage __) + { + Logger.Log("Discord RPC Client ready.", LoggingTarget.Network, LogLevel.Debug); + updateStatus(); + } + + private void updateStatus() + { + if (!client.IsInitialized) + return; + + if (status.Value is UserStatusOffline) + { + client.ClearPresence(); + return; + } + + if (status.Value is UserStatusOnline && activity.Value != null) + { + presence.State = truncate(activity.Value.Status); + presence.Details = truncate(getDetails(activity.Value)); + } + else + { + presence.State = "Idle"; + presence.Details = string.Empty; + } + + // update user information + presence.Assets.LargeImageText = $"{user.Value.Username}" + (user.Value.Statistics?.Ranks.Global > 0 ? $" (rank #{user.Value.Statistics.Ranks.Global:N0})" : string.Empty); + + // update ruleset + presence.Assets.SmallImageKey = ruleset.Value.ID <= 3 ? $"mode_{ruleset.Value.ID}" : "mode_custom"; + presence.Assets.SmallImageText = ruleset.Value.Name; + + client.SetPresence(presence); + } + + private static readonly int ellipsis_length = Encoding.UTF8.GetByteCount(new[] { '…' }); + + private string truncate(string str) + { + if (Encoding.UTF8.GetByteCount(str) <= 128) + return str; + + ReadOnlyMemory strMem = str.AsMemory(); + + do + { + strMem = strMem[..^1]; + } while (Encoding.UTF8.GetByteCount(strMem.Span) + ellipsis_length > 128); + + return string.Create(strMem.Length + 1, strMem, (span, mem) => + { + mem.Span.CopyTo(span); + span[^1] = '…'; + }); + } + + private string getDetails(UserActivity activity) + { + switch (activity) + { + case UserActivity.SoloGame solo: + return solo.Beatmap.ToString(); + + case UserActivity.Editing edit: + return edit.Beatmap.ToString(); + + case UserActivity.InLobby lobby: + return lobby.Room.Name.Value; + } + + return string.Empty; + } + + protected override void Dispose(bool isDisposing) + { + client.Dispose(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 975b7f9f5a..b17611f23f 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -1,4 +1,4 @@ -// 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; @@ -6,17 +6,17 @@ using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Win32; using osu.Desktop.Overlays; using osu.Framework.Platform; using osu.Game; -using osuTK.Input; -using Microsoft.Win32; using osu.Desktop.Updater; using osu.Framework; using osu.Framework.Logging; -using osu.Framework.Platform.Windows; using osu.Framework.Screens; using osu.Game.Screens.Menu; +using osu.Game.Updater; +using osu.Desktop.Windows; namespace osu.Desktop { @@ -36,33 +36,72 @@ namespace osu.Desktop try { if (Host is DesktopGameHost desktopHost) - return new StableStorage(desktopHost); + { + string stablePath = getStableInstallPath(); + if (!string.IsNullOrEmpty(stablePath)) + return new DesktopStorage(stablePath, desktopHost); + } } - catch (Exception e) + catch (Exception) { - Logger.Error(e, "Error while searching for stable install"); + Logger.Log("Could not find a stable install", LoggingTarget.Runtime, LogLevel.Important); } return null; } + private string getStableInstallPath() + { + static bool checkExists(string p) => Directory.Exists(Path.Combine(p, "Songs")); + + string stableInstallPath; + + try + { + using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) + stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty).ToString()?.Split('"')[1].Replace("osu!.exe", ""); + + if (checkExists(stableInstallPath)) + return stableInstallPath; + } + catch + { + } + + stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"osu!"); + if (checkExists(stableInstallPath)) + return stableInstallPath; + + stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".osu"); + if (checkExists(stableInstallPath)) + return stableInstallPath; + + return null; + } + + protected override UpdateManager CreateUpdateManager() + { + switch (RuntimeInfo.OS) + { + case RuntimeInfo.Platform.Windows: + return new SquirrelUpdateManager(); + + default: + return new SimpleUpdateManager(); + } + } + protected override void LoadComplete() { base.LoadComplete(); if (!noVersionOverlay) - { - LoadComponentAsync(versionManager = new VersionManager { Depth = int.MinValue }, v => - { - Add(v); - v.Show(); - }); + LoadComponentAsync(versionManager = new VersionManager { Depth = int.MinValue }, Add); - if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) - Add(new SquirrelUpdateManager()); - else - Add(new SimpleUpdateManager()); - } + LoadComponentAsync(new DiscordRichPresence(), Add); + + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) + LoadComponentAsync(new GameplayWinKeyBlocker(), Add); } protected override void ScreenChanged(IScreen lastScreen, IScreen newScreen) @@ -71,7 +110,7 @@ namespace osu.Desktop switch (newScreen) { - case Intro _: + case IntroScreen _: case MainMenu _: versionManager?.Show(); break; @@ -86,66 +125,35 @@ namespace osu.Desktop { base.SetHost(host); - if (host.Window is DesktopGameWindow desktopWindow) + var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); + + switch (host.Window) { - desktopWindow.CursorState |= CursorState.Hidden; + // Legacy osuTK DesktopGameWindow + case OsuTKDesktopWindow desktopGameWindow: + desktopGameWindow.CursorState |= CursorState.Hidden; + desktopGameWindow.SetIconFromStream(iconStream); + desktopGameWindow.Title = Name; + desktopGameWindow.FileDrop += (_, e) => fileDrop(e.FileNames); + break; - desktopWindow.SetIconFromStream(Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico")); - desktopWindow.Title = Name; - - desktopWindow.FileDrop += fileDrop; + // SDL2 DesktopWindow + case DesktopWindow desktopWindow: + desktopWindow.CursorState.Value |= CursorState.Hidden; + desktopWindow.SetIconFromStream(iconStream); + desktopWindow.Title = Name; + desktopWindow.DragDrop += f => fileDrop(new[] { f }); + break; } } - private void fileDrop(object sender, FileDropEventArgs e) + private void fileDrop(string[] filePaths) { - var filePaths = e.FileNames; - var firstExtension = Path.GetExtension(filePaths.First()); if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return; Task.Factory.StartNew(() => Import(filePaths), TaskCreationOptions.LongRunning); } - - /// - /// A method of accessing an osu-stable install in a controlled fashion. - /// - private class StableStorage : WindowsStorage - { - protected override string LocateBasePath() - { - bool checkExists(string p) => Directory.Exists(Path.Combine(p, "Songs")); - - string stableInstallPath; - - try - { - using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) - stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(String.Empty).ToString().Split('"')[1].Replace("osu!.exe", ""); - - if (checkExists(stableInstallPath)) - return stableInstallPath; - } - catch - { - } - - stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"osu!"); - if (checkExists(stableInstallPath)) - return stableInstallPath; - - stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".osu"); - if (checkExists(stableInstallPath)) - return stableInstallPath; - - return null; - } - - public StableStorage(DesktopGameHost host) - : base(string.Empty, host) - { - } - } } } diff --git a/osu.Desktop/Overlays/VersionManager.cs b/osu.Desktop/Overlays/VersionManager.cs index 1f1d2cea5f..8c759f8487 100644 --- a/osu.Desktop/Overlays/VersionManager.cs +++ b/osu.Desktop/Overlays/VersionManager.cs @@ -8,29 +8,18 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game; -using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Overlays; -using osu.Game.Overlays.Notifications; using osuTK; using osuTK.Graphics; namespace osu.Desktop.Overlays { - public class VersionManager : OverlayContainer + public class VersionManager : VisibilityContainer { - private OsuConfigManager config; - private OsuGameBase game; - private NotificationOverlay notificationOverlay; - [BackgroundDependencyLoader] - private void load(NotificationOverlay notification, OsuColour colours, TextureStore textures, OsuGameBase game, OsuConfigManager config) + private void load(OsuColour colours, TextureStore textures, OsuGameBase game) { - notificationOverlay = notification; - this.config = config; - this.game = game; - AutoSizeAxes = Axes.Both; Anchor = Anchor.BottomCentre; Origin = Anchor.BottomCentre; @@ -85,47 +74,6 @@ namespace osu.Desktop.Overlays }; } - protected override void LoadComplete() - { - base.LoadComplete(); - - var version = game.Version; - var lastVersion = config.Get(OsuSetting.Version); - - if (game.IsDeployedBuild && version != lastVersion) - { - config.Set(OsuSetting.Version, version); - - // only show a notification if we've previously saved a version to the config file (ie. not the first run). - if (!string.IsNullOrEmpty(lastVersion)) - notificationOverlay.Post(new UpdateCompleteNotification(version)); - } - } - - private class UpdateCompleteNotification : SimpleNotification - { - private readonly string version; - - public UpdateCompleteNotification(string version) - { - this.version = version; - Text = $"You are now running osu!lazer {version}.\nClick to see what's new!"; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours, ChangelogOverlay changelog) - { - Icon = FontAwesome.Solid.CheckSquare; - IconBackgound.Colour = colours.BlueDark; - - Activated = delegate - { - changelog.ShowBuild(OsuGameBase.CLIENT_STREAM_NAME, version); - return true; - }; - } - } - protected override void PopIn() { this.FadeIn(1400, Easing.OutQuint); diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 141b2cdbbc..285a813d97 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -22,8 +22,9 @@ namespace osu.Desktop { // Back up the cwd before DesktopGameHost changes it var cwd = Environment.CurrentDirectory; + bool useSdl = args.Contains("--sdl"); - using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true)) + using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true, useSdl: useSdl)) { host.ExceptionThrown += handleException; @@ -32,13 +33,11 @@ namespace osu.Desktop if (args.Length > 0 && args[0].Contains('.')) // easy way to check for a file import in args { var importer = new ArchiveImportIPCChannel(host); - // Restore the cwd so relative paths given at the command line work correctly - Directory.SetCurrentDirectory(cwd); foreach (var file in args) { Console.WriteLine(@"Importing {0}", file); - if (!importer.ImportAsync(Path.GetFullPath(file)).Wait(3000)) + if (!importer.ImportAsync(Path.GetFullPath(file, cwd)).Wait(3000)) throw new TimeoutException(@"IPC took too long to send"); } diff --git a/osu.Desktop/Properties/launchSettings.json b/osu.Desktop/Properties/launchSettings.json new file mode 100644 index 0000000000..5e768ec9fa --- /dev/null +++ b/osu.Desktop/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "osu! Desktop": { + "commandName": "Project" + }, + "osu! Tournament": { + "commandName": "Project", + "commandLineArgs": "--tournament" + } + } +} \ No newline at end of file diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index 78a1e680ec..71f9fafe57 100644 --- a/osu.Desktop/Updater/SquirrelUpdateManager.cs +++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs @@ -20,38 +20,53 @@ using LogLevel = Splat.LogLevel; namespace osu.Desktop.Updater { - public class SquirrelUpdateManager : Component + public class SquirrelUpdateManager : osu.Game.Updater.UpdateManager { private UpdateManager updateManager; private NotificationOverlay notificationOverlay; public Task PrepareUpdateAsync() => UpdateManager.RestartAppWhenExited(); + private static readonly Logger logger = Logger.GetLogger("updater"); + + /// + /// Whether an update has been downloaded but not yet applied. + /// + private bool updatePending; + [BackgroundDependencyLoader] - private void load(NotificationOverlay notification, OsuGameBase game) + private void load(NotificationOverlay notification) { notificationOverlay = notification; - if (game.IsDeployedBuild) - { - Splat.Locator.CurrentMutable.Register(() => new SquirrelLogger(), typeof(Splat.ILogger)); - Schedule(() => Task.Run(() => checkForUpdateAsync())); - } + Splat.Locator.CurrentMutable.Register(() => new SquirrelLogger(), typeof(Splat.ILogger)); } - private async void checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification notification = null) + protected override async Task PerformUpdateCheck() => await checkForUpdateAsync(); + + private async Task checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification notification = null) { - //should we schedule a retry on completion of this check? + // should we schedule a retry on completion of this check? bool scheduleRecheck = true; try { - if (updateManager == null) 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); var info = await updateManager.CheckForUpdate(!useDeltaPatching); + if (info.ReleasesToApply.Count == 0) - //no updates available. bail and retry later. - return; + { + if (updatePending) + { + // the user may have dismissed the completion notice, so show it again. + notificationOverlay.Post(new UpdateCompleteNotification(this)); + return true; + } + + // no updates available. bail and retry later. + return false; + } if (notification == null) { @@ -72,16 +87,17 @@ namespace osu.Desktop.Updater await updateManager.ApplyReleases(info, p => notification.Progress = p / 100f); notification.State = ProgressNotificationState.Completed; + updatePending = true; } catch (Exception e) { if (useDeltaPatching) { - Logger.Error(e, @"delta patching failed!"); + logger.Add(@"delta patching failed; will attempt full download!"); - //could fail if deltas are unavailable for full update path (https://github.com/Squirrel/Squirrel.Windows/issues/959) - //try again without deltas. - checkForUpdateAsync(false, notification); + // 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); scheduleRecheck = false; } else @@ -99,10 +115,12 @@ namespace osu.Desktop.Updater { if (scheduleRecheck) { - //check again in 30 minutes. - Scheduler.AddDelayed(() => checkForUpdateAsync(), 60000 * 30); + // check again in 30 minutes. + Scheduler.AddDelayed(async () => await checkForUpdateAsync(), 60000 * 30); } } + + return true; } protected override void Dispose(bool isDisposing) @@ -111,10 +129,27 @@ namespace osu.Desktop.Updater updateManager?.Dispose(); } + private class UpdateCompleteNotification : ProgressCompletionNotification + { + [Resolved] + private OsuGame game { get; set; } + + public UpdateCompleteNotification(SquirrelUpdateManager updateManager) + { + Text = @"Update ready to install. Click to restart!"; + + Activated = () => + { + updateManager.PrepareUpdateAsync() + .ContinueWith(_ => updateManager.Schedule(() => game.GracefullyExit())); + return true; + }; + } + } + private class UpdateProgressNotification : ProgressNotification { private readonly SquirrelUpdateManager updateManager; - private OsuGame game; public UpdateProgressNotification(SquirrelUpdateManager updateManager) { @@ -123,23 +158,12 @@ namespace osu.Desktop.Updater protected override Notification CreateCompletionNotification() { - return new ProgressCompletionNotification - { - Text = @"Update ready to install. Click to restart!", - Activated = () => - { - updateManager.PrepareUpdateAsync() - .ContinueWith(_ => updateManager.Schedule(() => game.GracefullyExit())); - return true; - } - }; + return new UpdateCompleteNotification(updateManager); } [BackgroundDependencyLoader] - private void load(OsuColour colours, OsuGame game) + private void load(OsuColour colours) { - this.game = game; - IconContent.AddRange(new Drawable[] { new Box @@ -163,16 +187,11 @@ namespace osu.Desktop.Updater { public LogLevel Level { get; set; } = LogLevel.Info; - private Logger logger; - public void Write(string message, LogLevel logLevel) { if (logLevel < Level) return; - if (logger == null) - logger = Logger.GetLogger("updater"); - logger.Add(message); } diff --git a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs new file mode 100644 index 0000000000..efc3f21149 --- /dev/null +++ b/osu.Desktop/Windows/GameplayWinKeyBlocker.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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Game; +using osu.Game.Configuration; + +namespace osu.Desktop.Windows +{ + public class GameplayWinKeyBlocker : Component + { + private Bindable disableWinKey; + private Bindable localUserPlaying; + + [Resolved] + private GameHost host { get; set; } + + [BackgroundDependencyLoader] + private void load(OsuGame game, OsuConfigManager config) + { + localUserPlaying = game.LocalUserPlaying.GetBoundCopy(); + localUserPlaying.BindValueChanged(_ => updateBlocking()); + + disableWinKey = config.GetBindable(OsuSetting.GameplayDisableWinKey); + disableWinKey.BindValueChanged(_ => updateBlocking(), true); + } + + private void updateBlocking() + { + bool shouldDisable = disableWinKey.Value && localUserPlaying.Value; + + if (shouldDisable) + host.InputThread.Scheduler.Add(WindowsKey.Disable); + else + host.InputThread.Scheduler.Add(WindowsKey.Enable); + } + } +} diff --git a/osu.Desktop/Windows/WindowsKey.cs b/osu.Desktop/Windows/WindowsKey.cs new file mode 100644 index 0000000000..f19d741107 --- /dev/null +++ b/osu.Desktop/Windows/WindowsKey.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; +using System.Runtime.InteropServices; + +namespace osu.Desktop.Windows +{ + internal class WindowsKey + { + private delegate int LowLevelKeyboardProcDelegate(int nCode, int wParam, ref KdDllHookStruct lParam); + + private static bool isBlocked; + + private const int wh_keyboard_ll = 13; + private const int wm_keydown = 256; + private const int wm_syskeyup = 261; + + //Resharper disable once NotAccessedField.Local + private static LowLevelKeyboardProcDelegate keyboardHookDelegate; // keeping a reference alive for the GC + private static IntPtr keyHook; + + [StructLayout(LayoutKind.Explicit)] + private readonly struct KdDllHookStruct + { + [FieldOffset(0)] + public readonly int VkCode; + + [FieldOffset(8)] + public readonly int Flags; + } + + private static int lowLevelKeyboardProc(int nCode, int wParam, ref KdDllHookStruct lParam) + { + if (wParam >= wm_keydown && wParam <= wm_syskeyup) + { + switch (lParam.VkCode) + { + case 0x5B: // left windows key + case 0x5C: // right windows key + return 1; + } + } + + return callNextHookEx(0, nCode, wParam, ref lParam); + } + + internal static void Disable() + { + if (keyHook != IntPtr.Zero || isBlocked) + return; + + keyHook = setWindowsHookEx(wh_keyboard_ll, (keyboardHookDelegate = lowLevelKeyboardProc), Marshal.GetHINSTANCE(System.Reflection.Assembly.GetExecutingAssembly().GetModules()[0]), 0); + + isBlocked = true; + } + + internal static void Enable() + { + if (keyHook == IntPtr.Zero || !isBlocked) + return; + + keyHook = unhookWindowsHookEx(keyHook); + keyboardHookDelegate = null; + + keyHook = IntPtr.Zero; + + isBlocked = false; + } + + [DllImport(@"user32.dll", EntryPoint = @"SetWindowsHookExA")] + private static extern IntPtr setWindowsHookEx(int idHook, LowLevelKeyboardProcDelegate lpfn, IntPtr hMod, int dwThreadId); + + [DllImport(@"user32.dll", EntryPoint = @"UnhookWindowsHookEx")] + private static extern IntPtr unhookWindowsHookEx(IntPtr hHook); + + [DllImport(@"user32.dll", EntryPoint = @"CallNextHookEx")] + private static extern int callNextHookEx(int hHook, int nCode, int wParam, ref KdDllHookStruct lParam); + } +} diff --git a/osu.Desktop/app.manifest b/osu.Desktop/app.manifest new file mode 100644 index 0000000000..2e9127bf44 --- /dev/null +++ b/osu.Desktop/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + true + + + \ No newline at end of file diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index 8c9e1f279f..62e8f7c518 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -1,15 +1,14 @@  - - netcoreapp2.2 + netcoreapp3.1 WinExe - AnyCPU true - click the circles. to the beat. + A free-to-win rhythm game. Rhythm is just a *click* away! osu! osu!lazer osu!lazer lazer.ico + app.manifest 0.0.0 0.0.0 @@ -23,13 +22,18 @@ - - + - - + + + + + + + + diff --git a/osu.Desktop/osu.nuspec b/osu.Desktop/osu.nuspec index a26b35fcd5..2fc6009183 100644 --- a/osu.Desktop/osu.nuspec +++ b/osu.Desktop/osu.nuspec @@ -9,10 +9,9 @@ https://osu.ppy.sh/ https://puu.sh/tYyXZ/9a01a5d1b0.ico false - click the circles. to the beat. - click the circles. + A free-to-win rhythm game. Rhythm is just a *click* away! testing - Copyright (c) 2019 ppy Pty Ltd + Copyright (c) 2020 ppy Pty Ltd en-AU diff --git a/osu.Game.Benchmarks/BenchmarkBeatmapParsing.cs b/osu.Game.Benchmarks/BenchmarkBeatmapParsing.cs new file mode 100644 index 0000000000..1d207d04c7 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkBeatmapParsing.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.IO; +using BenchmarkDotNet.Attributes; +using osu.Framework.IO.Stores; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.Tests.Resources; + +namespace osu.Game.Benchmarks +{ + public class BenchmarkBeatmapParsing : BenchmarkTest + { + private readonly MemoryStream beatmapStream = new MemoryStream(); + + public override void SetUp() + { + using (var resources = new DllResourceStore(typeof(TestResources).Assembly)) + using (var archive = resources.GetStream("Resources/Archives/241526 Soleily - Renatus.osz")) + using (var reader = new ZipArchiveReader(archive)) + reader.GetStream("Soleily - Renatus (Gamu) [Insane].osu").CopyTo(beatmapStream); + } + + [Benchmark] + public Beatmap BenchmarkBundledBeatmap() + { + beatmapStream.Seek(0, SeekOrigin.Begin); + var reader = new LineBufferedReader(beatmapStream); // no disposal + + var decoder = Decoder.GetDecoder(reader); + return decoder.Decode(reader); + } + } +} diff --git a/osu.Game.Benchmarks/BenchmarkTest.cs b/osu.Game.Benchmarks/BenchmarkTest.cs new file mode 100644 index 0000000000..34f5edd084 --- /dev/null +++ b/osu.Game.Benchmarks/BenchmarkTest.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 BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Running; +using NUnit.Framework; + +namespace osu.Game.Benchmarks +{ + [TestFixture] + [MemoryDiagnoser] + public abstract class BenchmarkTest + { + [GlobalSetup] + [OneTimeSetUp] + public virtual void SetUp() + { + } + + [Test] + public void RunBenchmark() => BenchmarkRunner.Run(GetType()); + } +} diff --git a/osu.Game.Benchmarks/Program.cs b/osu.Game.Benchmarks/Program.cs new file mode 100644 index 0000000000..c55075fea6 --- /dev/null +++ b/osu.Game.Benchmarks/Program.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 BenchmarkDotNet.Running; + +namespace osu.Game.Benchmarks +{ + public static class Program + { + public static void Main(string[] args) + { + BenchmarkSwitcher + .FromAssembly(typeof(Program).Assembly) + .Run(args); + } + } +} diff --git a/osu.Game.Benchmarks/Properties/launchSettings.json b/osu.Game.Benchmarks/Properties/launchSettings.json new file mode 100644 index 0000000000..c1868088f9 --- /dev/null +++ b/osu.Game.Benchmarks/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "All Benchmarks": { + "commandName": "Project", + "commandLineArgs": "--filter *" + } + } +} \ No newline at end of file diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj new file mode 100644 index 0000000000..ff26f4afaa --- /dev/null +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + Exe + false + + + + + + + + + + + + + + diff --git a/osu.Game.Rulesets.Catch.Tests.Android/Properties/AndroidManifest.xml b/osu.Game.Rulesets.Catch.Tests.Android/Properties/AndroidManifest.xml index db95e18f13..f8c3fcd894 100644 --- a/osu.Game.Rulesets.Catch.Tests.Android/Properties/AndroidManifest.xml +++ b/osu.Game.Rulesets.Catch.Tests.Android/Properties/AndroidManifest.xml @@ -1,5 +1,6 @@  - - + + + \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch.Tests.iOS/Application.cs b/osu.Game.Rulesets.Catch.Tests.iOS/Application.cs index beca477943..f7f07ef938 100644 --- a/osu.Game.Rulesets.Catch.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Catch.Tests.iOS/Application.cs @@ -5,7 +5,7 @@ using UIKit; namespace osu.Game.Rulesets.Catch.Tests.iOS { - public class Application + public static class Application { public static void Main(string[] args) { diff --git a/osu.Game.Rulesets.Catch.Tests.iOS/osu.Game.Rulesets.Catch.Tests.iOS.csproj b/osu.Game.Rulesets.Catch.Tests.iOS/osu.Game.Rulesets.Catch.Tests.iOS.csproj index 37e7c45a4e..be6044bbd0 100644 --- a/osu.Game.Rulesets.Catch.Tests.iOS/osu.Game.Rulesets.Catch.Tests.iOS.csproj +++ b/osu.Game.Rulesets.Catch.Tests.iOS/osu.Game.Rulesets.Catch.Tests.iOS.csproj @@ -1,6 +1,5 @@ - + - Debug iPhoneSimulator @@ -13,14 +12,6 @@ - - libbass.a - PreserveNewest - - - libbass_fx.a - PreserveNewest - Linker.xml @@ -41,5 +32,4 @@ - \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch.Tests/.vscode/launch.json b/osu.Game.Rulesets.Catch.Tests/.vscode/launch.json index 5dfaf5ec39..67d27c33eb 100644 --- a/osu.Game.Rulesets.Catch.Tests/.vscode/launch.json +++ b/osu.Game.Rulesets.Catch.Tests/.vscode/launch.json @@ -7,7 +7,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/bin/Debug/netcoreapp2.2/osu.Game.Rulesets.Catch.Tests.dll" + "${workspaceRoot}/bin/Debug/netcoreapp3.1/osu.Game.Rulesets.Catch.Tests.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build (Debug)", @@ -20,7 +20,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/bin/Release/netcoreapp2.2/osu.Game.Rulesets.Catch.Tests.dll" + "${workspaceRoot}/bin/Release/netcoreapp3.1/osu.Game.Rulesets.Catch.Tests.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build (Release)", diff --git a/osu.Game.Rulesets.Catch.Tests/.vscode/tasks.json b/osu.Game.Rulesets.Catch.Tests/.vscode/tasks.json index 18a6f8ca70..2c915a31b7 100644 --- a/osu.Game.Rulesets.Catch.Tests/.vscode/tasks.json +++ b/osu.Game.Rulesets.Catch.Tests/.vscode/tasks.json @@ -11,9 +11,9 @@ "build", "--no-restore", "osu.Game.Rulesets.Catch.Tests.csproj", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" @@ -26,10 +26,10 @@ "build", "--no-restore", "osu.Game.Rulesets.Catch.Tests.csproj", - "/p:Configuration=Release", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" diff --git a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs index e45ed8c6f4..466cbdaf8d 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs @@ -5,15 +5,16 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; using NUnit.Framework; -using osu.Framework.MathUtils; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] + [Timeout(10000)] public class CatchBeatmapConversionTest : BeatmapConversionTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Catch"; @@ -22,10 +23,11 @@ namespace osu.Game.Rulesets.Catch.Tests [TestCase("spinner")] [TestCase("spinner-and-circles")] [TestCase("slider")] - public new void Test(string name) - { - base.Test(name); - } + [TestCase("hardrock-stream", new[] { typeof(CatchModHardRock) })] + [TestCase("hardrock-repeat-slider", new[] { typeof(CatchModHardRock) })] + [TestCase("hardrock-spinner", new[] { typeof(CatchModHardRock) })] + [TestCase("right-bound-hr-offset", new[] { typeof(CatchModHardRock) })] + public new void Test(string name, params Type[] mods) => base.Test(name, mods); protected override IEnumerable CreateConvertValue(HitObject hitObject) { @@ -82,7 +84,7 @@ namespace osu.Game.Rulesets.Catch.Tests public float Position { - get => HitObject?.X * CatchPlayfield.BASE_WIDTH ?? position; + get => HitObject?.X ?? position; set => position = value; } diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs index 51fe0b035d..ee416e5a38 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Catch.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Catch"; - [TestCase(4.2058561036909863d, "diffcalc-test")] + [TestCase(4.050601681491468d, "diffcalc-test")] public void Test(double expected, string name) => base.Test(expected, name); diff --git a/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.cs new file mode 100644 index 0000000000..04e6dea376 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/CatchLegacyModConversionTest.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; +using NUnit.Framework; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Catch.Tests +{ + [TestFixture] + public class CatchLegacyModConversionTest : LegacyModConversionTest + { + [TestCase(LegacyMods.Easy, new[] { typeof(CatchModEasy) })] + [TestCase(LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(CatchModHardRock), typeof(CatchModDoubleTime) })] + [TestCase(LegacyMods.DoubleTime, new[] { typeof(CatchModDoubleTime) })] + [TestCase(LegacyMods.Nightcore, new[] { typeof(CatchModNightcore) })] + [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(CatchModNightcore) })] + [TestCase(LegacyMods.Flashlight | LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(CatchModFlashlight), typeof(CatchModNightcore) })] + [TestCase(LegacyMods.Perfect, new[] { typeof(CatchModPerfect) })] + [TestCase(LegacyMods.SuddenDeath, new[] { typeof(CatchModSuddenDeath) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(CatchModPerfect) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath | LegacyMods.DoubleTime, new[] { typeof(CatchModDoubleTime), typeof(CatchModPerfect) })] + public new void Test(LegacyMods legacyMods, Type[] expectedMods) => base.Test(legacyMods, expectedMods); + + protected override Ruleset CreateRuleset() => new CatchRuleset(); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs new file mode 100644 index 0000000000..b570f090ca --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.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 NUnit.Framework; +using osu.Framework.IO.Stores; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Tests +{ + [TestFixture] + public class CatchSkinColourDecodingTest + { + [Test] + public void TestCatchSkinColourDecoding() + { + var store = new NamespacedResourceStore(new DllResourceStore(GetType().Assembly), "Resources/special-skin"); + var rawSkin = new TestLegacySkin(new SkinInfo { Name = "special-skin" }, store); + var skinSource = new SkinProvidingContainer(rawSkin); + var skin = new CatchLegacySkinTransformer(skinSource); + + Assert.AreEqual(new Color4(232, 185, 35, 255), skin.GetConfig(CatchSkinColour.HyperDash)?.Value); + Assert.AreEqual(new Color4(232, 74, 35, 255), skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value); + Assert.AreEqual(new Color4(0, 255, 255, 255), skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value); + } + + private class TestLegacySkin : LegacySkin + { + public TestLegacySkin(SkinInfo skin, IResourceStore storage) + // Bypass LegacySkinResourceStore to avoid returning null for retrieving files due to bad skin info (SkinInfo.Files = null). + : base(skin, storage, null, "skin.ini") + { + } + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/CatchSkinnableTestScene.cs b/osu.Game.Rulesets.Catch.Tests/CatchSkinnableTestScene.cs new file mode 100644 index 0000000000..378772fea3 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/CatchSkinnableTestScene.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.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public abstract class CatchSkinnableTestScene : SkinnableTestScene + { + protected override Ruleset CreateRulesetForSkinProvider() => new CatchRuleset(); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs new file mode 100644 index 0000000000..3e06e78dba --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModPerfect.cs @@ -0,0 +1,56 @@ +// 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.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests.Mods +{ + public class TestSceneCatchModPerfect : ModPerfectTestScene + { + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + + public TestSceneCatchModPerfect() + : base(new CatchModPerfect()) + { + } + + [TestCase(false)] + [TestCase(true)] + public void TestBananaShower(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new BananaShower { StartTime = 1000, EndTime = 3000 }, false), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestFruit(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Fruit { StartTime = 1000 }), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestJuiceStream(bool shouldMiss) + { + var stream = new JuiceStream + { + StartTime = 1000, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(100, 0), + }) + }; + + CreateHitObjectTest(new HitObjectTestData(stream), shouldMiss); + } + + // We only care about testing misses, hits are tested via JuiceStream + [TestCase(true)] + public void TestDroplet(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Droplet { StartTime = 1000 }), shouldMiss); + + // We only care about testing misses, hits are tested via JuiceStream + [TestCase(true)] + public void TestTinyDroplet(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new TinyDroplet { StartTime = 1000 }), shouldMiss); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.cs new file mode 100644 index 0000000000..1eb0975010 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModRelax.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. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests.Mods +{ + public class TestSceneCatchModRelax : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + + [Test] + public void TestModRelax() => CreateModTest(new ModTestData + { + Mod = new CatchModRelax(), + Autoplay = false, + PassCondition = passCondition, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Fruit + { + X = CatchPlayfield.CENTER_X, + StartTime = 0 + }, + new Fruit + { + X = 0, + StartTime = 250 + }, + new Fruit + { + X = CatchPlayfield.WIDTH, + StartTime = 500 + }, + new JuiceStream + { + X = CatchPlayfield.CENTER_X, + StartTime = 750, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, Vector2.UnitY * 200 }) + } + } + } + }); + + private bool passCondition() + { + var playfield = this.ChildrenOfType().Single(); + + switch (Player.ScoreProcessor.Combo.Value) + { + case 0: + InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre); + break; + + case 1: + InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.BottomLeft); + break; + + case 2: + InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.BottomRight); + break; + + case 3: + InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre); + break; + } + + return Player.ScoreProcessor.Combo.Value >= 6; + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple-overlay@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple-overlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple-overlay@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-apple@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas-overlay@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas-overlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas-overlay@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-bananas@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-catcher-idle@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-catcher-idle@2x.png new file mode 100644 index 0000000000..76949ccfcc Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-catcher-idle@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-drop@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-drop@2x.png new file mode 100644 index 0000000000..ec2fdbdbdb Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-drop@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes-overlay@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes-overlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes-overlay@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-grapes@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange-overlay@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange-overlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange-overlay@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-orange@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear-overlay@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear-overlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear-overlay@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/metrics-skin/fruit-pear@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple-overlay.png new file mode 100644 index 0000000000..8d9608cfc9 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple.png new file mode 100644 index 0000000000..be1bda0383 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-apple.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas-overlay.png new file mode 100644 index 0000000000..3a6612378e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas.png new file mode 100644 index 0000000000..afb8698b2d Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-bananas.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-drop.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-drop.png new file mode 100644 index 0000000000..12c74f46e2 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-drop.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes-overlay.png new file mode 100644 index 0000000000..bb37ba1920 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes.png new file mode 100644 index 0000000000..10699b1f31 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-grapes.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange-overlay.png new file mode 100644 index 0000000000..e86aa6e7e3 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange.png new file mode 100644 index 0000000000..42cc80399f Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-orange.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear-overlay.png new file mode 100644 index 0000000000..5c479da954 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear.png new file mode 100644 index 0000000000..9fe400bdd1 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-pear.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-plate.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-plate.png new file mode 100644 index 0000000000..1da1fdde85 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-plate.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-ryuuta.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-ryuuta.png new file mode 100644 index 0000000000..f732092379 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/fruit-ryuuta.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit0.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit0.png new file mode 100644 index 0000000000..2d312ceefd Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit0.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit100.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit100.png new file mode 100644 index 0000000000..7884dc072d Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit100.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit300.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit300.png new file mode 100644 index 0000000000..3e4ec2e047 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit300.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit50.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit50.png new file mode 100644 index 0000000000..f02ad11a17 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/hit50.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-0.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-0.png new file mode 100644 index 0000000000..8304617d8c Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-0.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-1.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-1.png new file mode 100644 index 0000000000..c3b85eb873 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-1.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-2.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-2.png new file mode 100644 index 0000000000..7f65eb7ca7 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-2.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-3.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-3.png new file mode 100644 index 0000000000..82bec3babe Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-3.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-4.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-4.png new file mode 100644 index 0000000000..5e38c75a9d Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-4.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-5.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-5.png new file mode 100644 index 0000000000..a562d9f2ac Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-5.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-6.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-6.png new file mode 100644 index 0000000000..b4cf81f26e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-6.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-7.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-7.png new file mode 100644 index 0000000000..a23f5379b2 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-7.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-8.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-8.png new file mode 100644 index 0000000000..430b18509d Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-8.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-9.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-9.png new file mode 100644 index 0000000000..add1202c31 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-9.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple-overlay.png new file mode 100755 index 0000000000..fe567d158d Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple.png new file mode 100755 index 0000000000..17f3be9c26 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-apple.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas-overlay.png new file mode 100755 index 0000000000..2c94ea78bf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas.png new file mode 100755 index 0000000000..2c94ea78bf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-bananas.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-fail@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-fail@2x.png new file mode 100755 index 0000000000..1eea5c2083 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-fail@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-0@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-0@2x.png new file mode 100644 index 0000000000..786e5cc25a Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-0@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-1@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-1@2x.png new file mode 100644 index 0000000000..e93530fb16 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-1@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-2@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-2@2x.png new file mode 100644 index 0000000000..6f51257742 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-2@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-3@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-3@2x.png new file mode 100644 index 0000000000..953a04d4e4 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-3@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-4@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-4@2x.png new file mode 100644 index 0000000000..66a3cf9e0b Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-4@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-5@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-5@2x.png new file mode 100644 index 0000000000..ec4487f8fb Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-idle-5@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-kiai@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-kiai@2x.png new file mode 100755 index 0000000000..31be03b014 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-catcher-kiai@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop-overlay.png new file mode 100755 index 0000000000..56bf4a92fb Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop.png new file mode 100755 index 0000000000..f259684055 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-drop.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes-overlay.png new file mode 100755 index 0000000000..17f3be9c26 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes.png new file mode 100755 index 0000000000..3dc60464cf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-grapes.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange-overlay.png new file mode 100755 index 0000000000..3dc60464cf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange.png new file mode 100755 index 0000000000..3dc60464cf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-orange.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear-overlay.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear-overlay.png new file mode 100755 index 0000000000..3dc60464cf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear-overlay.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear.png new file mode 100755 index 0000000000..3dc60464cf Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/fruit-pear.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-0@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-0@2x.png new file mode 100644 index 0000000000..508cc85e4a Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-0@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-1@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-1@2x.png new file mode 100644 index 0000000000..84f74e1ec9 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-1@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-2@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-2@2x.png new file mode 100644 index 0000000000..49625c6623 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-2@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-3@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-3@2x.png new file mode 100644 index 0000000000..623b24612f Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-3@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-4@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-4@2x.png new file mode 100644 index 0000000000..a33286dc8f Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-4@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-5@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-5@2x.png new file mode 100644 index 0000000000..d8250b0c63 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-5@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-6@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-6@2x.png new file mode 100644 index 0000000000..75d3cbd3bd Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-6@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-7@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-7@2x.png new file mode 100644 index 0000000000..cfe2021df4 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-7@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-8@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-8@2x.png new file mode 100644 index 0000000000..ba9492c7f8 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-8@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-9@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-9@2x.png new file mode 100644 index 0000000000..a7b6b81570 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-9@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/skin.ini b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/skin.ini new file mode 100644 index 0000000000..96d50f1451 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/skin.ini @@ -0,0 +1,4 @@ +[CatchTheBeat] +HyperDash: 232,185,35 +HyperDashFruit: 0,255,255 +HyperDashAfterImage: 232,74,35 diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs index ab3c040b4e..3c636a5b97 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs @@ -1,25 +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 System.Linq; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; -using osu.Game.Screens.Play; using osu.Game.Tests.Visual; using osuTK; namespace osu.Game.Rulesets.Catch.Tests { - public class TestSceneAutoJuiceStream : PlayerTestScene + public class TestSceneAutoJuiceStream : TestSceneCatchPlayer { - public TestSceneAutoJuiceStream() - : base(new CatchRuleset()) - { - } - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = new Beatmap @@ -33,27 +29,31 @@ namespace osu.Game.Rulesets.Catch.Tests for (int i = 0; i < 100; i++) { - float width = (i % 10 + 1) / 20f; + float width = (i % 10 + 1) / 20f * CatchPlayfield.WIDTH; beatmap.HitObjects.Add(new JuiceStream { - X = 0.5f - width / 2, + X = CatchPlayfield.CENTER_X - width / 2, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, - new Vector2(width * CatchPlayfield.BASE_WIDTH, 0) + new Vector2(width, 0) }), StartTime = i * 2000, - NewCombo = i % 8 == 0 + NewCombo = i % 8 == 0, + Samples = new List(new[] + { + new HitSampleInfo { Bank = "normal", Name = "hitnormal", Volume = 100 } + }) }); } return beatmap; } - protected override Player CreatePlayer(Ruleset ruleset) + protected override TestPlayer CreatePlayer(Ruleset ruleset) { - Mods.Value = Mods.Value.Concat(new[] { ruleset.GetAutoplayMod() }).ToArray(); + SelectedMods.Value = SelectedMods.Value.Concat(new[] { ruleset.GetAutoplayMod() }).ToArray(); return base.CreatePlayer(ruleset); } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs index 0ad72412fc..e89a95ae37 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneBananaShower.cs @@ -1,32 +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; -using System.Collections.Generic; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; -using osu.Game.Rulesets.Catch.UI; -using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneBananaShower : PlayerTestScene + public class TestSceneBananaShower : TestSceneCatchPlayer { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(BananaShower), - typeof(DrawableBananaShower), - - typeof(CatchRuleset), - typeof(DrawableCatchRuleset), - }; - - public TestSceneBananaShower() - : base(new CatchRuleset()) + [Test] + public void TestBananaShower() { + AddUntilStep("player is done", () => !Player.ValidForResume); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) @@ -40,7 +27,7 @@ namespace osu.Game.Rulesets.Catch.Tests } }; - beatmap.HitObjects.Add(new BananaShower { StartTime = 200, Duration = 5000, NewCombo = true }); + beatmap.HitObjects.Add(new BananaShower { StartTime = 200, Duration = 3000, NewCombo = true }); return beatmap; } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs new file mode 100644 index 0000000000..f15da29993 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs @@ -0,0 +1,56 @@ +// 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.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneCatchModHidden : ModTestScene + { + [BackgroundDependencyLoader] + private void load() + { + LocalConfig.Set(OsuSetting.IncreaseFirstObjectVisibility, false); + } + + [Test] + public void TestJuiceStream() + { + CreateModTest(new ModTestData + { + Beatmap = new Beatmap + { + HitObjects = new List + { + new JuiceStream + { + StartTime = 1000, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(0, -192) }), + X = CatchPlayfield.WIDTH / 2 + } + } + }, + Mod = new CatchModHidden(), + PassCondition = () => Player.Results.Count > 0 + && Player.ChildrenOfType().Single().Alpha > 0 + && Player.ChildrenOfType().Last().Alpha > 0 + }); + } + + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs index 9836a7811a..31d0831fae 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchPlayer.cs @@ -9,9 +9,6 @@ namespace osu.Game.Rulesets.Catch.Tests [TestFixture] public class TestSceneCatchPlayer : PlayerTestScene { - public TestSceneCatchPlayer() - : base(new CatchRuleset()) - { - } + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs index 9ce46ad6ba..1ff31697b8 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchStacker.cs @@ -4,18 +4,13 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Tests.Visual; +using osu.Game.Rulesets.Catch.UI; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatchStacker : PlayerTestScene + public class TestSceneCatchStacker : TestSceneCatchPlayer { - public TestSceneCatchStacker() - : base(new CatchRuleset()) - { - } - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = new Beatmap @@ -28,7 +23,14 @@ namespace osu.Game.Rulesets.Catch.Tests }; for (int i = 0; i < 512; i++) - beatmap.HitObjects.Add(new Fruit { X = 0.5f + i / 2048f * (i % 10 - 5), StartTime = i * 100, NewCombo = i % 8 == 0 }); + { + beatmap.HitObjects.Add(new Fruit + { + X = (0.5f + i / 2048f * (i % 10 - 5)) * CatchPlayfield.WIDTH, + StartTime = i * 100, + NewCombo = i % 8 == 0 + }); + } return beatmap; } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 33f93cdb4a..6eeda2c731 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -3,103 +3,24 @@ using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics; using osu.Game.Rulesets.Catch.UI; -using osu.Game.Tests.Visual; -using System; -using System.Collections.Generic; -using osu.Game.Skinning; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osuTK.Graphics; -using osu.Framework.Audio.Sample; -using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatcher : OsuTestScene + public class TestSceneCatcher : CatchSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CatcherSprite), - }; - - private readonly Container container; - - public TestSceneCatcher() - { - Child = container = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; - } - [BackgroundDependencyLoader] private void load() { - AddStep("show default catcher implementation", () => { container.Child = new CatcherSprite(); }); - - AddStep("show custom catcher implementation", () => + SetContents(() => new Catcher(new Container()) { - container.Child = new CatchCustomSkinSourceContainer - { - Child = new CatcherSprite() - }; + RelativePositionAxes = Axes.None, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, }); } - - private class CatcherCustomSkin : Container - { - public CatcherCustomSkin() - { - RelativeSizeAxes = Axes.Both; - - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Blue - }, - new SpriteText - { - Text = "custom" - } - }; - } - } - - [Cached(typeof(ISkinSource))] - private class CatchCustomSkinSourceContainer : Container, ISkinSource - { - public event Action SourceChanged - { - add { } - remove { } - } - - public Drawable GetDrawableComponent(string componentName) - { - switch (componentName) - { - case "Play/Catch/fruit-catcher-idle": - return new CatcherCustomSkin(); - } - - return null; - } - - public SampleChannel GetSample(string sampleName) => - throw new NotImplementedException(); - - public Texture GetTexture(string componentName) => - throw new NotImplementedException(); - - public TValue GetValue(Func query) where TConfiguration : SkinConfiguration => - throw new NotImplementedException(); - } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 3ae6886c31..e055f08dc2 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -1,45 +1,111 @@ // 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.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.Judgements; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.UI; -using osu.Game.Tests.Visual; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatcherArea : OsuTestScene + public class TestSceneCatcherArea : CatchSkinnableTestScene { private RulesetInfo catchRuleset; - private TestCatcherArea catcherArea; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CatcherArea), - }; + [Resolved] + private OsuConfigManager config { get; set; } + + private Catcher catcher => this.ChildrenOfType().First().MovableCatcher; public TestSceneCatcherArea() { AddSliderStep("CircleSize", 0, 8, 5, createCatcher); - AddToggleStep("Hyperdash", t => catcherArea.ToggleHyperDash(t)); + AddToggleStep("Hyperdash", t => + CreatedDrawables.OfType().Select(i => i.Child) + .OfType().ForEach(c => c.ToggleHyperDash(t))); + + AddRepeatStep("catch fruit", () => catchFruit(new TestFruit(false) + { + X = catcher.X + }), 20); + AddRepeatStep("catch fruit last in combo", () => catchFruit(new TestFruit(false) + { + X = catcher.X, + LastInCombo = true, + }), 20); + AddRepeatStep("catch kiai fruit", () => catchFruit(new TestFruit(true) + { + X = catcher.X + }), 20); + AddRepeatStep("miss fruit", () => catchFruit(new Fruit + { + X = catcher.X + 100, + LastInCombo = true, + }, true), 20); + } + + [TestCase(true)] + [TestCase(false)] + public void TestHitLighting(bool enable) + { + AddStep("create catcher", () => createCatcher(5)); + + AddStep("toggle hit lighting", () => config.Set(OsuSetting.HitLighting, enable)); + AddStep("catch fruit", () => catchFruit(new TestFruit(false) + { + X = catcher.X + })); + AddStep("catch fruit last in combo", () => catchFruit(new TestFruit(false) + { + X = catcher.X, + LastInCombo = true + })); + AddAssert("check hit explosion", () => catcher.ChildrenOfType().Any() == enable); + } + + private void catchFruit(Fruit fruit, bool miss = false) + { + this.ChildrenOfType().ForEach(area => + { + DrawableFruit drawable = new DrawableFruit(fruit); + area.Add(drawable); + + Schedule(() => + { + area.AttemptCatch(fruit); + area.OnNewResult(drawable, new JudgementResult(fruit, new CatchJudgement()) { Type = miss ? HitResult.Miss : HitResult.Great }); + + drawable.Expire(); + }); + }); } private void createCatcher(float size) { - Child = new CatchInputManager(catchRuleset) + SetContents(() => new CatchInputManager(catchRuleset) { RelativeSizeAxes = Axes.Both, - Child = catcherArea = new TestCatcherArea(new BeatmapDifficulty { CircleSize = size }) + Child = new TestCatcherArea(new BeatmapDifficulty { CircleSize = size }) { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopLeft + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + CreateDrawableRepresentation = ((DrawableRuleset)catchRuleset.CreateInstance().CreateDrawableRulesetWith(new CatchBeatmap())).CreateDrawableRepresentation }, - }; + }); } [BackgroundDependencyLoader] @@ -48,6 +114,17 @@ namespace osu.Game.Rulesets.Catch.Tests catchRuleset = rulesets.GetRuleset(2); } + public class TestFruit : Fruit + { + public TestFruit(bool kiai) + { + var kiaiCpi = new ControlPointInfo(); + kiaiCpi.Add(0, new EffectControlPoint { KiaiMode = kiai }); + + ApplyDefaultsToSelf(kiaiCpi, new BeatmapDifficulty()); + } + } + private class TestCatcherArea : CatcherArea { public TestCatcherArea(BeatmapDifficulty beatmapDifficulty) @@ -55,6 +132,8 @@ namespace osu.Game.Rulesets.Catch.Tests { } + public new Catcher MovableCatcher => base.MovableCatcher; + public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1); } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs new file mode 100644 index 0000000000..c7b322c8a0 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.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 System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneComboCounter : CatchSkinnableTestScene + { + private ScoreProcessor scoreProcessor; + + private Color4 judgedObjectColour = Color4.White; + + [SetUp] + public void SetUp() => Schedule(() => + { + scoreProcessor = new ScoreProcessor(); + + SetContents(() => new CatchComboDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2.5f), + }); + }); + + [Test] + public void TestCatchComboCounter() + { + AddRepeatStep("perform hit", () => performJudgement(HitResult.Great), 20); + AddStep("perform miss", () => performJudgement(HitResult.Miss)); + + AddStep("randomize judged object colour", () => + { + judgedObjectColour = new Color4( + RNG.NextSingle(1f), + RNG.NextSingle(1f), + RNG.NextSingle(1f), + 1f + ); + }); + } + + private void performJudgement(HitResult type, Judgement judgement = null) + { + var judgedObject = new DrawableFruit(new Fruit()) { AccentColour = { Value = judgedObjectColour } }; + + var result = new JudgementResult(judgedObject.HitObject, judgement ?? new Judgement()) { Type = type }; + scoreProcessor.ApplyResult(result); + + foreach (var counter in CreatedDrawables.Cast()) + counter.OnNewResult(judgedObject, result); + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs index 7a9b61c60c..d35f828e28 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs @@ -1,16 +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.Linq; -using osu.Framework.Allocation; +using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -22,23 +21,14 @@ namespace osu.Game.Rulesets.Catch.Tests { public class TestSceneDrawableHitObjects : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CatcherArea.Catcher), - typeof(DrawableCatchRuleset), - typeof(DrawableFruit), - typeof(DrawableJuiceStream), - typeof(DrawableBanana) - }; - private DrawableCatchRuleset drawableRuleset; private double playfieldTime => drawableRuleset.Playfield.Time.Current; - [BackgroundDependencyLoader] - private void load() + [SetUp] + public void Setup() => Schedule(() => { var controlPointInfo = new ControlPointInfo(); - controlPointInfo.TimingPoints.Add(new TimingControlPoint()); + controlPointInfo.Add(0, new TimingControlPoint()); WorkingBeatmap beatmap = CreateWorkingBeatmap(new Beatmap { @@ -57,25 +47,58 @@ namespace osu.Game.Rulesets.Catch.Tests ControlPointInfo = controlPointInfo }); - Add(new Container + Child = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Children = new[] { - drawableRuleset = new DrawableCatchRuleset(new CatchRuleset(), beatmap, Array.Empty()) + drawableRuleset = new DrawableCatchRuleset(new CatchRuleset(), beatmap.GetPlayableBeatmap(new CatchRuleset().RulesetInfo)) } - }); + }; + }); + + [Test] + public void TestFruits() + { + AddStep("hit fruits", () => spawnFruits(true)); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is idle", () => catcherState == CatcherAnimationState.Idle); AddStep("miss fruits", () => spawnFruits()); - AddStep("hit fruits", () => spawnFruits(true)); - AddStep("miss juicestream", () => spawnJuiceStream()); - AddStep("hit juicestream", () => spawnJuiceStream(true)); - AddStep("miss bananas", () => spawnBananas()); - AddStep("hit bananas", () => spawnBananas(true)); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is failed", () => catcherState == CatcherAnimationState.Fail); } + [Test] + public void TestJuicestream() + { + AddStep("hit juicestream", () => spawnJuiceStream(true)); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is idle", () => catcherState == CatcherAnimationState.Idle); + + AddStep("miss juicestream", () => spawnJuiceStream()); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is failed", () => catcherState == CatcherAnimationState.Fail); + } + + [Test] + public void TestBananas() + { + AddStep("hit bananas", () => spawnBananas(true)); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is idle", () => catcherState == CatcherAnimationState.Idle); + + AddStep("miss bananas", () => spawnBananas()); + AddUntilStep("wait for completion", () => playfieldIsEmpty); + AddAssert("catcher state is idle", () => catcherState == CatcherAnimationState.Idle); + } + + private bool playfieldIsEmpty => !((CatchPlayfield)drawableRuleset.Playfield).AllHitObjects.Any(h => h.IsAlive); + + private CatcherAnimationState catcherState => ((CatchPlayfield)drawableRuleset.Playfield).CatcherArea.MovableCatcher.CurrentState; + private void spawnFruits(bool hit = false) { for (int i = 1; i <= 4; i++) @@ -135,8 +158,8 @@ namespace osu.Game.Rulesets.Catch.Tests private float getXCoords(bool hit) { - const float x_offset = 0.2f; - float xCoords = drawableRuleset.Playfield.Width / 2; + const float x_offset = 0.2f * CatchPlayfield.WIDTH; + float xCoords = CatchPlayfield.CENTER_X; if (drawableRuleset.Playfield is CatchPlayfield catchPlayfield) catchPlayfield.CatcherArea.MovableCatcher.X = xCoords - x_offset; @@ -151,7 +174,7 @@ namespace osu.Game.Rulesets.Catch.Tests private void addToPlayfield(DrawableCatchHitObject drawable) { - foreach (var mod in Mods.Value.OfType()) + foreach (var mod in SelectedMods.Value.OfType()) mod.ApplyToDrawableHitObjects(new[] { drawable }); drawableRuleset.Playfield.Add(drawable); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs index f6d26addaa..62fe5dca2c 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjectsHidden.cs @@ -1,20 +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; -using System.Collections.Generic; -using System.Linq; +using NUnit.Framework; using osu.Game.Rulesets.Catch.Mods; namespace osu.Game.Rulesets.Catch.Tests { public class TestSceneDrawableHitObjectsHidden : TestSceneDrawableHitObjects { - public override IReadOnlyList RequiredTypes => base.RequiredTypes.Concat(new[] { typeof(CatchModHidden) }).ToList(); - - public TestSceneDrawableHitObjectsHidden() + [SetUp] + public void SetUp() => Schedule(() => { - Mods.Value = new[] { new CatchModHidden() }; - } + SelectedMods.Value = new[] { new CatchModHidden() }; + }); } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs index 44517382f7..385d8ed7fa 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs @@ -2,81 +2,114 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; -using osu.Game.Rulesets.Catch.Objects.Drawable.Pieces; -using osu.Game.Tests.Visual; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osuTK; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneFruitObjects : OsuTestScene + public class TestSceneFruitObjects : CatchSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] + protected override void LoadComplete() { - typeof(CatchHitObject), - typeof(Fruit), - typeof(Droplet), - typeof(DrawableCatchHitObject), - typeof(DrawableFruit), - typeof(DrawableDroplet), - typeof(BananaShower), - typeof(Pulp), - }; + base.LoadComplete(); - public TestSceneFruitObjects() - { - Add(new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - createDrawable(0), - createDrawable(1), - createDrawable(2), - }, - new Drawable[] - { - createDrawable(3), - createDrawable(4), - createDrawable(5), - }, - } - }); + foreach (FruitVisualRepresentation rep in Enum.GetValues(typeof(FruitVisualRepresentation))) + AddStep($"show {rep}", () => SetContents(() => createDrawable(rep))); + + AddStep("show droplet", () => SetContents(() => createDrawableDroplet())); + AddStep("show tiny droplet", () => SetContents(createDrawableTinyDroplet)); + + foreach (FruitVisualRepresentation rep in Enum.GetValues(typeof(FruitVisualRepresentation))) + AddStep($"show hyperdash {rep}", () => SetContents(() => createDrawable(rep, true))); + + AddStep("show hyperdash droplet", () => SetContents(() => createDrawableDroplet(true))); } - private DrawableFruit createDrawable(int index) + private Drawable createDrawableTinyDroplet() { - Fruit fruit = index == 5 - ? new Banana - { - StartTime = 1000000000000, - IndexInBeatmap = index, - Scale = 1.5f, - } - : new Fruit - { - StartTime = 1000000000000, - IndexInBeatmap = index, - Scale = 1.5f, - }; + var droplet = new TestCatchTinyDroplet + { + Scale = 1.5f, + }; - return new DrawableFruit(fruit) + return new DrawableTinyDroplet(droplet) { Anchor = Anchor.Centre, - RelativePositionAxes = Axes.Both, + RelativePositionAxes = Axes.None, Position = Vector2.Zero, Alpha = 1, LifetimeStart = double.NegativeInfinity, LifetimeEnd = double.PositiveInfinity, }; } + + private Drawable createDrawableDroplet(bool hyperdash = false) + { + var droplet = new TestCatchDroplet + { + Scale = 1.5f, + HyperDashTarget = hyperdash ? new Banana() : null + }; + + return new DrawableDroplet(droplet) + { + Anchor = Anchor.Centre, + RelativePositionAxes = Axes.None, + Position = Vector2.Zero, + Alpha = 1, + LifetimeStart = double.NegativeInfinity, + LifetimeEnd = double.PositiveInfinity, + }; + } + + private Drawable createDrawable(FruitVisualRepresentation rep, bool hyperdash = false) + { + Fruit fruit = new TestCatchFruit(rep) + { + Scale = 1.5f, + HyperDashTarget = hyperdash ? new Banana() : null + }; + + return new DrawableFruit(fruit) + { + Anchor = Anchor.Centre, + RelativePositionAxes = Axes.None, + Position = Vector2.Zero, + Alpha = 1, + LifetimeStart = double.NegativeInfinity, + LifetimeEnd = double.PositiveInfinity, + }; + } + + public class TestCatchFruit : Fruit + { + public TestCatchFruit(FruitVisualRepresentation rep) + { + VisualRepresentation = rep; + StartTime = 1000000000000; + } + + public override FruitVisualRepresentation VisualRepresentation { get; } + } + + public class TestCatchDroplet : Droplet + { + public TestCatchDroplet() + { + StartTime = 1000000000000; + } + } + + public class TestCatchTinyDroplet : TinyDroplet + { + public TestCatchTinyDroplet() + { + StartTime = 1000000000000; + } + } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs index a603d96201..db09b2bc6b 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs @@ -1,25 +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 System; +using System.Linq; using NUnit.Framework; +using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Tests.Visual; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects; +using osuTK; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneHyperDash : PlayerTestScene + public class TestSceneHyperDash : TestSceneCatchPlayer { - public TestSceneHyperDash() - : base(new CatchRuleset()) - { - } + protected override bool Autoplay => true; + + private int hyperDashCount; + private bool inHyperDash; [Test] public void TestHyperDash() { + AddStep("reset count", () => + { + inHyperDash = false; + hyperDashCount = 0; + + // this needs to be done within the frame stable context due to how quickly hyperdash state changes occur. + Player.DrawableRuleset.FrameStableComponents.OnUpdate += d => + { + var catcher = Player.ChildrenOfType().FirstOrDefault()?.MovableCatcher; + + if (catcher == null) + return; + + if (catcher.HyperDashing != inHyperDash) + { + inHyperDash = catcher.HyperDashing; + if (catcher.HyperDashing) + hyperDashCount++; + } + }; + }); + AddAssert("First note is hyperdash", () => Beatmap.Value.Beatmap.HitObjects[0] is Fruit f && f.HyperDash); + + for (int i = 0; i < 9; i++) + { + int count = i + 1; + AddUntilStep($"wait for hyperdash #{count}", () => hyperDashCount >= count); + } } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) @@ -33,15 +67,68 @@ namespace osu.Game.Rulesets.Catch.Tests } }; - // Should produce a hyper-dash - beatmap.HitObjects.Add(new Fruit { StartTime = 816, X = 308 / 512f, NewCombo = true }); - beatmap.HitObjects.Add(new Fruit { StartTime = 1008, X = 56 / 512f, }); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint()); - for (int i = 0; i < 512; i++) - if (i % 5 < 3) - beatmap.HitObjects.Add(new Fruit { X = i % 10 < 5 ? 0.02f : 0.98f, StartTime = 2000 + i * 100, NewCombo = i % 8 == 0 }); + // Should produce a hyper-dash (edge case test) + beatmap.HitObjects.Add(new Fruit { StartTime = 1816, X = 56, NewCombo = true }); + beatmap.HitObjects.Add(new Fruit { StartTime = 2008, X = 308, NewCombo = true }); + + double startTime = 3000; + + const float left_x = 0.02f * CatchPlayfield.WIDTH; + const float right_x = 0.98f * CatchPlayfield.WIDTH; + + createObjects(() => new Fruit { X = left_x }); + createObjects(() => new TestJuiceStream(right_x), 1); + createObjects(() => new TestJuiceStream(left_x), 1); + createObjects(() => new Fruit { X = right_x }); + createObjects(() => new Fruit { X = left_x }); + createObjects(() => new Fruit { X = right_x }); + createObjects(() => new TestJuiceStream(left_x), 1); + + beatmap.ControlPointInfo.Add(startTime, new TimingControlPoint + { + BeatLength = 50 + }); + + createObjects(() => new TestJuiceStream(left_x) + { + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero), + new PathControlPoint(new Vector2(512, 0)) + }) + }, 1); return beatmap; + + void createObjects(Func createObject, int count = 3) + { + const float spacing = 140; + + for (int i = 0; i < count; i++) + { + var hitObject = createObject(); + hitObject.StartTime = startTime + i * spacing; + beatmap.HitObjects.Add(hitObject); + } + + startTime += 700; + } + } + + private class TestJuiceStream : JuiceStream + { + public TestJuiceStream(float x) + { + X = x; + + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero), + new PathControlPoint(new Vector2(30, 0)), + }); + } } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs new file mode 100644 index 0000000000..1b8368794c --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -0,0 +1,209 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneHyperDashColouring : OsuTestScene + { + [Resolved] + private SkinManager skins { get; set; } + + [Test] + public void TestDefaultCatcherColour() + { + var skin = new TestSkin(); + + checkHyperDashCatcherColour(skin, Catcher.DEFAULT_HYPER_DASH_COLOUR); + } + + [Test] + public void TestCustomCatcherColour() + { + var skin = new TestSkin + { + HyperDashColour = Color4.Goldenrod + }; + + checkHyperDashCatcherColour(skin, skin.HyperDashColour); + } + + [Test] + public void TestCustomEndGlowColour() + { + var skin = new TestSkin + { + HyperDashAfterImageColour = Color4.Lime + }; + + checkHyperDashCatcherColour(skin, Catcher.DEFAULT_HYPER_DASH_COLOUR, skin.HyperDashAfterImageColour); + } + + [Test] + public void TestCustomEndGlowColourPriority() + { + var skin = new TestSkin + { + HyperDashColour = Color4.Goldenrod, + HyperDashAfterImageColour = Color4.Lime + }; + + checkHyperDashCatcherColour(skin, skin.HyperDashColour, skin.HyperDashAfterImageColour); + } + + [Test] + public void TestDefaultFruitColour() + { + var skin = new TestSkin(); + + checkHyperDashFruitColour(skin, Catcher.DEFAULT_HYPER_DASH_COLOUR); + } + + [Test] + public void TestCustomFruitColour() + { + var skin = new TestSkin + { + HyperDashFruitColour = Color4.Cyan + }; + + checkHyperDashFruitColour(skin, skin.HyperDashFruitColour); + } + + [Test] + public void TestCustomFruitColourPriority() + { + var skin = new TestSkin + { + HyperDashColour = Color4.Goldenrod, + HyperDashFruitColour = Color4.Cyan + }; + + checkHyperDashFruitColour(skin, skin.HyperDashFruitColour); + } + + [Test] + public void TestFruitColourFallback() + { + var skin = new TestSkin + { + HyperDashColour = Color4.Goldenrod + }; + + checkHyperDashFruitColour(skin, skin.HyperDashColour); + } + + private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedEndGlowColour = null) + { + CatcherArea catcherArea = null; + CatcherTrailDisplay trails = null; + + AddStep("create hyper-dashing catcher", () => + { + Child = setupSkinHierarchy(catcherArea = new CatcherArea + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(4f), + }, skin); + }); + + AddStep("get trails container", () => + { + trails = catcherArea.OfType().Single(); + catcherArea.MovableCatcher.SetHyperDashState(2); + }); + + AddUntilStep("catcher colour is correct", () => catcherArea.MovableCatcher.Colour == expectedCatcherColour); + + AddAssert("catcher trails colours are correct", () => trails.HyperDashTrailsColour == expectedCatcherColour); + AddAssert("catcher end-glow colours are correct", () => trails.EndGlowSpritesColour == (expectedEndGlowColour ?? expectedCatcherColour)); + + AddStep("finish hyper-dashing", () => + { + catcherArea.MovableCatcher.SetHyperDashState(1); + catcherArea.MovableCatcher.FinishTransforms(); + }); + + AddAssert("catcher colour returned to white", () => catcherArea.MovableCatcher.Colour == Color4.White); + } + + private void checkHyperDashFruitColour(ISkin skin, Color4 expectedColour) + { + DrawableFruit drawableFruit = null; + + AddStep("create hyper-dash fruit", () => + { + var fruit = new Fruit { HyperDashTarget = new Banana() }; + fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + Child = setupSkinHierarchy(drawableFruit = new DrawableFruit(fruit) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(4f), + }, skin); + }); + + AddAssert("hyper-dash colour is correct", () => checkLegacyFruitHyperDashColour(drawableFruit, expectedColour)); + } + + private Drawable setupSkinHierarchy(Drawable child, ISkin skin) + { + var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.Info)); + var testSkinProvider = new SkinProvidingContainer(skin); + var legacySkinTransformer = new SkinProvidingContainer(new CatchLegacySkinTransformer(testSkinProvider)); + + return legacySkinProvider + .WithChild(testSkinProvider + .WithChild(legacySkinTransformer + .WithChild(child))); + } + + private bool checkLegacyFruitHyperDashColour(DrawableFruit fruit, Color4 expectedColour) => + fruit.ChildrenOfType().First().Drawable.ChildrenOfType().Any(c => c.Colour == expectedColour); + + private class TestSkin : LegacySkin + { + public Color4 HyperDashColour + { + get => Configuration.CustomColours[CatchSkinColour.HyperDash.ToString()]; + set => Configuration.CustomColours[CatchSkinColour.HyperDash.ToString()] = value; + } + + public Color4 HyperDashAfterImageColour + { + get => Configuration.CustomColours[CatchSkinColour.HyperDashAfterImage.ToString()]; + set => Configuration.CustomColours[CatchSkinColour.HyperDashAfterImage.ToString()] = value; + } + + public Color4 HyperDashFruitColour + { + get => Configuration.CustomColours[CatchSkinColour.HyperDashFruit.ToString()]; + set => Configuration.CustomColours[CatchSkinColour.HyperDashFruit.ToString()] = value; + } + + public TestSkin() + : base(new SkinInfo(), null, null, string.Empty) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.cs new file mode 100644 index 0000000000..269e783899 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneJuiceStream.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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneJuiceStream : TestSceneCatchPlayer + { + [Test] + public void TestJuiceStreamEndingCombo() + { + AddUntilStep("player is done", () => !Player.ValidForResume); + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty { CircleSize = 5, SliderMultiplier = 2 }, + Ruleset = ruleset + }, + HitObjects = new List + { + new JuiceStream + { + X = CatchPlayfield.CENTER_X, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(0, 100) + }), + StartTime = 200 + }, + new Banana + { + X = CatchPlayfield.CENTER_X, + StartTime = 1000, + NewCombo = true + } + } + }; + } +} 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 9acf47a67c..dfe3bf8af4 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,14 +2,14 @@ - + - + WinExe - netcoreapp2.2 + netcoreapp3.1 diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmap.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmap.cs index 18cc300ff9..f009c10a9c 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmap.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmap.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; @@ -23,19 +22,19 @@ namespace osu.Game.Rulesets.Catch.Beatmaps { Name = @"Fruit Count", Content = fruits.ToString(), - Icon = FontAwesome.Regular.Circle + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles), }, new BeatmapStatistic { Name = @"Juice Stream Count", Content = juiceStreams.ToString(), - Icon = FontAwesome.Regular.Circle + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), }, new BeatmapStatistic { Name = @"Banana Shower Count", Content = bananaShowers.ToString(), - Icon = FontAwesome.Regular.Circle + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners), } }; } diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs index 0d9a663b9f..34964fc4ae 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs @@ -4,66 +4,63 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using System.Collections.Generic; -using System; -using osu.Game.Rulesets.Catch.UI; +using System.Linq; +using System.Threading; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects; +using osu.Framework.Extensions.IEnumerableExtensions; namespace osu.Game.Rulesets.Catch.Beatmaps { public class CatchBeatmapConverter : BeatmapConverter { - public CatchBeatmapConverter(IBeatmap beatmap) - : base(beatmap) + public CatchBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) + : base(beatmap, ruleset) { } - protected override IEnumerable ValidConversionTypes { get; } = new[] { typeof(IHasXPosition) }; + public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition); - protected override IEnumerable ConvertHitObject(HitObject obj, IBeatmap beatmap) + protected override IEnumerable ConvertHitObject(HitObject obj, IBeatmap beatmap, CancellationToken cancellationToken) { - var curveData = obj as IHasCurve; var positionData = obj as IHasXPosition; var comboData = obj as IHasCombo; - var endTime = obj as IHasEndTime; - var legacyOffset = obj as IHasLegacyLastTickOffset; - if (curveData != null) + switch (obj) { - yield return new JuiceStream - { - StartTime = obj.StartTime, - Samples = obj.Samples, - Path = curveData.Path, - NodeSamples = curveData.NodeSamples, - RepeatCount = curveData.RepeatCount, - X = (positionData?.X ?? 0) / CatchPlayfield.BASE_WIDTH, - NewCombo = comboData?.NewCombo ?? false, - ComboOffset = comboData?.ComboOffset ?? 0, - LegacyLastTickOffset = legacyOffset?.LegacyLastTickOffset ?? 0 - }; - } - else if (endTime != null) - { - yield return new BananaShower - { - StartTime = obj.StartTime, - Samples = obj.Samples, - Duration = endTime.Duration, - NewCombo = comboData?.NewCombo ?? false, - ComboOffset = comboData?.ComboOffset ?? 0, - }; - } - else - { - yield return new Fruit - { - StartTime = obj.StartTime, - Samples = obj.Samples, - NewCombo = comboData?.NewCombo ?? false, - ComboOffset = comboData?.ComboOffset ?? 0, - X = (positionData?.X ?? 0) / CatchPlayfield.BASE_WIDTH - }; + case IHasPathWithRepeats curveData: + return new JuiceStream + { + StartTime = obj.StartTime, + Samples = obj.Samples, + Path = curveData.Path, + NodeSamples = curveData.NodeSamples, + RepeatCount = curveData.RepeatCount, + X = positionData?.X ?? 0, + NewCombo = comboData?.NewCombo ?? false, + ComboOffset = comboData?.ComboOffset ?? 0, + LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0 + }.Yield(); + + case IHasDuration endTime: + return new BananaShower + { + StartTime = obj.StartTime, + Samples = obj.Samples, + Duration = endTime.Duration, + NewCombo = comboData?.NewCombo ?? false, + ComboOffset = comboData?.ComboOffset ?? 0, + }.Yield(); + + default: + return new Fruit + { + StartTime = obj.StartTime, + Samples = obj.Samples, + NewCombo = comboData?.NewCombo ?? false, + ComboOffset = comboData?.ComboOffset ?? 0, + X = positionData?.X ?? 0 + }.Yield(); } } diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs index 645cb5701a..a08c5b6fb1 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs @@ -8,8 +8,8 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects.Types; -using osuTK; using osu.Game.Rulesets.Catch.MathUtils; +using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Beatmaps { @@ -26,33 +26,46 @@ namespace osu.Game.Rulesets.Catch.Beatmaps { base.PostProcess(); - applyPositionOffsets(); - - initialiseHyperDash((List)Beatmap.HitObjects); + ApplyPositionOffsets(Beatmap); int index = 0; foreach (var obj in Beatmap.HitObjects.OfType()) { - obj.IndexInBeatmap = index++; + obj.IndexInBeatmap = index; + foreach (var nested in obj.NestedHitObjects.OfType()) + nested.IndexInBeatmap = index; + if (obj.LastInCombo && obj.NestedHitObjects.LastOrDefault() is IHasComboInformation lastNested) lastNested.LastInCombo = true; + + index++; } } - private void applyPositionOffsets() + public static void ApplyPositionOffsets(IBeatmap beatmap, params Mod[] mods) { var rng = new FastRandom(RNG_SEED); - // todo: HardRock displacement should be applied here - foreach (var obj in Beatmap.HitObjects) + bool shouldApplyHardRockOffset = mods.Any(m => m is ModHardRock); + float? lastPosition = null; + double lastStartTime = 0; + + foreach (var obj in beatmap.HitObjects.OfType()) { + obj.XOffset = 0; + switch (obj) { + case Fruit fruit: + if (shouldApplyHardRockOffset) + applyHardRockOffset(fruit, ref lastPosition, ref lastStartTime, rng); + break; + case BananaShower bananaShower: foreach (var banana in bananaShower.NestedHitObjects.OfType()) { - banana.X = (float)rng.NextDouble(); + banana.XOffset = (float)(rng.NextDouble() * CatchPlayfield.WIDTH); rng.Next(); // osu!stable retrieved a random banana type rng.Next(); // osu!stable retrieved a random banana rotation rng.Next(); // osu!stable retrieved a random banana colour @@ -61,38 +74,150 @@ namespace osu.Game.Rulesets.Catch.Beatmaps break; case JuiceStream juiceStream: + // Todo: BUG!! Stable used the last control point as the final position of the path, but it should use the computed path instead. + lastPosition = juiceStream.X + juiceStream.Path.ControlPoints[^1].Position.Value.X; + + // Todo: BUG!! Stable attempted to use the end time of the stream, but referenced it too early in execution and used the start time instead. + lastStartTime = juiceStream.StartTime; + foreach (var nested in juiceStream.NestedHitObjects) { - var hitObject = (CatchHitObject)nested; - if (hitObject is TinyDroplet) - hitObject.X += rng.Next(-20, 20) / CatchPlayfield.BASE_WIDTH; - else if (hitObject is Droplet) + var catchObject = (CatchHitObject)nested; + catchObject.XOffset = 0; + + if (catchObject is TinyDroplet) + catchObject.XOffset = Math.Clamp(rng.Next(-20, 20), -catchObject.X, CatchPlayfield.WIDTH - catchObject.X); + else if (catchObject is Droplet) rng.Next(); // osu!stable retrieved a random droplet rotation - hitObject.X = MathHelper.Clamp(hitObject.X, 0, 1); } break; } } + + initialiseHyperDash(beatmap); } - private void initialiseHyperDash(List objects) + private static void applyHardRockOffset(CatchHitObject hitObject, ref float? lastPosition, ref double lastStartTime, FastRandom rng) + { + float offsetPosition = hitObject.X; + double startTime = hitObject.StartTime; + + if (lastPosition == null) + { + lastPosition = offsetPosition; + lastStartTime = startTime; + + return; + } + + float positionDiff = offsetPosition - lastPosition.Value; + + // Todo: BUG!! Stable calculated time deltas as ints, which affects randomisation. This should be changed to a double. + int timeDiff = (int)(startTime - lastStartTime); + + if (timeDiff > 1000) + { + lastPosition = offsetPosition; + lastStartTime = startTime; + return; + } + + if (positionDiff == 0) + { + applyRandomOffset(ref offsetPosition, timeDiff / 4d, rng); + hitObject.XOffset = offsetPosition - hitObject.X; + return; + } + + // ReSharper disable once PossibleLossOfFraction + if (Math.Abs(positionDiff) < timeDiff / 3) + applyOffset(ref offsetPosition, positionDiff); + + hitObject.XOffset = offsetPosition - hitObject.X; + + lastPosition = offsetPosition; + lastStartTime = startTime; + } + + /// + /// Applies a random offset in a random direction to a position, ensuring that the final position remains within the boundary of the playfield. + /// + /// The position which the offset should be applied to. + /// The maximum offset, cannot exceed 20px. + /// The random number generator. + private static void applyRandomOffset(ref float position, double maxOffset, FastRandom rng) + { + bool right = rng.NextBool(); + float rand = Math.Min(20, (float)rng.Next(0, Math.Max(0, maxOffset))); + + if (right) + { + // Clamp to the right bound + if (position + rand <= CatchPlayfield.WIDTH) + position += rand; + else + position -= rand; + } + else + { + // Clamp to the left bound + if (position - rand >= 0) + position -= rand; + else + position += rand; + } + } + + /// + /// Applies an offset to a position, ensuring that the final position remains within the boundary of the playfield. + /// + /// The position which the offset should be applied to. + /// The amount to offset by. + private static void applyOffset(ref float position, float amount) + { + if (amount > 0) + { + // Clamp to the right bound + if (position + amount < CatchPlayfield.WIDTH) + position += amount; + } + else + { + // Clamp to the left bound + if (position + amount > 0) + position += amount; + } + } + + private static void initialiseHyperDash(IBeatmap beatmap) { List objectWithDroplets = new List(); - foreach (var currentObject in objects) + foreach (var currentObject in beatmap.HitObjects) { - if (currentObject is Fruit) - objectWithDroplets.Add(currentObject); + if (currentObject is Fruit fruitObject) + objectWithDroplets.Add(fruitObject); + if (currentObject is JuiceStream) + { foreach (var currentJuiceElement in currentObject.NestedHitObjects) + { if (!(currentJuiceElement is TinyDroplet)) objectWithDroplets.Add((CatchHitObject)currentJuiceElement); + } + } } objectWithDroplets.Sort((h1, h2) => h1.StartTime.CompareTo(h2.StartTime)); - double halfCatcherWidth = CatcherArea.GetCatcherSize(Beatmap.BeatmapInfo.BaseDifficulty) / 2; + double halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) / 2; + + // Todo: This is wrong. osu!stable calculated hyperdashes using the full catcher size, excluding the margins. + // This should theoretically cause impossible scenarios, but practically, likely due to the size of the playfield, it doesn't seem possible. + // For now, to bring gameplay (and diffcalc!) completely in-line with stable, this code also uses the full catcher size. + halfCatcherWidth /= Catcher.ALLOWED_CATCH_RANGE; + int lastDirection = 0; double lastExcess = halfCatcherWidth; @@ -101,10 +226,14 @@ namespace osu.Game.Rulesets.Catch.Beatmaps CatchHitObject currentObject = objectWithDroplets[i]; CatchHitObject nextObject = objectWithDroplets[i + 1]; + // Reset variables in-case values have changed (e.g. after applying HR) + currentObject.HyperDashTarget = null; + currentObject.DistanceToHyperDash = 0; + int thisDirection = nextObject.X > currentObject.X ? 1 : -1; double timeToNext = nextObject.StartTime - currentObject.StartTime - 1000f / 60f / 4; // 1/4th of a frame of grace time, taken from osu-stable double distanceToNext = Math.Abs(nextObject.X - currentObject.X) - (lastDirection == thisDirection ? lastExcess : halfCatcherWidth); - float distanceToHyper = (float)(timeToNext * CatcherArea.Catcher.BASE_SPEED - distanceToNext); + float distanceToHyper = (float)(timeToNext * Catcher.BASE_SPEED - distanceToNext); if (distanceToHyper < 0) { @@ -114,7 +243,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps else { currentObject.DistanceToHyperDash = distanceToHyper; - lastExcess = MathHelper.Clamp(distanceToHyper, 0, halfCatcherWidth); + lastExcess = Math.Clamp(distanceToHyper, 0, halfCatcherWidth); } lastDirection = thisDirection; diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index ea9f225cc1..ad584d3f48 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -11,23 +11,33 @@ using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; -using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Replays; using osu.Game.Rulesets.Replays.Types; using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Difficulty; +using osu.Game.Rulesets.Catch.Scoring; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using System; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch { - public class CatchRuleset : Ruleset + public class CatchRuleset : Ruleset, ILegacyRuleset { - public override DrawableRuleset CreateDrawableRulesetWith(WorkingBeatmap beatmap, IReadOnlyList mods) => new DrawableCatchRuleset(this, beatmap, mods); - public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new CatchBeatmapConverter(beatmap); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableCatchRuleset(this, beatmap, mods); + + public override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new CatchBeatmapConverter(beatmap, this); + public override IBeatmapProcessor CreateBeatmapProcessor(IBeatmap beatmap) => new CatchBeatmapProcessor(beatmap); + public const string SHORT_NAME = "fruits"; + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] { new KeyBinding(InputKey.Z, CatchAction.MoveLeft), @@ -38,14 +48,21 @@ namespace osu.Game.Rulesets.Catch new KeyBinding(InputKey.Shift, CatchAction.Dash), }; - public override IEnumerable ConvertLegacyMods(LegacyMods mods) + public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { if (mods.HasFlag(LegacyMods.Nightcore)) yield return new CatchModNightcore(); else if (mods.HasFlag(LegacyMods.DoubleTime)) yield return new CatchModDoubleTime(); - if (mods.HasFlag(LegacyMods.Autoplay)) + if (mods.HasFlag(LegacyMods.Perfect)) + yield return new CatchModPerfect(); + else if (mods.HasFlag(LegacyMods.SuddenDeath)) + yield return new CatchModSuddenDeath(); + + if (mods.HasFlag(LegacyMods.Cinema)) + yield return new CatchModCinema(); + else if (mods.HasFlag(LegacyMods.Autoplay)) yield return new CatchModAutoplay(); if (mods.HasFlag(LegacyMods.Easy)) @@ -66,14 +83,8 @@ namespace osu.Game.Rulesets.Catch if (mods.HasFlag(LegacyMods.NoFail)) yield return new CatchModNoFail(); - if (mods.HasFlag(LegacyMods.Perfect)) - yield return new CatchModPerfect(); - if (mods.HasFlag(LegacyMods.Relax)) yield return new CatchModRelax(); - - if (mods.HasFlag(LegacyMods.SuddenDeath)) - yield return new CatchModSuddenDeath(); } public override IEnumerable GetModsFor(ModType type) @@ -98,41 +109,75 @@ namespace osu.Game.Rulesets.Catch new CatchModFlashlight(), }; + case ModType.Conversion: + return new Mod[] + { + new CatchModDifficultyAdjust(), + }; + case ModType.Automation: return new Mod[] { - new MultiMod(new CatchModAutoplay(), new ModCinema()), + new MultiMod(new CatchModAutoplay(), new CatchModCinema()), new CatchModRelax(), }; case ModType.Fun: return new Mod[] { - new MultiMod(new ModWindUp(), new ModWindDown()) + new MultiMod(new ModWindUp(), new ModWindDown()) }; default: - return new Mod[] { }; + return Array.Empty(); } } public override string Description => "osu!catch"; - public override string ShortName => "fruits"; + public override string ShortName => SHORT_NAME; + + public override string PlayingVerb => "Catching fruit"; public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetCatch }; + protected override IEnumerable GetValidHitResults() + { + return new[] + { + HitResult.Great, + + HitResult.LargeTickHit, + HitResult.SmallTickHit, + HitResult.LargeBonus, + }; + } + + public override string GetDisplayNameForHitResult(HitResult result) + { + switch (result) + { + case HitResult.LargeTickHit: + return "large droplet"; + + case HitResult.SmallTickHit: + return "small droplet"; + + case HitResult.LargeBonus: + return "banana"; + } + + return base.GetDisplayNameForHitResult(result); + } + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(this, beatmap); - public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new CatchPerformanceCalculator(this, beatmap, score); + public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new CatchLegacySkinTransformer(source); - public override int? LegacyID => 2; + public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => new CatchPerformanceCalculator(this, attributes, score); + + public int LegacyID => 2; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new CatchReplayFrame(); - - public CatchRuleset(RulesetInfo rulesetInfo = null) - : base(rulesetInfo) - { - } } } diff --git a/osu.Game.Rulesets.Catch/CatchSkinComponent.cs b/osu.Game.Rulesets.Catch/CatchSkinComponent.cs new file mode 100644 index 0000000000..8bf53e53e3 --- /dev/null +++ b/osu.Game.Rulesets.Catch/CatchSkinComponent.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.Skinning; + +namespace osu.Game.Rulesets.Catch +{ + public class CatchSkinComponent : GameplaySkinComponent + { + public CatchSkinComponent(CatchSkinComponents component) + : base(component) + { + } + + protected override string RulesetPrefix => "catch"; // todo: use CatchRuleset.SHORT_NAME; + + protected override string ComponentName => Component.ToString().ToLower(); + } +} diff --git a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs new file mode 100644 index 0000000000..23d8428fec --- /dev/null +++ b/osu.Game.Rulesets.Catch/CatchSkinComponents.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. + +namespace osu.Game.Rulesets.Catch +{ + public enum CatchSkinComponents + { + FruitBananas, + FruitApple, + FruitGrapes, + FruitOrange, + FruitPear, + Droplet, + CatcherIdle, + CatcherFail, + CatcherKiai, + CatchComboCounter + } +} diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs index 75f5b18607..fa9011d826 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs @@ -8,6 +8,5 @@ namespace osu.Game.Rulesets.Catch.Difficulty public class CatchDifficultyAttributes : DifficultyAttributes { public double ApproachRate; - public int MaxCombo; } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index 44e1a8e5cc..a317ef252d 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -19,10 +19,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty { public class CatchDifficultyCalculator : DifficultyCalculator { - private const double star_scaling_factor = 0.145; + private const double star_scaling_factor = 0.153; protected override int SectionLength => 750; + private float halfCatcherWidth; + public CatchDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) : base(ruleset, beatmap) { @@ -48,14 +50,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { - float halfCatchWidth; - - using (var catcher = new CatcherArea.Catcher(beatmap.BeatmapInfo.BaseDifficulty)) - { - halfCatchWidth = catcher.CatchWidth * 0.5f; - halfCatchWidth *= 0.8f; // We're only using 80% of the catcher's width to simulate imperfect gameplay. - } - CatchHitObject lastObject = null; // In 2B beatmaps, it is possible that a normal Fruit is placed in the middle of a JuiceStream. @@ -69,16 +63,24 @@ namespace osu.Game.Rulesets.Catch.Difficulty continue; if (lastObject != null) - yield return new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatchWidth); + yield return new CatchDifficultyHitObject(hitObject, lastObject, clockRate, halfCatcherWidth); lastObject = hitObject; } } - protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap) { - new Movement(), - }; + halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) * 0.5f; + + // For circle sizes above 5.5, reduce the catcher width further to simulate imperfect gameplay. + halfCatcherWidth *= 1 - (Math.Max(0, beatmap.BeatmapInfo.BaseDifficulty.CircleSize - 5.5f) * 0.0625f); + + return new Skill[] + { + new Movement(halfCatcherWidth), + }; + } protected override Mod[] DifficultyAdjustmentMods => new Mod[] { diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index 5a640f6d1a..6a3a16ed33 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -4,13 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Game.Beatmaps; +using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; -using osu.Game.Scoring.Legacy; -using osuTK; namespace osu.Game.Rulesets.Catch.Difficulty { @@ -26,8 +24,8 @@ namespace osu.Game.Rulesets.Catch.Difficulty private int tinyTicksMissed; private int misses; - public CatchPerformanceCalculator(Ruleset ruleset, WorkingBeatmap beatmap, ScoreInfo score) - : base(ruleset, beatmap, score) + public CatchPerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) + : base(ruleset, attributes, score) { } @@ -35,68 +33,72 @@ namespace osu.Game.Rulesets.Catch.Difficulty { mods = Score.Mods; - var legacyScore = Score as LegacyScoreInfo; - - fruitsHit = legacyScore?.Count300 ?? Score.Statistics[HitResult.Perfect]; - ticksHit = legacyScore?.Count100 ?? 0; - tinyTicksHit = legacyScore?.Count50 ?? 0; - tinyTicksMissed = legacyScore?.CountKatu ?? 0; - misses = Score.Statistics[HitResult.Miss]; + fruitsHit = Score.Statistics.GetOrDefault(HitResult.Great); + ticksHit = Score.Statistics.GetOrDefault(HitResult.LargeTickHit); + tinyTicksHit = Score.Statistics.GetOrDefault(HitResult.SmallTickHit); + tinyTicksMissed = Score.Statistics.GetOrDefault(HitResult.SmallTickMiss); + misses = Score.Statistics.GetOrDefault(HitResult.Miss); // Don't count scores made with supposedly unranked mods if (mods.Any(m => !m.Ranked)) return 0; // We are heavily relying on aim in catch the beat - double value = Math.Pow(5.0f * Math.Max(1.0f, Attributes.StarRating / 0.0049f) - 4.0f, 2.0f) / 100000.0f; + double value = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0049) - 4.0, 2.0) / 100000.0; // Longer maps are worth more. "Longer" means how many hits there are which can contribute to combo int numTotalHits = totalComboHits(); // Longer maps are worth more - float lengthBonus = - 0.95f + 0.4f * Math.Min(1.0f, numTotalHits / 3000.0f) + - (numTotalHits > 3000 ? (float)Math.Log10(numTotalHits / 3000.0f) * 0.5f : 0.0f); + double lengthBonus = + 0.95 + 0.3 * Math.Min(1.0, numTotalHits / 2500.0) + + (numTotalHits > 2500 ? Math.Log10(numTotalHits / 2500.0) * 0.475 : 0.0); // Longer maps are worth more value *= lengthBonus; // Penalize misses exponentially. This mainly fixes tag4 maps and the likes until a per-hitobject solution is available - value *= Math.Pow(0.97f, misses); + value *= Math.Pow(0.97, misses); // Combo scaling - float beatmapMaxCombo = Attributes.MaxCombo; - if (beatmapMaxCombo > 0) - value *= Math.Min(Math.Pow(Attributes.MaxCombo, 0.8f) / Math.Pow(beatmapMaxCombo, 0.8f), 1.0f); + if (Attributes.MaxCombo > 0) + value *= Math.Min(Math.Pow(Score.MaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); - float approachRate = (float)Attributes.ApproachRate; - float approachRateFactor = 1.0f; - if (approachRate > 9.0f) - approachRateFactor += 0.1f * (approachRate - 9.0f); // 10% for each AR above 9 - else if (approachRate < 8.0f) - approachRateFactor += 0.025f * (8.0f - approachRate); // 2.5% for each AR below 8 + double approachRate = Attributes.ApproachRate; + double approachRateFactor = 1.0; + if (approachRate > 9.0) + approachRateFactor += 0.1 * (approachRate - 9.0); // 10% for each AR above 9 + if (approachRate > 10.0) + approachRateFactor += 0.1 * (approachRate - 10.0); // Additional 10% at AR 11, 30% total + else if (approachRate < 8.0) + approachRateFactor += 0.025 * (8.0 - approachRate); // 2.5% for each AR below 8 value *= approachRateFactor; if (mods.Any(m => m is ModHidden)) - // Hiddens gives nothing on max approach rate, and more the lower it is - value *= 1.05f + 0.075f * (10.0f - Math.Min(10.0f, approachRate)); // 7.5% for each AR below 10 + { + // Hiddens gives almost nothing on max approach rate, and more the lower it is + if (approachRate <= 10.0) + value *= 1.05 + 0.075 * (10.0 - approachRate); // 7.5% for each AR below 10 + else if (approachRate > 10.0) + value *= 1.01 + 0.04 * (11.0 - Math.Min(11.0, approachRate)); // 5% at AR 10, 1% at AR 11 + } if (mods.Any(m => m is ModFlashlight)) // Apply length bonus again if flashlight is on simply because it becomes a lot harder on longer maps. - value *= 1.35f * lengthBonus; + value *= 1.35 * lengthBonus; // Scale the aim value with accuracy _slightly_ - value *= Math.Pow(accuracy(), 5.5f); + value *= Math.Pow(accuracy(), 5.5); // Custom multipliers for NoFail. SpunOut is not applicable. if (mods.Any(m => m is ModNoFail)) - value *= 0.90f; + value *= 0.90; return value; } - private float accuracy() => totalHits() == 0 ? 0 : MathHelper.Clamp((float)totalSuccessfulHits() / totalHits(), 0f, 1f); + private double accuracy() => totalHits() == 0 ? 0 : Math.Clamp((double)totalSuccessfulHits() / totalHits(), 0, 1); private int totalHits() => tinyTicksHit + ticksHit + fruitsHit + misses + tinyTicksMissed; private int totalSuccessfulHits() => tinyTicksHit + ticksHit + fruitsHit; private int totalComboHits() => misses + ticksHit + fruitsHit; diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs index 24e526ed19..3e21b8fbaf 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -3,7 +3,6 @@ using System; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; @@ -21,21 +20,24 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing public readonly float LastNormalizedPosition; /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. + /// Milliseconds elapsed since the start time of the previous , with a minimum of 40ms. /// public readonly double StrainTime; + public readonly double ClockRate; + public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth) : base(hitObject, lastObject, clockRate) { // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. var scalingFactor = normalized_hitobject_radius / halfCatcherWidth; - NormalizedPosition = BaseObject.X * CatchPlayfield.BASE_WIDTH * scalingFactor; - LastNormalizedPosition = LastObject.X * CatchPlayfield.BASE_WIDTH * scalingFactor; + NormalizedPosition = BaseObject.X * scalingFactor; + LastNormalizedPosition = LastObject.X * scalingFactor; - // Every strain interval is hard capped at the equivalent of 600 BPM streaming speed as a safety measure - StrainTime = Math.Max(25, DeltaTime); + // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure + StrainTime = Math.Max(40, DeltaTime); + ClockRate = clockRate; } } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs index d146153294..e679231638 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -3,10 +3,8 @@ using System; using osu.Game.Rulesets.Catch.Difficulty.Preprocessing; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; -using osuTK; namespace osu.Game.Rulesets.Catch.Difficulty.Skills { @@ -14,24 +12,31 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills { private const float absolute_player_positioning_error = 16f; private const float normalized_hitobject_radius = 41.0f; - private const double direction_change_bonus = 12.5; + private const double direction_change_bonus = 21.0; - protected override double SkillMultiplier => 850; + protected override double SkillMultiplier => 900; protected override double StrainDecayBase => 0.2; protected override double DecayWeight => 0.94; + protected readonly float HalfCatcherWidth; + private float? lastPlayerPosition; private float lastDistanceMoved; + private double lastStrainTime; + + public Movement(float halfCatcherWidth) + { + HalfCatcherWidth = halfCatcherWidth; + } protected override double StrainValueOf(DifficultyHitObject current) { var catchCurrent = (CatchDifficultyHitObject)current; - if (lastPlayerPosition == null) - lastPlayerPosition = catchCurrent.LastNormalizedPosition; + lastPlayerPosition ??= catchCurrent.LastNormalizedPosition; - float playerPosition = MathHelper.Clamp( + float playerPosition = Math.Clamp( lastPlayerPosition.Value, catchCurrent.NormalizedPosition - (normalized_hitobject_radius - absolute_player_positioning_error), catchCurrent.NormalizedPosition + (normalized_hitobject_radius - absolute_player_positioning_error) @@ -39,47 +44,47 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills float distanceMoved = playerPosition - lastPlayerPosition.Value; - double distanceAddition = Math.Pow(Math.Abs(distanceMoved), 1.3) / 500; - double sqrtStrain = Math.Sqrt(catchCurrent.StrainTime); + double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catchCurrent.ClockRate); - double bonus = 0; + double distanceAddition = (Math.Pow(Math.Abs(distanceMoved), 1.3) / 510); + double sqrtStrain = Math.Sqrt(weightedStrainTime); - // Direction changes give an extra point! + double edgeDashBonus = 0; + + // Direction change bonus. if (Math.Abs(distanceMoved) > 0.1) { if (Math.Abs(lastDistanceMoved) > 0.1 && Math.Sign(distanceMoved) != Math.Sign(lastDistanceMoved)) { - double bonusFactor = Math.Min(absolute_player_positioning_error, Math.Abs(distanceMoved)) / absolute_player_positioning_error; + double bonusFactor = Math.Min(50, Math.Abs(distanceMoved)) / 50; + double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(lastDistanceMoved)) / 70, 0.38); - distanceAddition += direction_change_bonus / sqrtStrain * bonusFactor; - - // Bonus for tougher direction switches and "almost" hyperdashes at this point - if (catchCurrent.LastObject.DistanceToHyperDash <= 10 / CatchPlayfield.BASE_WIDTH) - bonus = 0.3 * bonusFactor; + distanceAddition += direction_change_bonus / Math.Sqrt(lastStrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - Math.Pow(weightedStrainTime / 1000, 3), 0); } // Base bonus for every movement, giving some weight to streams. - distanceAddition += 7.5 * Math.Min(Math.Abs(distanceMoved), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtStrain; + distanceAddition += 12.5 * Math.Min(Math.Abs(distanceMoved), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtStrain; } - // Bonus for "almost" hyperdashes at corner points - if (catchCurrent.LastObject.DistanceToHyperDash <= 10.0f / CatchPlayfield.BASE_WIDTH) + // Bonus for edge dashes. + if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f) { if (!catchCurrent.LastObject.HyperDash) - bonus += 1.0; + edgeDashBonus += 5.7; else { // After a hyperdash we ARE in the correct position. Always! playerPosition = catchCurrent.NormalizedPosition; } - distanceAddition *= 1.0 + bonus * ((10 - catchCurrent.LastObject.DistanceToHyperDash * CatchPlayfield.BASE_WIDTH) / 10); + distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) * Math.Pow((Math.Min(catchCurrent.StrainTime * catchCurrent.ClockRate, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values } lastPlayerPosition = playerPosition; lastDistanceMoved = distanceMoved; + lastStrainTime = catchCurrent.StrainTime; - return distanceAddition / catchCurrent.StrainTime; + return distanceAddition / weightedStrainTime; } } } diff --git a/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs b/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs index 374dd50c11..b919102215 100644 --- a/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs +++ b/osu.Game.Rulesets.Catch/Judgements/CatchBananaJudgement.cs @@ -8,31 +8,7 @@ namespace osu.Game.Rulesets.Catch.Judgements { public class CatchBananaJudgement : CatchJudgement { - public override bool AffectsCombo => false; - - protected override int NumericResultFor(HitResult result) - { - switch (result) - { - default: - return 0; - - case HitResult.Perfect: - return 1100; - } - } - - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - default: - return 0; - - case HitResult.Perfect: - return 0.008; - } - } + public override HitResult MaxResult => HitResult.LargeBonus; public override bool ShouldExplodeFor(JudgementResult result) => true; } diff --git a/osu.Game.Rulesets.Catch/Judgements/CatchDropletJudgement.cs b/osu.Game.Rulesets.Catch/Judgements/CatchDropletJudgement.cs index f1399bb5c0..8fd7b93e4c 100644 --- a/osu.Game.Rulesets.Catch/Judgements/CatchDropletJudgement.cs +++ b/osu.Game.Rulesets.Catch/Judgements/CatchDropletJudgement.cs @@ -7,28 +7,6 @@ namespace osu.Game.Rulesets.Catch.Judgements { public class CatchDropletJudgement : CatchJudgement { - protected override int NumericResultFor(HitResult result) - { - switch (result) - { - default: - return 0; - - case HitResult.Perfect: - return 30; - } - } - - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - default: - return base.HealthIncreaseFor(result); - - case HitResult.Perfect: - return 0.007; - } - } + public override HitResult MaxResult => HitResult.LargeTickHit; } } diff --git a/osu.Game.Rulesets.Catch/Judgements/CatchJudgement.cs b/osu.Game.Rulesets.Catch/Judgements/CatchJudgement.cs index 8fd9ac92ba..ccafe0abc4 100644 --- a/osu.Game.Rulesets.Catch/Judgements/CatchJudgement.cs +++ b/osu.Game.Rulesets.Catch/Judgements/CatchJudgement.cs @@ -9,31 +9,7 @@ namespace osu.Game.Rulesets.Catch.Judgements { public class CatchJudgement : Judgement { - public override HitResult MaxResult => HitResult.Perfect; - - protected override int NumericResultFor(HitResult result) - { - switch (result) - { - default: - return 0; - - case HitResult.Perfect: - return 300; - } - } - - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - default: - return -0.02; - - case HitResult.Perfect: - return 0.01; - } - } + public override HitResult MaxResult => HitResult.Great; /// /// Whether fruit on the platter should explode or drop. diff --git a/osu.Game.Rulesets.Catch/Judgements/CatchTinyDropletJudgement.cs b/osu.Game.Rulesets.Catch/Judgements/CatchTinyDropletJudgement.cs index 3829b5e94f..d957d4171b 100644 --- a/osu.Game.Rulesets.Catch/Judgements/CatchTinyDropletJudgement.cs +++ b/osu.Game.Rulesets.Catch/Judgements/CatchTinyDropletJudgement.cs @@ -7,30 +7,6 @@ namespace osu.Game.Rulesets.Catch.Judgements { public class CatchTinyDropletJudgement : CatchJudgement { - public override bool AffectsCombo => false; - - protected override int NumericResultFor(HitResult result) - { - switch (result) - { - default: - return 0; - - case HitResult.Perfect: - return 10; - } - } - - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - default: - return 0; - - case HitResult.Perfect: - return 0.004; - } - } + public override HitResult MaxResult => HitResult.SmallTickHit; } } diff --git a/osu.Game.Rulesets.Catch/MathUtils/FastRandom.cs b/osu.Game.Rulesets.Catch/MathUtils/FastRandom.cs index b3605b013b..46e427e1b7 100644 --- a/osu.Game.Rulesets.Catch/MathUtils/FastRandom.cs +++ b/osu.Game.Rulesets.Catch/MathUtils/FastRandom.cs @@ -12,14 +12,14 @@ namespace osu.Game.Rulesets.Catch.MathUtils { private const double int_to_real = 1.0 / (int.MaxValue + 1.0); private const uint int_mask = 0x7FFFFFFF; - private const uint y = 842502087; - private const uint z = 3579807591; - private const uint w = 273326509; - private uint _x, _y = y, _z = z, _w = w; + private const uint y_initial = 842502087; + private const uint z_initial = 3579807591; + private const uint w_initial = 273326509; + private uint x, y = y_initial, z = z_initial, w = w_initial; public FastRandom(int seed) { - _x = (uint)seed; + x = (uint)seed; } public FastRandom() @@ -33,11 +33,11 @@ namespace osu.Game.Rulesets.Catch.MathUtils /// The random value. public uint NextUInt() { - uint t = _x ^ (_x << 11); - _x = _y; - _y = _z; - _z = _w; - return _w = _w ^ (_w >> 19) ^ t ^ (t >> 8); + uint t = x ^ (x << 11); + x = y; + y = z; + z = w; + return w = w ^ (w >> 19) ^ t ^ (t >> 8); } /// @@ -61,6 +61,14 @@ namespace osu.Game.Rulesets.Catch.MathUtils /// The random value. public int Next(int lowerBound, int upperBound) => (int)(lowerBound + NextDouble() * (upperBound - lowerBound)); + /// + /// Generates a random integer value within the range [, ). + /// + /// The lower bound of the range. + /// The upper bound of the range. + /// The random value. + public int Next(double lowerBound, double upperBound) => (int)(lowerBound + NextDouble() * (upperBound - lowerBound)); + /// /// Generates a random double value within the range [0, 1). /// diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs b/osu.Game.Rulesets.Catch/Mods/CatchModCinema.cs new file mode 100644 index 0000000000..3bc1ee5bf5 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Mods/CatchModCinema.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.Beatmaps; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Replays; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Rulesets.Catch.Mods +{ + public class CatchModCinema : ModCinema + { + public override Score CreateReplayScore(IBeatmap beatmap) => new Score + { + ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad!" } }, + Replay = new CatchAutoGenerator(beatmap).Generate(), + }; + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs new file mode 100644 index 0000000000..acdd0a420c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.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 System.Linq; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Catch.Mods +{ + public class CatchModDifficultyAdjust : ModDifficultyAdjust + { + [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)] + public BindableNumber CircleSize { get; } = new BindableFloat + { + Precision = 0.1f, + MinValue = 1, + MaxValue = 10, + Default = 5, + Value = 5, + }; + + [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)] + public BindableNumber ApproachRate { get; } = new BindableFloat + { + Precision = 0.1f, + MinValue = 1, + MaxValue = 10, + Default = 5, + Value = 5, + }; + + public override string SettingDescription + { + get + { + string circleSize = CircleSize.IsDefault ? string.Empty : $"CS {CircleSize.Value:N1}"; + string approachRate = ApproachRate.IsDefault ? string.Empty : $"AR {ApproachRate.Value:N1}"; + + return string.Join(", ", new[] + { + circleSize, + base.SettingDescription, + approachRate + }.Where(s => !string.IsNullOrEmpty(s))); + } + } + + protected override void TransferSettings(BeatmapDifficulty difficulty) + { + base.TransferSettings(difficulty); + + TransferSetting(CircleSize, difficulty.CircleSize); + TransferSetting(ApproachRate, difficulty.ApproachRate); + } + + protected override void ApplySettings(BeatmapDifficulty difficulty) + { + base.ApplySettings(difficulty); + + difficulty.CircleSize = CircleSize.Value; + difficulty.ApproachRate = ApproachRate.Value; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs index 060e51e31d..ced1900ba9 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs @@ -1,121 +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.MathUtils; -using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; -using System; -using osu.Game.Rulesets.Objects; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Beatmaps; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModHardRock : ModHardRock, IApplicableToHitObject + public class CatchModHardRock : ModHardRock, IApplicableToBeatmap { public override double ScoreMultiplier => 1.12; public override bool Ranked => true; - private float? lastPosition; - private double lastStartTime; - - public void ApplyToHitObject(HitObject hitObject) - { - if (hitObject is JuiceStream stream) - { - lastPosition = stream.EndX; - lastStartTime = stream.EndTime; - return; - } - - if (!(hitObject is Fruit)) - return; - - var catchObject = (CatchHitObject)hitObject; - - float position = catchObject.X; - double startTime = hitObject.StartTime; - - if (lastPosition == null) - { - lastPosition = position; - lastStartTime = startTime; - - return; - } - - float positionDiff = position - lastPosition.Value; - double timeDiff = startTime - lastStartTime; - - if (timeDiff > 1000) - { - lastPosition = position; - lastStartTime = startTime; - return; - } - - if (positionDiff == 0) - { - applyRandomOffset(ref position, timeDiff / 4d); - catchObject.X = position; - return; - } - - if (Math.Abs(positionDiff * CatchPlayfield.BASE_WIDTH) < timeDiff / 3d) - applyOffset(ref position, positionDiff); - - catchObject.X = position; - - lastPosition = position; - lastStartTime = startTime; - } - - /// - /// Applies a random offset in a random direction to a position, ensuring that the final position remains within the boundary of the playfield. - /// - /// The position which the offset should be applied to. - /// The maximum offset, cannot exceed 20px. - private void applyRandomOffset(ref float position, double maxOffset) - { - bool right = RNG.NextBool(); - float rand = Math.Min(20, (float)RNG.NextDouble(0, Math.Max(0, maxOffset))) / CatchPlayfield.BASE_WIDTH; - - if (right) - { - // Clamp to the right bound - if (position + rand <= 1) - position += rand; - else - position -= rand; - } - else - { - // Clamp to the left bound - if (position - rand >= 0) - position -= rand; - else - position += rand; - } - } - - /// - /// Applies an offset to a position, ensuring that the final position remains within the boundary of the playfield. - /// - /// The position which the offset should be applied to. - /// The amount to offset by. - private void applyOffset(ref float position, float amount) - { - if (amount > 0) - { - // Clamp to the right bound - if (position + amount < 1) - position += amount; - } - else - { - // Clamp to the left bound - if (position + amount > 0) - position += amount; - } - } + public void ApplyToBeatmap(IBeatmap beatmap) => CatchBeatmapProcessor.ApplyPositionOffsets(beatmap, this); } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs index 606a935229..4b008d2734 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs @@ -3,7 +3,7 @@ using System.Linq; using osu.Framework.Graphics; -using osu.Game.Rulesets.Catch.Objects.Drawable; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; @@ -17,9 +17,11 @@ namespace osu.Game.Rulesets.Catch.Mods private const double fade_out_offset_multiplier = 0.6; private const double fade_out_duration_multiplier = 0.44; - protected override void ApplyHiddenState(DrawableHitObject drawable, ArmedState state) + protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) { - if (!(drawable is DrawableCatchHitObject catchDrawable)) + base.ApplyNormalVisibilityState(hitObject, state); + + if (!(hitObject is DrawableCatchHitObject catchDrawable)) return; if (catchDrawable.NestedHitObjects.Any()) diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs b/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs index da2edcee44..c07087efaf 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModNightcore.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 osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModNightcore : ModNightcore + public class CatchModNightcore : ModNightcore { public override double ScoreMultiplier => 1.06; } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs index 0454bc969d..1e42c6a240 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs @@ -1,12 +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 osu.Framework.Graphics; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osuTK; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModRelax : ModRelax + public class CatchModRelax : ModRelax, IApplicableToDrawableRuleset, IApplicableToPlayer { public override string Description => @"Use the mouse to control the catcher."; + + private DrawableRuleset drawableRuleset; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + this.drawableRuleset = drawableRuleset; + } + + public void ApplyToPlayer(Player player) + { + if (!drawableRuleset.HasReplayLoaded.Value) + drawableRuleset.Cursor.Add(new MouseInputHelper((CatchPlayfield)drawableRuleset.Playfield)); + } + + private class MouseInputHelper : Drawable, IKeyBindingHandler, IRequireHighFrequencyMousePosition + { + private readonly Catcher catcher; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + public MouseInputHelper(CatchPlayfield playfield) + { + catcher = playfield.CatcherArea.MovableCatcher; + RelativeSizeAxes = Axes.Both; + } + + // disable keyboard controls + public bool OnPressed(CatchAction action) => true; + + public void OnReleased(CatchAction action) + { + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + catcher.UpdatePosition(e.MousePosition.X / DrawSize.X * CatchPlayfield.WIDTH); + return base.OnMouseMove(e); + } + } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Banana.cs b/osu.Game.Rulesets.Catch/Objects/Banana.cs index 0b3d1d23e0..4ecfb7b16d 100644 --- a/osu.Game.Rulesets.Catch/Objects/Banana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Banana.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.Collections.Generic; +using osu.Game.Audio; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Judgements; @@ -8,8 +10,27 @@ namespace osu.Game.Rulesets.Catch.Objects { public class Banana : Fruit { + /// + /// Index of banana in current shower. + /// + public int BananaIndex; + public override FruitVisualRepresentation VisualRepresentation => FruitVisualRepresentation.Banana; public override Judgement CreateJudgement() => new CatchBananaJudgement(); + + private static readonly List samples = new List { new BananaHitSampleInfo() }; + + public Banana() + { + Samples = samples; + } + + private class BananaHitSampleInfo : HitSampleInfo + { + private static string[] lookupNames { get; } = { "metronomelow", "catch-banana" }; + + public override IEnumerable LookupNames => lookupNames; + } } } diff --git a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs index 6d44e4660e..89c51459a6 100644 --- a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs @@ -1,23 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Threading; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Catch.Objects { - public class BananaShower : CatchHitObject, IHasEndTime + public class BananaShower : CatchHitObject, IHasDuration { public override FruitVisualRepresentation VisualRepresentation => FruitVisualRepresentation.Banana; public override bool LastInCombo => true; - protected override void CreateNestedHitObjects() + public override Judgement CreateJudgement() => new IgnoreJudgement(); + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - base.CreateNestedHitObjects(); - createBananas(); + base.CreateNestedHitObjects(cancellationToken); + createBananas(cancellationToken); } - private void createBananas() + private void createBananas(CancellationToken cancellationToken) { double spacing = Duration; while (spacing > 100) @@ -26,15 +30,29 @@ namespace osu.Game.Rulesets.Catch.Objects if (spacing <= 0) return; - for (double i = StartTime; i <= EndTime; i += spacing) + double time = StartTime; + int i = 0; + + while (time <= EndTime) + { + cancellationToken.ThrowIfCancellationRequested(); + AddNested(new Banana { - Samples = Samples, - StartTime = i + StartTime = time, + BananaIndex = i, }); + + time += spacing; + i++; + } } - public double EndTime => StartTime + Duration; + public double EndTime + { + get => StartTime + Duration; + set => Duration = value - StartTime; + } public double Duration { get; set; } } diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index be76edc01b..5985ec9b68 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -1,32 +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 osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Catch.Objects { public abstract class CatchHitObject : HitObject, IHasXPosition, IHasComboInformation { - public const double OBJECT_RADIUS = 44; + public const float OBJECT_RADIUS = 64; - public float X { get; set; } + private float x; + + /// + /// The horizontal position of the fruit between 0 and . + /// + public float X + { + get => x + XOffset; + set => x = value; + } + + /// + /// Whether this object can be placed on the catcher's plate. + /// + public virtual bool CanBePlated => false; + + /// + /// A random offset applied to , set by the . + /// + internal float XOffset { get; set; } public double TimePreempt = 1000; public int IndexInBeatmap { get; set; } - public virtual FruitVisualRepresentation VisualRepresentation => (FruitVisualRepresentation)(ComboIndex % 4); + public virtual FruitVisualRepresentation VisualRepresentation => (FruitVisualRepresentation)(IndexInBeatmap % 4); public virtual bool NewCombo { get; set; } public int ComboOffset { get; set; } - public int IndexInCurrentCombo { get; set; } + public Bindable IndexInCurrentComboBindable { get; } = new Bindable(); - public int ComboIndex { get; set; } + public int IndexInCurrentCombo + { + get => IndexInCurrentComboBindable.Value; + set => IndexInCurrentComboBindable.Value = value; + } + + public Bindable ComboIndexBindable { get; } = new Bindable(); + + public int ComboIndex + { + get => ComboIndexBindable.Value; + set => ComboIndexBindable.Value = value; + } /// /// Difference between the distance to the next object @@ -35,10 +70,16 @@ namespace osu.Game.Rulesets.Catch.Objects /// public float DistanceToHyperDash { get; set; } + public Bindable LastInComboBindable { get; } = new Bindable(); + /// /// The next fruit starts a new combo. Used for explodey. /// - public virtual bool LastInCombo { get; set; } + public virtual bool LastInCombo + { + get => LastInComboBindable.Value; + set => LastInComboBindable.Value = value; + } public float Scale { get; set; } = 1; @@ -58,18 +99,26 @@ namespace osu.Game.Rulesets.Catch.Objects TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450); - Scale = 1.0f - 0.7f * (difficulty.CircleSize - 5) / 5; + Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2; } - protected override HitWindows CreateHitWindows() => null; + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + } + + /// + /// Represents a single object that can be caught by the catcher. + /// + public abstract class PalpableCatchHitObject : CatchHitObject + { + public override bool CanBePlated => true; } public enum FruitVisualRepresentation { Pear, Grape, - Raspberry, Pineapple, + Raspberry, Banana // banananananannaanana } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBanana.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBanana.cs deleted file mode 100644 index 5afdb14888..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBanana.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Game.Rulesets.Catch.Objects.Drawable -{ - public class DrawableBanana : DrawableFruit - { - public DrawableBanana(Banana h) - : base(h) - { - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBananaShower.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBananaShower.cs deleted file mode 100644 index 42646851d7..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableBananaShower.cs +++ /dev/null @@ -1,36 +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.Linq; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Objects.Drawables; - -namespace osu.Game.Rulesets.Catch.Objects.Drawable -{ - public class DrawableBananaShower : DrawableCatchHitObject - { - private readonly Container bananaContainer; - - public DrawableBananaShower(BananaShower s, Func> createDrawableRepresentation = null) - : base(s) - { - RelativeSizeAxes = Axes.X; - Origin = Anchor.BottomLeft; - X = 0; - - AddInternal(bananaContainer = new Container { RelativeSizeAxes = Axes.Both }); - - foreach (var b in s.NestedHitObjects.Cast()) - AddNested(createDrawableRepresentation?.Invoke(b)); - } - - protected override void AddNested(DrawableHitObject h) - { - ((DrawableCatchHitObject)h).CheckPosition = o => CheckPosition?.Invoke(o) ?? false; - bananaContainer.Add(h); - base.AddNested(h); - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableCatchHitObject.cs deleted file mode 100644 index a1279e8443..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableCatchHitObject.cs +++ /dev/null @@ -1,86 +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 osuTK; -using osu.Framework.Graphics; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Catch.Objects.Drawable -{ - public abstract class PalpableCatchHitObject : DrawableCatchHitObject - where TObject : CatchHitObject - { - public override bool CanBePlated => true; - - protected PalpableCatchHitObject(TObject hitObject) - : base(hitObject) - { - Scale = new Vector2(HitObject.Scale); - } - } - - public abstract class DrawableCatchHitObject : DrawableCatchHitObject - where TObject : CatchHitObject - { - public new TObject HitObject; - - protected DrawableCatchHitObject(TObject hitObject) - : base(hitObject) - { - HitObject = hitObject; - Anchor = Anchor.BottomLeft; - } - } - - public abstract class DrawableCatchHitObject : DrawableHitObject - { - public virtual bool CanBePlated => false; - - public virtual bool StaysOnPlate => CanBePlated; - - protected DrawableCatchHitObject(CatchHitObject hitObject) - : base(hitObject) - { - RelativePositionAxes = Axes.X; - X = hitObject.X; - } - - public Func CheckPosition; - - protected override void CheckForResult(bool userTriggered, double timeOffset) - { - if (CheckPosition == null) return; - - if (timeOffset >= 0 && Result != null) - ApplyResult(r => r.Type = CheckPosition.Invoke(HitObject) ? HitResult.Perfect : HitResult.Miss); - } - - protected override bool UseTransformStateManagement => false; - - protected override void UpdateState(ArmedState state) - { - // TODO: update to use new state management. - using (BeginAbsoluteSequence(HitObject.StartTime - HitObject.TimePreempt)) - this.FadeIn(200); - - var endTime = (HitObject as IHasEndTime)?.EndTime ?? HitObject.StartTime; - - using (BeginAbsoluteSequence(endTime, true)) - { - switch (state) - { - case ArmedState.Miss: - this.FadeOut(250).RotateTo(Rotation * 2, 250, Easing.Out).Expire(); - break; - - case ArmedState.Hit: - this.FadeOut().Expire(); - break; - } - } - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableDroplet.cs deleted file mode 100644 index 059310d671..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableDroplet.cs +++ /dev/null @@ -1,33 +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.Graphics; -using osu.Game.Rulesets.Catch.Objects.Drawable.Pieces; -using osuTK; - -namespace osu.Game.Rulesets.Catch.Objects.Drawable -{ - public class DrawableDroplet : PalpableCatchHitObject - { - private Pulp pulp; - - public override bool StaysOnPlate => false; - - public DrawableDroplet(Droplet h) - : base(h) - { - Origin = Anchor.Centre; - Size = new Vector2((float)CatchHitObject.OBJECT_RADIUS) / 4; - Masking = false; - } - - [BackgroundDependencyLoader] - private void load() - { - AddInternal(pulp = new Pulp { Size = Size }); - - AccentColour.BindValueChanged(colour => { pulp.AccentColour = colour.NewValue; }, true); - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableFruit.cs deleted file mode 100644 index ce2daebbf1..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableFruit.cs +++ /dev/null @@ -1,316 +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.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.MathUtils; -using osu.Game.Rulesets.Catch.Objects.Drawable.Pieces; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Rulesets.Catch.Objects.Drawable -{ - public class DrawableFruit : PalpableCatchHitObject - { - private Circle border; - - private const float drawable_radius = (float)CatchHitObject.OBJECT_RADIUS * radius_adjust; - - /// - /// Because we're adding a border around the fruit, we need to scale down some. - /// - private const float radius_adjust = 1.1f; - - public DrawableFruit(Fruit h) - : base(h) - { - Origin = Anchor.Centre; - - Size = new Vector2(drawable_radius); - Masking = false; - - Rotation = (float)(RNG.NextDouble() - 0.5f) * 40; - } - - [BackgroundDependencyLoader] - private void load() - { - // todo: this should come from the skin. - AccentColour.Value = colourForRepresentation(HitObject.VisualRepresentation); - - AddRangeInternal(new[] - { - createPulp(HitObject.VisualRepresentation), - border = new Circle - { - EdgeEffect = new EdgeEffectParameters - { - Hollow = !HitObject.HyperDash, - Type = EdgeEffectType.Glow, - Radius = 4 * radius_adjust, - Colour = HitObject.HyperDash ? Color4.Red : AccentColour.Value.Darken(1).Opacity(0.6f) - }, - Size = new Vector2(Height), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - BorderColour = Color4.White, - BorderThickness = 3f * radius_adjust, - Children = new Framework.Graphics.Drawable[] - { - new Box - { - AlwaysPresent = true, - Colour = AccentColour.Value, - Alpha = 0, - RelativeSizeAxes = Axes.Both - } - } - }, - }); - - if (HitObject.HyperDash) - { - AddInternal(new Pulp - { - RelativePositionAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AccentColour = Color4.Red, - Blending = BlendingMode.Additive, - Alpha = 0.5f, - Scale = new Vector2(1.333f) - }); - } - } - - private Framework.Graphics.Drawable createPulp(FruitVisualRepresentation representation) - { - const float large_pulp_3 = 8f * radius_adjust; - const float distance_from_centre_3 = 0.15f; - - const float large_pulp_4 = large_pulp_3 * 0.925f; - const float distance_from_centre_4 = distance_from_centre_3 / 0.925f; - - const float small_pulp = large_pulp_3 / 2; - - Vector2 positionAt(float angle, float distance) => new Vector2( - distance * (float)Math.Sin(angle * Math.PI / 180), - distance * (float)Math.Cos(angle * Math.PI / 180)); - - switch (representation) - { - default: - return new Container(); - - case FruitVisualRepresentation.Raspberry: - return new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Framework.Graphics.Drawable[] - { - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(small_pulp), - Y = -0.34f, - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(0, distance_from_centre_4), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(90, distance_from_centre_4), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(180, distance_from_centre_4), - }, - new Pulp - { - Size = new Vector2(large_pulp_4), - AccentColour = AccentColour.Value, - Position = positionAt(270, distance_from_centre_4), - }, - } - }; - - case FruitVisualRepresentation.Pineapple: - return new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Framework.Graphics.Drawable[] - { - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(small_pulp), - Y = -0.3f, - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(45, distance_from_centre_4), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(135, distance_from_centre_4), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4), - Position = positionAt(225, distance_from_centre_4), - }, - new Pulp - { - Size = new Vector2(large_pulp_4), - AccentColour = AccentColour.Value, - Position = positionAt(315, distance_from_centre_4), - }, - } - }; - - case FruitVisualRepresentation.Pear: - return new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Framework.Graphics.Drawable[] - { - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(small_pulp), - Y = -0.33f, - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_3), - Position = positionAt(60, distance_from_centre_3), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_3), - Position = positionAt(180, distance_from_centre_3), - }, - new Pulp - { - Size = new Vector2(large_pulp_3), - AccentColour = AccentColour.Value, - Position = positionAt(300, distance_from_centre_3), - }, - } - }; - - case FruitVisualRepresentation.Grape: - return new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Framework.Graphics.Drawable[] - { - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(small_pulp), - Y = -0.25f, - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_3), - Position = positionAt(0, distance_from_centre_3), - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_3), - Position = positionAt(120, distance_from_centre_3), - }, - new Pulp - { - Size = new Vector2(large_pulp_3), - AccentColour = AccentColour.Value, - Position = positionAt(240, distance_from_centre_3), - }, - } - }; - - case FruitVisualRepresentation.Banana: - return new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Framework.Graphics.Drawable[] - { - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(small_pulp), - Y = -0.3f - }, - new Pulp - { - AccentColour = AccentColour.Value, - Size = new Vector2(large_pulp_4 * 0.8f, large_pulp_4 * 2.5f), - Y = 0.05f, - }, - } - }; - } - } - - protected override void Update() - { - base.Update(); - - border.Alpha = (float)MathHelper.Clamp((HitObject.StartTime - Time.Current) / 500, 0, 1); - } - - private Color4 colourForRepresentation(FruitVisualRepresentation representation) - { - switch (representation) - { - default: - case FruitVisualRepresentation.Pear: - return new Color4(17, 136, 170, 255); - - case FruitVisualRepresentation.Grape: - return new Color4(204, 102, 0, 255); - - case FruitVisualRepresentation.Raspberry: - return new Color4(121, 9, 13, 255); - - case FruitVisualRepresentation.Pineapple: - return new Color4(102, 136, 0, 255); - - case FruitVisualRepresentation.Banana: - switch (RNG.Next(0, 3)) - { - default: - return new Color4(255, 240, 0, 255); - - case 1: - return new Color4(255, 192, 0, 255); - - case 2: - return new Color4(214, 221, 28, 255); - } - } - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableJuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableJuiceStream.cs deleted file mode 100644 index 9e5e9f6a04..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableJuiceStream.cs +++ /dev/null @@ -1,39 +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.Linq; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Objects.Drawables; - -namespace osu.Game.Rulesets.Catch.Objects.Drawable -{ - public class DrawableJuiceStream : DrawableCatchHitObject - { - private readonly Container dropletContainer; - - public DrawableJuiceStream(JuiceStream s, Func> createDrawableRepresentation = null) - : base(s) - { - RelativeSizeAxes = Axes.Both; - Origin = Anchor.BottomLeft; - X = 0; - - AddInternal(dropletContainer = new Container { RelativeSizeAxes = Axes.Both, }); - - foreach (var o in s.NestedHitObjects.Cast()) - AddNested(createDrawableRepresentation?.Invoke(o)); - } - - protected override void AddNested(DrawableHitObject h) - { - var catchObject = (DrawableCatchHitObject)h; - - catchObject.CheckPosition = o => CheckPosition?.Invoke(o) ?? false; - - dropletContainer.Add(h); - base.AddNested(h); - } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/BananaPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/BananaPiece.cs new file mode 100644 index 0000000000..ebb0bf0f2c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/BananaPiece.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 osu.Framework.Graphics; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class BananaPiece : PulpFormation + { + public BananaPiece() + { + InternalChildren = new Drawable[] + { + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(SMALL_PULP), + Y = -0.3f + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4 * 0.8f, LARGE_PULP_4 * 2.5f), + Y = 0.05f, + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs new file mode 100644 index 0000000000..a865984d45 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.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 System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class DrawableBanana : DrawableFruit + { + public DrawableBanana(Banana h) + : base(h) + { + } + + private Color4? colour; + + protected override Color4 GetComboColour(IReadOnlyList comboColours) + { + // override any external colour changes with banananana + return colour ??= getBananaColour(); + } + + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + const float end_scale = 0.6f; + const float random_scale_range = 1.6f; + + ScaleContainer.ScaleTo(HitObject.Scale * (end_scale + random_scale_range * RNG.NextSingle())) + .Then().ScaleTo(HitObject.Scale * end_scale, HitObject.TimePreempt); + + ScaleContainer.RotateTo(getRandomAngle()) + .Then() + .RotateTo(getRandomAngle(), HitObject.TimePreempt); + + float getRandomAngle() => 180 * (RNG.NextSingle() * 2 - 1); + } + + public override void PlaySamples() + { + base.PlaySamples(); + if (Samples != null) + Samples.Frequency.Value = 0.77f + ((Banana)HitObject).BananaIndex * 0.006f; + } + + private Color4 getBananaColour() + { + switch (RNG.Next(0, 3)) + { + default: + return new Color4(255, 240, 0, 255); + + case 1: + return new Color4(255, 192, 0, 255); + + case 2: + return new Color4(214, 221, 28, 255); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.cs new file mode 100644 index 0000000000..4ce80aceb8 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBananaShower.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; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class DrawableBananaShower : DrawableCatchHitObject + { + private readonly Func> createDrawableRepresentation; + private readonly Container bananaContainer; + + public DrawableBananaShower(BananaShower s, Func> createDrawableRepresentation = null) + : base(s) + { + this.createDrawableRepresentation = createDrawableRepresentation; + RelativeSizeAxes = Axes.X; + Origin = Anchor.BottomLeft; + X = 0; + + AddInternal(bananaContainer = new Container { RelativeSizeAxes = Axes.Both }); + } + + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + bananaContainer.Add(hitObject); + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + bananaContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case Banana banana: + return createDrawableRepresentation?.Invoke(banana)?.With(o => ((DrawableCatchHitObject)o).CheckPosition = p => CheckPosition?.Invoke(p) ?? false); + } + + return base.CreateNestedHitObject(hitObject); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs new file mode 100644 index 0000000000..7922510a49 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.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 System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public abstract class PalpableDrawableCatchHitObject : DrawableCatchHitObject + where TObject : PalpableCatchHitObject + { + protected Container ScaleContainer { get; private set; } + + protected PalpableDrawableCatchHitObject(TObject hitObject) + : base(hitObject) + { + Origin = Anchor.Centre; + Size = new Vector2(CatchHitObject.OBJECT_RADIUS * 2); + Masking = false; + } + + [BackgroundDependencyLoader] + private void load() + { + AddRangeInternal(new Drawable[] + { + ScaleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + } + }); + + ScaleContainer.Scale = new Vector2(HitObject.Scale); + } + + protected override Color4 GetComboColour(IReadOnlyList comboColours) => + comboColours[(HitObject.IndexInBeatmap + 1) % comboColours.Count]; + } + + public abstract class DrawableCatchHitObject : DrawableCatchHitObject + where TObject : CatchHitObject + { + public new TObject HitObject; + + protected DrawableCatchHitObject(TObject hitObject) + : base(hitObject) + { + HitObject = hitObject; + Anchor = Anchor.BottomLeft; + } + } + + public abstract class DrawableCatchHitObject : DrawableHitObject + { + protected override double InitialLifetimeOffset => HitObject.TimePreempt; + + public virtual bool StaysOnPlate => HitObject.CanBePlated; + + public float DisplayRadius => DrawSize.X / 2 * Scale.X * HitObject.Scale; + + protected override float SamplePlaybackPosition => HitObject.X / CatchPlayfield.WIDTH; + + protected DrawableCatchHitObject(CatchHitObject hitObject) + : base(hitObject) + { + X = hitObject.X; + } + + public Func CheckPosition; + + public bool IsOnPlate; + + public override bool RemoveWhenNotAlive => IsOnPlate; + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (CheckPosition == null) return; + + if (timeOffset >= 0 && Result != null) + ApplyResult(r => r.Type = CheckPosition.Invoke(HitObject) ? r.Judgement.MaxResult : r.Judgement.MinResult); + } + + protected override void UpdateHitStateTransforms(ArmedState state) + { + switch (state) + { + case ArmedState.Miss: + this.FadeOut(250).RotateTo(Rotation * 2, 250, Easing.Out); + break; + + case ArmedState.Hit: + this.FadeOut(); + break; + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs new file mode 100644 index 0000000000..688240fd86 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.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.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class DrawableDroplet : PalpableDrawableCatchHitObject + { + public override bool StaysOnPlate => false; + + public DrawableDroplet(Droplet h) + : base(h) + { + } + + [BackgroundDependencyLoader] + private void load() + { + ScaleContainer.Child = new SkinnableDrawable(new CatchSkinComponent(CatchSkinComponents.Droplet), _ => new DropletPiece()); + } + + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + // roughly matches osu-stable + float startRotation = RNG.NextSingle() * 20; + double duration = HitObject.TimePreempt + 2000; + + ScaleContainer.RotateTo(startRotation).RotateTo(startRotation + 720, duration); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs new file mode 100644 index 0000000000..c1c34e4157 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.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; +using osu.Framework.Allocation; +using osu.Framework.Utils; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class DrawableFruit : PalpableDrawableCatchHitObject + { + public DrawableFruit(Fruit h) + : base(h) + { + } + + [BackgroundDependencyLoader] + private void load() + { + ScaleContainer.Child = new SkinnableDrawable( + new CatchSkinComponent(getComponent(HitObject.VisualRepresentation)), _ => new FruitPiece()); + + ScaleContainer.Rotation = (float)(RNG.NextDouble() - 0.5f) * 40; + } + + private CatchSkinComponents getComponent(FruitVisualRepresentation hitObjectVisualRepresentation) + { + switch (hitObjectVisualRepresentation) + { + case FruitVisualRepresentation.Pear: + return CatchSkinComponents.FruitPear; + + case FruitVisualRepresentation.Grape: + return CatchSkinComponents.FruitGrapes; + + case FruitVisualRepresentation.Pineapple: + return CatchSkinComponents.FruitApple; + + case FruitVisualRepresentation.Raspberry: + return CatchSkinComponents.FruitOrange; + + case FruitVisualRepresentation.Banana: + return CatchSkinComponents.FruitBananas; + + default: + throw new ArgumentOutOfRangeException(nameof(hitObjectVisualRepresentation), hitObjectVisualRepresentation, null); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.cs new file mode 100644 index 0000000000..7bc016d94f --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableJuiceStream.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class DrawableJuiceStream : DrawableCatchHitObject + { + private readonly Func> createDrawableRepresentation; + private readonly Container dropletContainer; + + public override Vector2 OriginPosition => base.OriginPosition - new Vector2(0, CatchHitObject.OBJECT_RADIUS); + + public DrawableJuiceStream(JuiceStream s, Func> createDrawableRepresentation = null) + : base(s) + { + this.createDrawableRepresentation = createDrawableRepresentation; + RelativeSizeAxes = Axes.X; + Origin = Anchor.BottomLeft; + X = 0; + + AddInternal(dropletContainer = new Container { RelativeSizeAxes = Axes.Both, }); + } + + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + hitObject.Origin = Anchor.BottomCentre; + + base.AddNestedHitObject(hitObject); + dropletContainer.Add(hitObject); + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + dropletContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case CatchHitObject catchObject: + return createDrawableRepresentation?.Invoke(catchObject)?.With(o => + ((DrawableCatchHitObject)o).CheckPosition = p => CheckPosition?.Invoke(p) ?? false); + } + + throw new ArgumentException($"{nameof(hitObject)} must be of type {nameof(CatchHitObject)}."); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableTinyDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs similarity index 60% rename from osu.Game.Rulesets.Catch/Objects/Drawable/DrawableTinyDroplet.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs index d41aea1e7b..ae775684d8 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/DrawableTinyDroplet.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs @@ -1,16 +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 osuTK; +using osu.Framework.Allocation; -namespace osu.Game.Rulesets.Catch.Objects.Drawable +namespace osu.Game.Rulesets.Catch.Objects.Drawables { public class DrawableTinyDroplet : DrawableDroplet { public DrawableTinyDroplet(TinyDroplet h) : base(h) { - Size = new Vector2((float)CatchHitObject.OBJECT_RADIUS) / 8; + } + + [BackgroundDependencyLoader] + private void load() + { + ScaleContainer.Scale /= 2; } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DropletPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DropletPiece.cs new file mode 100644 index 0000000000..c2499446fa --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DropletPiece.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class DropletPiece : CompositeDrawable + { + public DropletPiece() + { + Size = new Vector2(CatchHitObject.OBJECT_RADIUS / 2); + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableObject) + { + DrawableCatchHitObject drawableCatchObject = (DrawableCatchHitObject)drawableObject; + var hitObject = drawableCatchObject.HitObject; + + InternalChild = new Pulp + { + RelativeSizeAxes = Axes.Both, + AccentColour = { BindTarget = drawableObject.AccentColour } + }; + + if (hitObject.HyperDash) + { + AddInternal(new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(2f), + Depth = 1, + Children = new Drawable[] + { + new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BorderColour = Catcher.DEFAULT_HYPER_DASH_COLOUR, + BorderThickness = 6, + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0.3f, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR, + } + } + } + } + }); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs new file mode 100644 index 0000000000..4bffdab3d8 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs @@ -0,0 +1,111 @@ +// 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.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + internal class FruitPiece : CompositeDrawable + { + /// + /// Because we're adding a border around the fruit, we need to scale down some. + /// + public const float RADIUS_ADJUST = 1.1f; + + private Circle border; + private CatchHitObject hitObject; + + public FruitPiece() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableObject) + { + DrawableCatchHitObject drawableCatchObject = (DrawableCatchHitObject)drawableObject; + hitObject = drawableCatchObject.HitObject; + + AddRangeInternal(new[] + { + getFruitFor(drawableCatchObject.HitObject.VisualRepresentation), + border = new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BorderColour = Color4.White, + BorderThickness = 6f * RADIUS_ADJUST, + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + } + } + }, + }); + + if (hitObject.HyperDash) + { + AddInternal(new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BorderColour = Catcher.DEFAULT_HYPER_DASH_COLOUR, + BorderThickness = 12f * RADIUS_ADJUST, + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0.3f, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR, + } + } + }); + } + } + + protected override void Update() + { + base.Update(); + border.Alpha = (float)Math.Clamp((hitObject.StartTime - Time.Current) / 500, 0, 1); + } + + private Drawable getFruitFor(FruitVisualRepresentation representation) + { + switch (representation) + { + case FruitVisualRepresentation.Pear: + return new PearPiece(); + + case FruitVisualRepresentation.Grape: + return new GrapePiece(); + + case FruitVisualRepresentation.Pineapple: + return new PineapplePiece(); + + case FruitVisualRepresentation.Banana: + return new BananaPiece(); + + case FruitVisualRepresentation.Raspberry: + return new RaspberryPiece(); + } + + return Empty(); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/GrapePiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/GrapePiece.cs new file mode 100644 index 0000000000..1d1faf893b --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/GrapePiece.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.Graphics; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class GrapePiece : PulpFormation + { + public GrapePiece() + { + InternalChildren = new Drawable[] + { + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(SMALL_PULP), + Y = -0.25f, + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_3), + Position = PositionAt(0, DISTANCE_FROM_CENTRE_3), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_3), + Position = PositionAt(120, DISTANCE_FROM_CENTRE_3), + }, + new Pulp + { + Size = new Vector2(LARGE_PULP_3), + AccentColour = { BindTarget = AccentColour }, + Position = PositionAt(240, DISTANCE_FROM_CENTRE_3), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/PearPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/PearPiece.cs new file mode 100644 index 0000000000..7f14217cda --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/PearPiece.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.Graphics; +using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class PearPiece : PulpFormation + { + public PearPiece() + { + InternalChildren = new Drawable[] + { + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(SMALL_PULP), + Y = -0.33f, + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_3), + Position = PositionAt(60, DISTANCE_FROM_CENTRE_3), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_3), + Position = PositionAt(180, DISTANCE_FROM_CENTRE_3), + }, + new Pulp + { + Size = new Vector2(LARGE_PULP_3), + AccentColour = { BindTarget = AccentColour }, + Position = PositionAt(300, DISTANCE_FROM_CENTRE_3), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawable/Pieces/Pulp.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/Pulp.cs similarity index 56% rename from osu.Game.Rulesets.Catch/Objects/Drawable/Pieces/Pulp.cs rename to osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/Pulp.cs index b9b6d5b924..d3e4945611 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawable/Pieces/Pulp.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/Pulp.cs @@ -1,16 +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.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osuTK.Graphics; -namespace osu.Game.Rulesets.Catch.Objects.Drawable.Pieces +namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces { - public class Pulp : Circle, IHasAccentColour + public class Pulp : Circle { public Pulp() { @@ -18,36 +18,27 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawable.Pieces Anchor = Anchor.Centre; Origin = Anchor.Centre; - Blending = BlendingMode.Additive; + Blending = BlendingParameters.Additive; Colour = Color4.White.Opacity(0.9f); } - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - accentColour = value; - if (IsLoaded) updateAccentColour(); - } - } - - private void updateAccentColour() - { - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = Size.X / 2, - Colour = accentColour.Darken(0.2f).Opacity(0.75f) - }; - } + public readonly Bindable AccentColour = new Bindable(); protected override void LoadComplete() { base.LoadComplete(); - updateAccentColour(); + + AccentColour.BindValueChanged(updateAccentColour, true); + } + + private void updateAccentColour(ValueChangedEvent colour) + { + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = DrawWidth / 2, + Colour = colour.NewValue.Darken(0.2f).Opacity(0.75f) + }; } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/PineapplePiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/PineapplePiece.cs new file mode 100644 index 0000000000..c328ba1837 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/PineapplePiece.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.Catch.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class PineapplePiece : PulpFormation + { + public PineapplePiece() + { + InternalChildren = new Drawable[] + { + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(SMALL_PULP), + Y = -0.3f, + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(45, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(135, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(225, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + Size = new Vector2(LARGE_PULP_4), + AccentColour = { BindTarget = AccentColour }, + Position = PositionAt(315, DISTANCE_FROM_CENTRE_4), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/PulpFormation.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/PulpFormation.cs new file mode 100644 index 0000000000..be70c3400c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/PulpFormation.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 System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public abstract class PulpFormation : CompositeDrawable + { + protected readonly IBindable AccentColour = new Bindable(); + + protected const float LARGE_PULP_3 = 16f * FruitPiece.RADIUS_ADJUST; + protected const float DISTANCE_FROM_CENTRE_3 = 0.15f; + + protected const float LARGE_PULP_4 = LARGE_PULP_3 * 0.925f; + protected const float DISTANCE_FROM_CENTRE_4 = DISTANCE_FROM_CENTRE_3 / 0.925f; + + protected const float SMALL_PULP = LARGE_PULP_3 / 2; + + protected PulpFormation() + { + RelativeSizeAxes = Axes.Both; + } + + protected static Vector2 PositionAt(float angle, float distance) => new Vector2( + distance * MathF.Sin(angle * MathF.PI / 180), + distance * MathF.Cos(angle * MathF.PI / 180)); + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableObject) + { + DrawableCatchHitObject drawableCatchObject = (DrawableCatchHitObject)drawableObject; + AccentColour.BindTo(drawableCatchObject.AccentColour); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/RaspberryPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/RaspberryPiece.cs new file mode 100644 index 0000000000..22ce3ba5b3 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/RaspberryPiece.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.Catch.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Objects.Drawables +{ + public class RaspberryPiece : PulpFormation + { + public RaspberryPiece() + { + InternalChildren = new Drawable[] + { + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(SMALL_PULP), + Y = -0.34f, + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(0, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(90, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + AccentColour = { BindTarget = AccentColour }, + Size = new Vector2(LARGE_PULP_4), + Position = PositionAt(180, DISTANCE_FROM_CENTRE_4), + }, + new Pulp + { + Size = new Vector2(LARGE_PULP_4), + AccentColour = { BindTarget = AccentColour }, + Position = PositionAt(270, DISTANCE_FROM_CENTRE_4), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Droplet.cs b/osu.Game.Rulesets.Catch/Objects/Droplet.cs index 7b0bb3f0ae..9c1004a04b 100644 --- a/osu.Game.Rulesets.Catch/Objects/Droplet.cs +++ b/osu.Game.Rulesets.Catch/Objects/Droplet.cs @@ -6,7 +6,7 @@ using osu.Game.Rulesets.Judgements; namespace osu.Game.Rulesets.Catch.Objects { - public class Droplet : CatchHitObject + public class Droplet : PalpableCatchHitObject { public override Judgement CreateJudgement() => new CatchDropletJudgement(); } diff --git a/osu.Game.Rulesets.Catch/Objects/Fruit.cs b/osu.Game.Rulesets.Catch/Objects/Fruit.cs index 6f0423b420..43486796ad 100644 --- a/osu.Game.Rulesets.Catch/Objects/Fruit.cs +++ b/osu.Game.Rulesets.Catch/Objects/Fruit.cs @@ -6,7 +6,7 @@ using osu.Game.Rulesets.Judgements; namespace osu.Game.Rulesets.Catch.Objects { - public class Fruit : CatchHitObject + public class Fruit : PalpableCatchHitObject { public override Judgement CreateJudgement() => new CatchJudgement(); } diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 0952e8981a..e209d012fa 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -1,28 +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; using System.Collections.Generic; using System.Linq; +using System.Threading; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Catch.Objects { - public class JuiceStream : CatchHitObject, IHasCurve + public class JuiceStream : CatchHitObject, IHasPathWithRepeats { /// /// Positional distance that results in a duration of one second, before any speed adjustments. /// private const float base_scoring_distance = 100; + public override Judgement CreateJudgement() => new IgnoreJudgement(); + public int RepeatCount { get; set; } - public double Velocity; - public double TickDistance; + public double Velocity { get; private set; } + public double TickDistance { get; private set; } /// /// The length of one span of this . @@ -42,20 +46,21 @@ namespace osu.Game.Rulesets.Catch.Objects TickDistance = scoringDistance / difficulty.SliderTickRate; } - protected override void CreateNestedHitObjects() + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - base.CreateNestedHitObjects(); + base.CreateNestedHitObjects(cancellationToken); - var tickSamples = Samples.Select(s => new HitSampleInfo + var dropletSamples = Samples.Select(s => new HitSampleInfo { Bank = s.Bank, Name = @"slidertick", Volume = s.Volume }).ToList(); + int nodeIndex = 0; SliderEventDescriptor? lastEvent = null; - foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset)) + foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken)) { // generate tiny droplets since the last point if (lastEvent != null) @@ -70,12 +75,13 @@ namespace osu.Game.Rulesets.Catch.Objects for (double t = timeBetweenTiny; t < sinceLastTick; t += timeBetweenTiny) { + cancellationToken.ThrowIfCancellationRequested(); + AddNested(new TinyDroplet { - Samples = tickSamples, StartTime = t + lastEvent.Value.Time, X = X + Path.PositionAt( - lastEvent.Value.PathProgress + (t / sinceLastTick) * (e.PathProgress - lastEvent.Value.PathProgress)).X / CatchPlayfield.BASE_WIDTH, + lastEvent.Value.PathProgress + (t / sinceLastTick) * (e.PathProgress - lastEvent.Value.PathProgress)).X, }); } } @@ -90,9 +96,9 @@ namespace osu.Game.Rulesets.Catch.Objects case SliderEventType.Tick: AddNested(new Droplet { - Samples = tickSamples, + Samples = dropletSamples, StartTime = e.Time, - X = X + Path.PositionAt(e.PathProgress).X / CatchPlayfield.BASE_WIDTH, + X = X + Path.PositionAt(e.PathProgress).X, }); break; @@ -101,32 +107,46 @@ namespace osu.Game.Rulesets.Catch.Objects case SliderEventType.Repeat: AddNested(new Fruit { - Samples = Samples, + Samples = this.GetNodeSamples(nodeIndex++), StartTime = e.Time, - X = X + Path.PositionAt(e.PathProgress).X / CatchPlayfield.BASE_WIDTH, + X = X + Path.PositionAt(e.PathProgress).X, }); break; } } } - public double EndTime => StartTime + this.SpanCount() * Path.Distance / Velocity; + public float EndX => X + this.CurvePositionAt(1).X; - public float EndX => X + this.CurvePositionAt(1).X / CatchPlayfield.BASE_WIDTH; + public double Duration + { + get => this.SpanCount() * Path.Distance / Velocity; + set => throw new NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed. + } - public double Duration => EndTime - StartTime; + public double EndTime => StartTime + Duration; - private SliderPath path; + private readonly SliderPath path = new SliderPath(); public SliderPath Path { get => path; - set => path = value; + set + { + path.ControlPoints.Clear(); + path.ExpectedDistance.Value = null; + + if (value != null) + { + path.ControlPoints.AddRange(value.ControlPoints.Select(c => new PathControlPoint(c.Position.Value, c.Type.Value))); + path.ExpectedDistance.Value = value.ExpectedDistance.Value; + } + } } public double Distance => Path.Distance; - public List> NodeSamples { get; set; } = new List>(); + public List> NodeSamples { get; set; } = new List>(); public double? LegacyLastTickOffset { get; set; } } diff --git a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs index 8dd00756f2..a4f54bfe82 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs @@ -3,7 +3,7 @@ using System; using System.Linq; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Replays; using osu.Game.Rulesets.Catch.Beatmaps; @@ -27,16 +27,18 @@ namespace osu.Game.Rulesets.Catch.Replays protected Replay Replay; + private CatchReplayFrame currentFrame; + public override Replay Generate() { - // todo: add support for HT DT - const double dash_speed = CatcherArea.Catcher.BASE_SPEED; - const double movement_speed = dash_speed / 2; - float lastPosition = 0.5f; - double lastTime = 0; + if (Beatmap.HitObjects.Count == 0) + return Replay; - // Todo: Realistically this shouldn't be needed, but the first frame is skipped with the way replays are currently handled - Replay.Frames.Add(new CatchReplayFrame(-100000, lastPosition)); + // todo: add support for HT DT + const double dash_speed = Catcher.BASE_SPEED; + const double movement_speed = dash_speed / 2; + float lastPosition = CatchPlayfield.CENTER_X; + double lastTime = 0; void moveToNext(CatchHitObject h) { @@ -52,45 +54,45 @@ namespace osu.Game.Rulesets.Catch.Replays bool impossibleJump = speedRequired > movement_speed * 2; // todo: get correct catcher size, based on difficulty CS. - const float catcher_width_half = CatcherArea.CATCHER_SIZE / CatchPlayfield.BASE_WIDTH * 0.3f * 0.5f; + const float catcher_width_half = CatcherArea.CATCHER_SIZE * 0.3f * 0.5f; if (lastPosition - catcher_width_half < h.X && lastPosition + catcher_width_half > h.X) { - //we are already in the correct range. + // we are already in the correct range. lastTime = h.StartTime; - Replay.Frames.Add(new CatchReplayFrame(h.StartTime, lastPosition)); + addFrame(h.StartTime, lastPosition); return; } if (impossibleJump) { - Replay.Frames.Add(new CatchReplayFrame(h.StartTime, h.X)); + addFrame(h.StartTime, h.X); } else if (h.HyperDash) { - Replay.Frames.Add(new CatchReplayFrame(h.StartTime - timeAvailable, lastPosition)); - Replay.Frames.Add(new CatchReplayFrame(h.StartTime, h.X)); + addFrame(h.StartTime - timeAvailable, lastPosition); + addFrame(h.StartTime, h.X); } else if (dashRequired) { - //we do a movement in two parts - the dash part then the normal part... + // we do a movement in two parts - the dash part then the normal part... double timeAtNormalSpeed = positionChange / movement_speed; double timeWeNeedToSave = timeAtNormalSpeed - timeAvailable; double timeAtDashSpeed = timeWeNeedToSave / 2; float midPosition = (float)Interpolation.Lerp(lastPosition, h.X, (float)timeAtDashSpeed / timeAvailable); - //dash movement - Replay.Frames.Add(new CatchReplayFrame(h.StartTime - timeAvailable + 1, lastPosition, true)); - Replay.Frames.Add(new CatchReplayFrame(h.StartTime - timeAvailable + timeAtDashSpeed, midPosition)); - Replay.Frames.Add(new CatchReplayFrame(h.StartTime, h.X)); + // dash movement + addFrame(h.StartTime - timeAvailable + 1, lastPosition, true); + addFrame(h.StartTime - timeAvailable + timeAtDashSpeed, midPosition); + addFrame(h.StartTime, h.X); } else { double timeBefore = positionChange / movement_speed; - Replay.Frames.Add(new CatchReplayFrame(h.StartTime - timeBefore, lastPosition)); - Replay.Frames.Add(new CatchReplayFrame(h.StartTime, h.X)); + addFrame(h.StartTime - timeBefore, lastPosition); + addFrame(h.StartTime, h.X); } lastTime = h.StartTime; @@ -122,5 +124,16 @@ 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); + } } } diff --git a/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs b/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs index 103aa6c3f1..99d899db80 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchFramedReplayInputHandler.cs @@ -3,8 +3,9 @@ using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using osu.Framework.Input.StateChanges; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Replays; using osu.Game.Rulesets.Replays; @@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Catch.Replays { } - protected override bool IsImportant(CatchReplayFrame frame) => frame.Position > 0; + protected override bool IsImportant(CatchReplayFrame frame) => frame.Actions.Any(); protected float? Position { @@ -34,28 +35,15 @@ namespace osu.Game.Rulesets.Catch.Replays } } - public override List GetPendingInputs() + public override void CollectPendingInputs(List inputs) { - if (!Position.HasValue) return new List(); + if (!Position.HasValue) return; - var actions = new List(); - - if (CurrentFrame.Dashing) - actions.Add(CatchAction.Dash); - - if (Position.Value > CurrentFrame.Position) - actions.Add(CatchAction.MoveRight); - else if (Position.Value < CurrentFrame.Position) - actions.Add(CatchAction.MoveLeft); - - return new List + inputs.Add(new CatchReplayState { - new CatchReplayState - { - PressedActions = actions, - CatcherX = Position.Value - }, - }; + PressedActions = CurrentFrame?.Actions ?? new List(), + CatcherX = Position.Value + }); } public class CatchReplayState : ReplayState diff --git a/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs b/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs index 1e88b35c3b..1a80adb584 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchReplayFrame.cs @@ -1,9 +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 osu.Game.Beatmaps; using osu.Game.Replays.Legacy; -using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; @@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Catch.Replays { public class CatchReplayFrame : ReplayFrame, IConvertibleReplayFrame { + public List Actions = new List(); + public float Position; public bool Dashing; @@ -18,17 +20,49 @@ namespace osu.Game.Rulesets.Catch.Replays { } - public CatchReplayFrame(double time, float? position = null, bool dashing = false) + public CatchReplayFrame(double time, float? position = null, bool dashing = false, CatchReplayFrame lastFrame = null) : base(time) { Position = position ?? -1; Dashing = dashing; + + if (Dashing) + Actions.Add(CatchAction.Dash); + + if (lastFrame != null) + { + if (Position > lastFrame.Position) + lastFrame.Actions.Add(CatchAction.MoveRight); + else if (Position < lastFrame.Position) + lastFrame.Actions.Add(CatchAction.MoveLeft); + } } - public void ConvertFrom(LegacyReplayFrame legacyFrame, IBeatmap beatmap) + public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) { - Position = legacyFrame.Position.X / CatchPlayfield.BASE_WIDTH; - Dashing = legacyFrame.ButtonState == ReplayButtonState.Left1; + Position = currentFrame.Position.X; + Dashing = currentFrame.ButtonState == ReplayButtonState.Left1; + + if (Dashing) + Actions.Add(CatchAction.Dash); + + // this probably needs some cross-checking with osu-stable to ensure it is actually correct. + if (lastFrame is CatchReplayFrame lastCatchFrame) + { + if (Position > lastCatchFrame.Position) + lastCatchFrame.Actions.Add(CatchAction.MoveRight); + else if (Position < lastCatchFrame.Position) + lastCatchFrame.Actions.Add(CatchAction.MoveLeft); + } + } + + public LegacyReplayFrame ToLegacy(IBeatmap beatmap) + { + ReplayButtonState state = ReplayButtonState.None; + + if (Actions.Contains(CatchAction.Dash)) state |= ReplayButtonState.Left1; + + return new LegacyReplayFrame(Time, Position, null, state); } } } diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json new file mode 100644 index 0000000000..83f9e30800 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json @@ -0,0 +1,150 @@ +{ + "Mappings": [{ + "StartTime": 369, + "Objects": [{ + "StartTime": 369, + "Position": 177 + }, + { + "StartTime": 450, + "Position": 216.539276 + }, + { + "StartTime": 532, + "Position": 256.5667 + }, + { + "StartTime": 614, + "Position": 296.594116 + }, + { + "StartTime": 696, + "Position": 336.621521 + }, + { + "StartTime": 778, + "Position": 376.99762 + }, + { + "StartTime": 860, + "Position": 337.318878 + }, + { + "StartTime": 942, + "Position": 297.291443 + }, + { + "StartTime": 1024, + "Position": 257.264038 + }, + { + "StartTime": 1106, + "Position": 217.2366 + }, + { + "StartTime": 1188, + "Position": 177 + }, + { + "StartTime": 1270, + "Position": 216.818192 + }, + { + "StartTime": 1352, + "Position": 256.8456 + }, + { + "StartTime": 1434, + "Position": 296.873047 + }, + { + "StartTime": 1516, + "Position": 336.900452 + }, + { + "StartTime": 1598, + "Position": 376.99762 + }, + { + "StartTime": 1680, + "Position": 337.039948 + }, + { + "StartTime": 1762, + "Position": 297.0125 + }, + { + "StartTime": 1844, + "Position": 256.9851 + }, + { + "StartTime": 1926, + "Position": 216.957672 + }, + { + "StartTime": 2008, + "Position": 177 + }, + { + "StartTime": 2090, + "Position": 217.097137 + }, + { + "StartTime": 2172, + "Position": 257.124573 + }, + { + "StartTime": 2254, + "Position": 297.152 + }, + { + "StartTime": 2336, + "Position": 337.179443 + }, + { + "StartTime": 2418, + "Position": 376.99762 + }, + { + "StartTime": 2500, + "Position": 336.760956 + }, + { + "StartTime": 2582, + "Position": 296.733643 + }, + { + "StartTime": 2664, + "Position": 256.7062 + }, + { + "StartTime": 2746, + "Position": 216.678772 + }, + { + "StartTime": 2828, + "Position": 177 + }, + { + "StartTime": 2909, + "Position": 216.887909 + }, + { + "StartTime": 2991, + "Position": 256.915344 + }, + { + "StartTime": 3073, + "Position": 296.942749 + }, + { + "StartTime": 3155, + "Position": 336.970184 + }, + { + "StartTime": 3237, + "Position": 376.99762 + } + ] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider.osu b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider.osu new file mode 100644 index 0000000000..c1971edf2c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider.osu @@ -0,0 +1,18 @@ +osu file format v14 + +[General] +StackLeniency: 0.4 +Mode: 0 + +[Difficulty] +CircleSize:4 +OverallDifficulty:7 +ApproachRate:8 +SliderMultiplier:1.6 +SliderTickRate:4 + +[TimingPoints] +369,327.868852459016,4,2,2,32,1,0 + +[HitObjects] +177,191,369,6,0,L|382:192,7,200 diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json new file mode 100644 index 0000000000..7333b600fb --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json @@ -0,0 +1,74 @@ +{ + "Mappings": [{ + "StartTime": 369, + "Objects": [{ + "StartTime": 369, + "Position": 65 + }, + { + "StartTime": 450, + "Position": 482 + }, + { + "StartTime": 532, + "Position": 164 + }, + { + "StartTime": 614, + "Position": 315 + }, + { + "StartTime": 696, + "Position": 145 + }, + { + "StartTime": 778, + "Position": 159 + }, + { + "StartTime": 860, + "Position": 310 + }, + { + "StartTime": 942, + "Position": 441 + }, + { + "StartTime": 1024, + "Position": 428 + }, + { + "StartTime": 1106, + "Position": 243 + }, + { + "StartTime": 1188, + "Position": 422 + }, + { + "StartTime": 1270, + "Position": 481 + }, + { + "StartTime": 1352, + "Position": 104 + }, + { + "StartTime": 1434, + "Position": 473 + }, + { + "StartTime": 1516, + "Position": 135 + }, + { + "StartTime": 1598, + "Position": 360 + }, + { + "StartTime": 1680, + "Position": 123 + } + ] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner.osu b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner.osu new file mode 100644 index 0000000000..208d21a344 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner.osu @@ -0,0 +1,18 @@ +osu file format v14 + +[General] +StackLeniency: 0.4 +Mode: 0 + +[Difficulty] +CircleSize:4 +OverallDifficulty:7 +ApproachRate:8 +SliderMultiplier:1.6 +SliderTickRate:4 + +[TimingPoints] +369,327.868852459016,4,2,2,32,1,0 + +[HitObjects] +256,192,369,12,0,1680,0:0:0:0: diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json new file mode 100644 index 0000000000..bbc16ab912 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json @@ -0,0 +1,234 @@ +{ + "Mappings": [{ + "StartTime": 369, + "Objects": [{ + "StartTime": 369, + "Position": 258 + }] + }, + { + "StartTime": 450, + "Objects": [{ + "StartTime": 450, + "Position": 254 + }] + }, + { + "StartTime": 532, + "Objects": [{ + "StartTime": 532, + "Position": 241 + }] + }, + { + "StartTime": 614, + "Objects": [{ + "StartTime": 614, + "Position": 238 + }] + }, + { + "StartTime": 696, + "Objects": [{ + "StartTime": 696, + "Position": 238 + }] + }, + { + "StartTime": 778, + "Objects": [{ + "StartTime": 778, + "Position": 278 + }] + }, + { + "StartTime": 860, + "Objects": [{ + "StartTime": 860, + "Position": 238 + }] + }, + { + "StartTime": 942, + "Objects": [{ + "StartTime": 942, + "Position": 278 + }] + }, + { + "StartTime": 1024, + "Objects": [{ + "StartTime": 1024, + "Position": 238 + }] + }, + { + "StartTime": 1106, + "Objects": [{ + "StartTime": 1106, + "Position": 278 + }] + }, + { + "StartTime": 1188, + "Objects": [{ + "StartTime": 1188, + "Position": 278 + }] + }, + { + "StartTime": 1270, + "Objects": [{ + "StartTime": 1270, + "Position": 278 + }] + }, + { + "StartTime": 1352, + "Objects": [{ + "StartTime": 1352, + "Position": 238 + }] + }, + { + "StartTime": 1434, + "Objects": [{ + "StartTime": 1434, + "Position": 258 + }] + }, + { + "StartTime": 1516, + "Objects": [{ + "StartTime": 1516, + "Position": 253 + }] + }, + { + "StartTime": 1598, + "Objects": [{ + "StartTime": 1598, + "Position": 238 + }] + }, + { + "StartTime": 1680, + "Objects": [{ + "StartTime": 1680, + "Position": 260 + }] + }, + { + "StartTime": 1762, + "Objects": [{ + "StartTime": 1762, + "Position": 238 + }] + }, + { + "StartTime": 1844, + "Objects": [{ + "StartTime": 1844, + "Position": 278 + }] + }, + { + "StartTime": 1926, + "Objects": [{ + "StartTime": 1926, + "Position": 278 + }] + }, + { + "StartTime": 2008, + "Objects": [{ + "StartTime": 2008, + "Position": 238 + }] + }, + { + "StartTime": 2090, + "Objects": [{ + "StartTime": 2090, + "Position": 238 + }] + }, + { + "StartTime": 2172, + "Objects": [{ + "StartTime": 2172, + "Position": 243 + }] + }, + { + "StartTime": 2254, + "Objects": [{ + "StartTime": 2254, + "Position": 278 + }] + }, + { + "StartTime": 2336, + "Objects": [{ + "StartTime": 2336, + "Position": 278 + }] + }, + { + "StartTime": 2418, + "Objects": [{ + "StartTime": 2418, + "Position": 238 + }] + }, + { + "StartTime": 2500, + "Objects": [{ + "StartTime": 2500, + "Position": 258 + }] + }, + { + "StartTime": 2582, + "Objects": [{ + "StartTime": 2582, + "Position": 256 + }] + }, + { + "StartTime": 2664, + "Objects": [{ + "StartTime": 2664, + "Position": 242 + }] + }, + { + "StartTime": 2746, + "Objects": [{ + "StartTime": 2746, + "Position": 238 + }] + }, + { + "StartTime": 2828, + "Objects": [{ + "StartTime": 2828, + "Position": 238 + }] + }, + { + "StartTime": 2909, + "Objects": [{ + "StartTime": 2909, + "Position": 271 + }] + }, + { + "StartTime": 2991, + "Objects": [{ + "StartTime": 2991, + "Position": 254 + }] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream.osu b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream.osu new file mode 100644 index 0000000000..321af4604b --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream.osu @@ -0,0 +1,50 @@ +osu file format v14 + +[General] +StackLeniency: 0.4 +Mode: 0 + +[Difficulty] +CircleSize:4 +OverallDifficulty:7 +ApproachRate:8 +SliderMultiplier:1.6 +SliderTickRate:1 + +[TimingPoints] +369,327.868852459016,4,2,2,32,1,0 + +[HitObjects] +258,189,369,1,0,0:0:0:0: +258,189,450,1,0,0:0:0:0: +258,189,532,1,0,0:0:0:0: +258,189,614,1,0,0:0:0:0: +258,189,696,1,0,0:0:0:0: +258,189,778,1,0,0:0:0:0: +258,189,860,1,0,0:0:0:0: +258,189,942,1,0,0:0:0:0: +258,189,1024,1,0,0:0:0:0: +258,189,1106,1,0,0:0:0:0: +258,189,1188,1,0,0:0:0:0: +258,189,1270,1,0,0:0:0:0: +258,189,1352,1,0,0:0:0:0: +258,189,1434,1,0,0:0:0:0: +258,189,1516,1,0,0:0:0:0: +258,189,1598,1,0,0:0:0:0: +258,189,1680,1,0,0:0:0:0: +258,189,1762,1,0,0:0:0:0: +258,189,1844,1,0,0:0:0:0: +258,189,1926,1,0,0:0:0:0: +258,189,2008,1,0,0:0:0:0: +258,189,2090,1,0,0:0:0:0: +258,189,2172,1,0,0:0:0:0: +258,189,2254,1,0,0:0:0:0: +258,189,2336,1,0,0:0:0:0: +258,189,2418,1,0,0:0:0:0: +258,189,2500,1,0,0:0:0:0: +258,189,2582,1,0,0:0:0:0: +258,189,2664,1,0,0:0:0:0: +258,189,2746,1,0,0:0:0:0: +258,189,2828,1,0,0:0:0:0: +258,189,2909,1,0,0:0:0:0: +258,189,2991,1,0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json new file mode 100644 index 0000000000..3bde97070c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json @@ -0,0 +1,17 @@ +{ + "Mappings": [{ + "StartTime": 3368, + "Objects": [{ + "StartTime": 3368, + "Position": 374 + }] + }, + { + "StartTime": 3501, + "Objects": [{ + "StartTime": 3501, + "Position": 446 + }] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset.osu b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset.osu new file mode 100644 index 0000000000..6630f369d5 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset.osu @@ -0,0 +1,20 @@ +osu file format v14 + +[General] +StackLeniency: 0.7 +Mode: 2 + +[Difficulty] +HPDrainRate:6 +CircleSize:4 +OverallDifficulty:9.6 +ApproachRate:9.6 +SliderMultiplier:1.9 +SliderTickRate:1 + +[TimingPoints] +2169,266.666666666667,4,2,1,70,1,0 + +[HitObjects] +374,60,3368,1,0,0:0:0:0: +410,146,3501,1,2,0:1:0:0: \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitWindows.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHitWindows.cs similarity index 80% rename from osu.Game.Rulesets.Catch/Objects/CatchHitWindows.cs rename to osu.Game.Rulesets.Catch/Scoring/CatchHitWindows.cs index 837662f5fe..0a444d923e 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitWindows.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchHitWindows.cs @@ -1,10 +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.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; -namespace osu.Game.Rulesets.Catch.Objects +namespace osu.Game.Rulesets.Catch.Scoring { public class CatchHitWindows : HitWindows { @@ -12,7 +11,7 @@ namespace osu.Game.Rulesets.Catch.Objects { switch (result) { - case HitResult.Perfect: + case HitResult.Great: case HitResult.Miss: return true; } diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs index 99b22b2d56..2cc05826b4 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs @@ -1,43 +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.Beatmaps; -using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Catch.Scoring { - public class CatchScoreProcessor : ScoreProcessor + public class CatchScoreProcessor : ScoreProcessor { - public CatchScoreProcessor(DrawableRuleset drawableRuleset) - : base(drawableRuleset) - { - } - - private float hpDrainRate; - - protected override void ApplyBeatmap(Beatmap beatmap) - { - base.ApplyBeatmap(beatmap); - - hpDrainRate = beatmap.BeatmapInfo.BaseDifficulty.DrainRate; - } - - protected override double HealthAdjustmentFactorFor(JudgementResult result) - { - switch (result.Type) - { - case HitResult.Miss: - return hpDrainRate; - - default: - return 10.2 - hpDrainRate; // Award less HP as drain rate is increased - } - } - - public override HitWindows CreateHitWindows() => new CatchHitWindows(); } } diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs new file mode 100644 index 0000000000..22db147e32 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.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 Humanizer; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; +using static osu.Game.Skinning.LegacySkinConfiguration; + +namespace osu.Game.Rulesets.Catch.Skinning +{ + public class CatchLegacySkinTransformer : LegacySkinTransformer + { + /// + /// 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"); + + public CatchLegacySkinTransformer(ISkinSource source) + : base(source) + { + } + + public override Drawable GetDrawableComponent(ISkinComponent component) + { + if (component is HUDSkinComponent hudComponent) + { + switch (hudComponent.Component) + { + case HUDSkinComponents.ComboCounter: + // catch may provide its own combo counter; hide the default. + return providesComboCounter ? Drawable.Empty() : null; + } + } + + if (!(component is CatchSkinComponent catchSkinComponent)) + return null; + + switch (catchSkinComponent.Component) + { + case CatchSkinComponents.FruitApple: + case CatchSkinComponents.FruitBananas: + case CatchSkinComponents.FruitOrange: + case CatchSkinComponents.FruitGrapes: + case CatchSkinComponents.FruitPear: + var lookupName = catchSkinComponent.Component.ToString().Kebaberize(); + if (GetTexture(lookupName) != null) + return new LegacyFruitPiece(lookupName); + + break; + + case CatchSkinComponents.Droplet: + if (GetTexture("fruit-drop") != null) + return new LegacyFruitPiece("fruit-drop") { Scale = new Vector2(0.8f) }; + + break; + + 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.CatcherKiai: + return this.GetAnimation("fruit-catcher-kiai", true, true, true) ?? + this.GetAnimation("fruit-ryuuta", true, true, true); + + case CatchSkinComponents.CatchComboCounter: + + if (providesComboCounter) + return new LegacyCatchComboCounter(Source); + + break; + } + + return null; + } + + public override IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case CatchSkinColour colour: + var result = (Bindable)Source.GetConfig(new SkinCustomColourLookup(colour)); + if (result == null) + return null; + + result.Value = LegacyColourCompatibility.DisallowZeroAlpha(result.Value); + return (IBindable)result; + } + + return Source.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchSkinColour.cs b/osu.Game.Rulesets.Catch/Skinning/CatchSkinColour.cs new file mode 100644 index 0000000000..4506111498 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/CatchSkinColour.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. + +namespace osu.Game.Rulesets.Catch.Skinning +{ + public enum CatchSkinColour + { + /// + /// The colour to be used for the catcher while in hyper-dashing state. + /// + HyperDash, + + /// + /// The colour to be used for fruits that grant the catcher the ability to hyper-dash. + /// + HyperDashFruit, + + /// + /// The colour to be used for the "exploding" catcher sprite on beginning of hyper-dashing. + /// + HyperDashAfterImage, + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyCatchComboCounter.cs b/osu.Game.Rulesets.Catch/Skinning/LegacyCatchComboCounter.cs new file mode 100644 index 0000000000..34608b07ff --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/LegacyCatchComboCounter.cs @@ -0,0 +1,103 @@ +// 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.Rulesets.Catch.UI; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; +using static osu.Game.Skinning.LegacySkinConfiguration; + +namespace osu.Game.Rulesets.Catch.Skinning +{ + /// + /// A combo counter implementation that visually behaves almost similar to stable's osu!catch combo counter. + /// + public class LegacyCatchComboCounter : CompositeDrawable, ICatchComboCounter + { + private readonly LegacyRollingCounter counter; + + private readonly LegacyRollingCounter explosion; + + 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; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Scale = new Vector2(0.8f); + + InternalChildren = new Drawable[] + { + explosion = new LegacyRollingCounter(skin, fontName, fontOverlap) + { + Alpha = 0.65f, + Blending = BlendingParameters.Additive, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.5f), + }, + counter = new LegacyRollingCounter(skin, fontName, fontOverlap) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + private int lastDisplayedCombo; + + public void UpdateCombo(int combo, Color4? hitObjectColour = null) + { + if (combo == lastDisplayedCombo) + return; + + // There may still be existing transforms to the counter (including value change after 250ms), + // finish them immediately before new transforms. + counter.SetCountWithoutRolling(lastDisplayedCombo); + + lastDisplayedCombo = combo; + + if (Time.Elapsed < 0) + { + // needs more work to make rewind somehow look good. + // basically we want the previous increment to play... or turning off RemoveCompletedTransforms (not feasible from a performance angle). + Hide(); + return; + } + + // Combo fell to zero, roll down and fade out the counter. + if (combo == 0) + { + counter.Current.Value = 0; + explosion.Current.Value = 0; + + this.FadeOut(400, Easing.Out); + } + else + { + this.FadeInFromZero().Then().Delay(1000).FadeOut(300); + + counter.ScaleTo(1.5f) + .ScaleTo(0.8f, 250, Easing.Out) + .OnComplete(c => c.SetCountWithoutRolling(combo)); + + counter.Delay(250) + .ScaleTo(1f) + .ScaleTo(1.1f, 60).Then().ScaleTo(1f, 30); + + explosion.Colour = hitObjectColour ?? Color4.White; + + explosion.SetCountWithoutRolling(combo); + explosion.ScaleTo(1.5f) + .ScaleTo(1.9f, 400, Easing.Out) + .FadeOutFromOne(400); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs new file mode 100644 index 0000000000..381d066750 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs @@ -0,0 +1,81 @@ +// 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.Sprites; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Skinning +{ + internal class LegacyFruitPiece : CompositeDrawable + { + private readonly string lookupName; + + private readonly IBindable accentColour = new Bindable(); + private Sprite colouredSprite; + + public LegacyFruitPiece(string lookupName) + { + this.lookupName = lookupName; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableObject, ISkinSource skin) + { + DrawableCatchHitObject drawableCatchObject = (DrawableCatchHitObject)drawableObject; + + accentColour.BindTo(drawableCatchObject.AccentColour); + + InternalChildren = new Drawable[] + { + colouredSprite = new Sprite + { + Texture = skin.GetTexture(lookupName), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new Sprite + { + Texture = skin.GetTexture($"{lookupName}-overlay"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + + if (drawableCatchObject.HitObject.HyperDash) + { + var hyperDash = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Depth = 1, + Alpha = 0.7f, + Scale = new Vector2(1.2f), + Texture = skin.GetTexture(lookupName), + Colour = skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value ?? + skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? + Catcher.DEFAULT_HYPER_DASH_COLOUR, + }; + + AddInternal(hyperDash); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + accentColour.BindValueChanged(colour => colouredSprite.Colour = LegacyColourCompatibility.DisallowZeroAlpha(colour.NewValue), true); + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs new file mode 100644 index 0000000000..75feb21298 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs @@ -0,0 +1,62 @@ +// 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.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.UI +{ + /// + /// Represents a component that displays a skinned and handles combo judgement results for updating it accordingly. + /// + public class CatchComboDisplay : SkinnableDrawable + { + private int currentCombo; + + [CanBeNull] + public ICatchComboCounter ComboCounter => Drawable as ICatchComboCounter; + + public CatchComboDisplay() + : base(new CatchSkinComponent(CatchSkinComponents.CatchComboCounter), _ => Empty()) + { + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + ComboCounter?.UpdateCombo(currentCombo); + } + + public void OnNewResult(DrawableCatchHitObject judgedObject, JudgementResult result) + { + if (!result.Type.AffectsCombo() || !result.HasResult) + return; + + if (!result.IsHit) + { + updateCombo(0, null); + return; + } + + updateCombo(result.ComboAtJudgement + 1, judgedObject.AccentColour.Value); + } + + public void OnRevertResult(DrawableCatchHitObject judgedObject, JudgementResult result) + { + if (!result.Type.AffectsCombo() || !result.HasResult) + return; + + updateCombo(result.ComboAtJudgement, judgedObject.AccentColour.Value); + } + + private void updateCombo(int newCombo, Color4? hitObjectColour) + { + currentCombo = newCombo; + ComboCounter?.UpdateCombo(newCombo, hitObjectColour); + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index b6d8cf9cbe..735d7fc300 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -6,37 +6,54 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; +using osuTK; namespace osu.Game.Rulesets.Catch.UI { public class CatchPlayfield : ScrollingPlayfield { - public const float BASE_WIDTH = 512; + /// + /// The width of the playfield. + /// The horizontal movement of the catcher is confined in the area of this width. + /// + public const float WIDTH = 512; + + /// + /// The center position of the playfield. + /// + public const float CENTER_X = WIDTH / 2; internal readonly CatcherArea CatcherArea; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + // only check the X position; handle all vertical space. + base.ReceivePositionalInputAt(new Vector2(screenSpacePos.X, ScreenSpaceDrawQuad.Centre.Y)); + public CatchPlayfield(BeatmapDifficulty difficulty, Func> createDrawableRepresentation) { - Container explodingFruitContainer; - - InternalChildren = new Drawable[] + var explodingFruitContainer = new Container { - explodingFruitContainer = new Container - { - RelativeSizeAxes = Axes.Both, - }, - CatcherArea = new CatcherArea(difficulty) - { - CreateDrawableRepresentation = createDrawableRepresentation, - ExplodingFruitTarget = explodingFruitContainer, - Anchor = Anchor.BottomLeft, - Origin = Anchor.TopLeft, - }, - HitObjectContainer + RelativeSizeAxes = Axes.Both, + }; + + CatcherArea = new CatcherArea(difficulty) + { + CreateDrawableRepresentation = createDrawableRepresentation, + ExplodingFruitTarget = explodingFruitContainer, + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + }; + + InternalChildren = new[] + { + explodingFruitContainer, + CatcherArea.MovableCatcher.CreateProxiedContent(), + HitObjectContainer, + CatcherArea, }; } @@ -45,6 +62,7 @@ namespace osu.Game.Rulesets.Catch.UI public override void Add(DrawableHitObject h) { h.OnNewResult += onNewResult; + h.OnRevertResult += onRevertResult; base.Add(h); @@ -53,6 +71,9 @@ namespace osu.Game.Rulesets.Catch.UI } private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) - => CatcherArea.OnResult((DrawableCatchHitObject)judgedObject, result); + => CatcherArea.OnNewResult((DrawableCatchHitObject)judgedObject, result); + + private void onRevertResult(DrawableHitObject judgedObject, JudgementResult result) + => CatcherArea.OnRevertResult((DrawableCatchHitObject)judgedObject, result); } } diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs index b8d3dc9017..efc1b24ed5 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfieldAdjustmentContainer.cs @@ -10,15 +10,21 @@ namespace osu.Game.Rulesets.Catch.UI { public class CatchPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer { + private const float playfield_size_adjust = 0.8f; + protected override Container Content => content; private readonly Container content; public CatchPlayfieldAdjustmentContainer() { - Anchor = Anchor.TopCentre; - Origin = Anchor.TopCentre; + // because we are using centre anchor/origin, we will need to limit visibility in the future + // to ensure tall windows do not get a readability advantage. + // it may be possible to bake the catch-specific offsets (-100..340 mentioned below) into new values + // which are compatible with TopCentre alignment. + Anchor = Anchor.Centre; + Origin = Anchor.Centre; - Size = new Vector2(0.86f); // matches stable's vertical offset for catcher plate + Size = new Vector2(playfield_size_adjust); InternalChild = new Container { @@ -27,7 +33,7 @@ namespace osu.Game.Rulesets.Catch.UI RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fit, FillAspectRatio = 4f / 3, - Child = content = new ScalingContainer { RelativeSizeAxes = Axes.Both } + Child = content = new ScalingContainer { RelativeSizeAxes = Axes.Both, } }; } @@ -40,8 +46,14 @@ namespace osu.Game.Rulesets.Catch.UI { base.Update(); - Scale = new Vector2(Parent.ChildSize.X / CatchPlayfield.BASE_WIDTH); - Size = Vector2.Divide(Vector2.One, Scale); + // in stable, fruit fall vertically from -100 to 340. + // to emulate this, we want to make our playfield 440 gameplay pixels high. + // we then offset it -100 vertically in the position set below. + const float stable_v_offset_ratio = 440 / 384f; + + Scale = new Vector2(Parent.ChildSize.X / CatchPlayfield.WIDTH); + Position = new Vector2(0, -100 * stable_v_offset_ratio + Scale.X); + Size = Vector2.Divide(new Vector2(1, stable_v_offset_ratio), Scale); } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs b/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs new file mode 100644 index 0000000000..9a4d1f9585 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.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.Collections.Generic; +using osu.Game.Replays; +using osu.Game.Rulesets.Catch.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class CatchReplayRecorder : ReplayRecorder + { + private readonly CatchPlayfield playfield; + + public CatchReplayRecorder(Replay target, CatchPlayfield playfield) + : base(target) + { + this.playfield = playfield; + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) + => new CatchReplayFrame(Time.Current, playfield.CatcherArea.MovableCatcher.X, actions.Contains(CatchAction.Dash), previousFrame as CatchReplayFrame); + } +} diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs new file mode 100644 index 0000000000..a221ca7966 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -0,0 +1,501 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class Catcher : SkinReloadableDrawable, IKeyBindingHandler + { + /// + /// The default colour used to tint hyper-dash fruit, along with the moving catcher, its trail + /// and end glow/after-image during a hyper-dash. + /// + public static readonly Color4 DEFAULT_HYPER_DASH_COLOUR = Color4.Red; + + /// + /// The duration between transitioning to hyper-dash state. + /// + public const double HYPER_DASH_TRANSITION_DURATION = 180; + + /// + /// Whether we are hyper-dashing or not. + /// + public bool HyperDashing => hyperDashModifier != 1; + + /// + /// 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; + + public Container ExplodingFruitTarget; + + private Container caughtFruitContainer { get; } = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, + }; + + [NotNull] + private readonly Container trailsTarget; + + private CatcherTrailDisplay trails; + + public CatcherAnimationState CurrentState { get; private set; } + + /// + /// The width of the catcher which can receive fruit. Equivalent to "catchMargin" in osu-stable. + /// + public const float ALLOWED_CATCH_RANGE = 0.8f; + + /// + /// The drawable catcher for . + /// + internal Drawable CurrentDrawableCatcher => currentCatcher.Drawable; + + private bool dashing; + + public bool Dashing + { + get => dashing; + protected set + { + if (value == dashing) return; + + dashing = value; + + updateTrailVisibility(); + } + } + + /// + /// Width of the area that can be used to attempt catches during gameplay. + /// + private readonly float catchWidth; + + private CatcherSprite catcherIdle; + private CatcherSprite catcherKiai; + private CatcherSprite catcherFail; + + private CatcherSprite currentCatcher; + + private Color4 hyperDashColour = DEFAULT_HYPER_DASH_COLOUR; + private Color4 hyperDashEndGlowColour = DEFAULT_HYPER_DASH_COLOUR; + + private int currentDirection; + + private double hyperDashModifier = 1; + private int hyperDashDirection; + private float hyperDashTargetPosition; + private Bindable hitLighting; + + public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null) + { + this.trailsTarget = trailsTarget; + + Origin = Anchor.TopCentre; + + Size = new Vector2(CatcherArea.CATCHER_SIZE); + if (difficulty != null) + Scale = calculateScale(difficulty); + + catchWidth = CalculateCatchWidth(Scale); + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + hitLighting = config.GetBindable(OsuSetting.HitLighting); + + InternalChildren = new Drawable[] + { + caughtFruitContainer, + catcherIdle = new CatcherSprite(CatcherAnimationState.Idle) + { + Anchor = Anchor.TopCentre, + Alpha = 0, + }, + catcherKiai = new CatcherSprite(CatcherAnimationState.Kiai) + { + Anchor = Anchor.TopCentre, + Alpha = 0, + }, + catcherFail = new CatcherSprite(CatcherAnimationState.Fail) + { + Anchor = Anchor.TopCentre, + Alpha = 0, + } + }; + + trails = new CatcherTrailDisplay(this); + + updateCatcher(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // don't add in above load as we may potentially modify a parent in an unsafe manner. + trailsTarget.Add(trails); + } + + /// + /// Creates proxied content to be displayed beneath hitobjects. + /// + public Drawable CreateProxiedContent() => caughtFruitContainer.CreateProxy(); + + /// + /// Calculates the scale of the catcher based off the provided beatmap difficulty. + /// + private static Vector2 calculateScale(BeatmapDifficulty difficulty) + => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); + + /// + /// 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; + + /// + /// Calculates the width of the area used for attempting catches in gameplay. + /// + /// The beatmap difficulty. + internal static float CalculateCatchWidth(BeatmapDifficulty difficulty) + => CalculateCatchWidth(calculateScale(difficulty)); + + /// + /// Add a caught fruit to the catcher's stack. + /// + /// The fruit that was caught. + public void PlaceOnPlate(DrawableCatchHitObject fruit) + { + var ourRadius = fruit.DisplayRadius; + float theirRadius = 0; + + const float allowance = 10; + + while (caughtFruitContainer.Any(f => + f.LifetimeEnd == double.MaxValue && + Vector2Extensions.Distance(f.Position, fruit.Position) < (ourRadius + (theirRadius = f.DrawSize.X / 2 * f.Scale.X)) / (allowance / 2))) + { + var diff = (ourRadius + theirRadius) / allowance; + fruit.X += (RNG.NextSingle() - 0.5f) * diff * 2; + fruit.Y -= RNG.NextSingle() * diff; + } + + fruit.X = Math.Clamp(fruit.X, -CatcherArea.CATCHER_SIZE / 2, CatcherArea.CATCHER_SIZE / 2); + + caughtFruitContainer.Add(fruit); + + if (hitLighting.Value) + { + AddInternal(new HitExplosion(fruit) + { + X = fruit.X, + Scale = new Vector2(fruit.HitObject.Scale) + }); + } + } + + /// + /// Let the catcher attempt to catch a fruit. + /// + /// The fruit to catch. + /// Whether the catch is possible. + public bool AttemptCatch(CatchHitObject fruit) + { + if (!fruit.CanBePlated) + return false; + + var halfCatchWidth = catchWidth * 0.5f; + + // this stuff wil disappear once we move fruit to non-relative coordinate space in the future. + var catchObjectPosition = fruit.X; + var catcherPosition = Position.X; + + var validCatch = + catchObjectPosition >= catcherPosition - halfCatchWidth && + catchObjectPosition <= catcherPosition + halfCatchWidth; + + // only update hyperdash state if we are not catching a tiny droplet. + if (fruit is TinyDroplet) return validCatch; + + if (validCatch && fruit.HyperDash) + { + var target = fruit.HyperDashTarget; + var timeDifference = target.StartTime - fruit.StartTime; + double positionDifference = target.X - catcherPosition; + var velocity = positionDifference / Math.Max(1.0, timeDifference - 1000.0 / 60.0); + + SetHyperDashState(Math.Abs(velocity), target.X); + } + else + SetHyperDashState(); + + if (validCatch) + updateState(fruit.Kiai ? CatcherAnimationState.Kiai : CatcherAnimationState.Idle); + else if (!(fruit is Banana)) + updateState(CatcherAnimationState.Fail); + + return validCatch; + } + + /// + /// Set hyper-dash state. + /// + /// The speed multiplier. If this is less or equals to 1, this catcher will be non-hyper-dashing state. + /// When this catcher crosses this position, this catcher ends hyper-dashing. + public void SetHyperDashState(double modifier = 1, float targetPosition = -1) + { + var wasHyperDashing = HyperDashing; + + if (modifier <= 1 || X == targetPosition) + { + hyperDashModifier = 1; + hyperDashDirection = 0; + + if (wasHyperDashing) + runHyperDashStateTransition(false); + } + else + { + hyperDashModifier = modifier; + hyperDashDirection = Math.Sign(targetPosition - X); + hyperDashTargetPosition = targetPosition; + + if (!wasHyperDashing) + { + trails.DisplayEndGlow(); + runHyperDashStateTransition(true); + } + } + } + + private void runHyperDashStateTransition(bool hyperDashing) + { + 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); + } + } + + private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; + + public bool OnPressed(CatchAction action) + { + switch (action) + { + case CatchAction.MoveLeft: + currentDirection--; + return true; + + case CatchAction.MoveRight: + currentDirection++; + return true; + + case CatchAction.Dash: + Dashing = true; + return true; + } + + return false; + } + + public void OnReleased(CatchAction action) + { + switch (action) + { + case CatchAction.MoveLeft: + currentDirection++; + break; + + case CatchAction.MoveRight: + currentDirection--; + break; + + case CatchAction.Dash: + Dashing = false; + break; + } + } + + public void UpdatePosition(float position) + { + position = Math.Clamp(position, 0, CatchPlayfield.WIDTH); + + if (position == X) + return; + + Scale = new Vector2(Math.Abs(Scale.X) * (position > X ? 1 : -1), Scale.Y); + X = position; + } + + /// + /// Drop any fruit off the plate. + /// + public void Drop() + { + foreach (var f in caughtFruitContainer.ToArray()) + Drop(f); + } + + /// + /// Explode any fruit off the plate. + /// + public void Explode() + { + foreach (var f in caughtFruitContainer.ToArray()) + Explode(f); + } + + public void Drop(DrawableHitObject fruit) + { + removeFromPlateWithTransform(fruit, f => + { + f.MoveToY(f.Y + 75, 750, Easing.InSine); + f.FadeOut(750); + }); + } + + public void Explode(DrawableHitObject fruit) + { + var originalX = fruit.X * Scale.X; + + removeFromPlateWithTransform(fruit, f => + { + f.MoveToY(f.Y - 50, 250, Easing.OutSine).Then().MoveToY(f.Y + 50, 500, Easing.InSine); + f.MoveToX(f.X + originalX * 6, 1000); + f.FadeOut(750); + }); + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + + hyperDashColour = + skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? + DEFAULT_HYPER_DASH_COLOUR; + + hyperDashEndGlowColour = + skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value ?? + hyperDashColour; + + trails.HyperDashTrailsColour = hyperDashColour; + trails.EndGlowSpritesColour = hyperDashEndGlowColour; + + runHyperDashStateTransition(HyperDashing); + } + + protected override void Update() + { + base.Update(); + + if (currentDirection == 0) return; + + var direction = Math.Sign(currentDirection); + + var dashModifier = Dashing ? 1 : 0.5; + var speed = BASE_SPEED * dashModifier * hyperDashModifier; + + UpdatePosition((float)(X + direction * Clock.ElapsedFrameTime * speed)); + + // Correct overshooting. + if ((hyperDashDirection > 0 && hyperDashTargetPosition < X) || + (hyperDashDirection < 0 && hyperDashTargetPosition > X)) + { + X = hyperDashTargetPosition; + SetHyperDashState(); + } + } + + private void updateCatcher() + { + currentCatcher?.Hide(); + + switch (CurrentState) + { + default: + currentCatcher = catcherIdle; + break; + + case CatcherAnimationState.Fail: + currentCatcher = catcherFail; + break; + + case CatcherAnimationState.Kiai: + currentCatcher = catcherKiai; + break; + } + + currentCatcher.Show(); + (currentCatcher.Drawable as IFramedAnimation)?.GotoFrame(0); + } + + private void updateState(CatcherAnimationState state) + { + if (CurrentState == state) + return; + + CurrentState = state; + updateCatcher(); + } + + private void removeFromPlateWithTransform(DrawableHitObject fruit, Action action) + { + if (ExplodingFruitTarget != null) + { + fruit.Anchor = Anchor.TopLeft; + fruit.Position = caughtFruitContainer.ToSpaceOfOtherDrawable(fruit.DrawPosition, ExplodingFruitTarget); + + if (!caughtFruitContainer.Remove(fruit)) + // we may have already been removed by a previous operation (due to the weird OnLoadComplete scheduling). + // this avoids a crash on potentially attempting to Add a fruit to ExplodingFruitTarget twice. + return; + + ExplodingFruitTarget.Add(fruit); + } + + var actionTime = Clock.CurrentTime; + + fruit.ApplyCustomUpdateState += onFruitOnApplyCustomUpdateState; + onFruitOnApplyCustomUpdateState(fruit, fruit.State.Value); + + void onFruitOnApplyCustomUpdateState(DrawableHitObject o, ArmedState state) + { + using (fruit.BeginAbsoluteSequence(actionTime)) + action(fruit); + + fruit.Expire(); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatcherAnimationState.cs b/osu.Game.Rulesets.Catch/UI/CatcherAnimationState.cs new file mode 100644 index 0000000000..566e9d1911 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherAnimationState.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. + +namespace osu.Game.Rulesets.Catch.UI +{ + public enum CatcherAnimationState + { + Idle, + Fail, + Kiai + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index 0b06e958e6..5e794a76aa 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -2,22 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Bindings; -using osu.Framework.MathUtils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Replays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.UI { @@ -25,35 +21,47 @@ namespace osu.Game.Rulesets.Catch.UI { public const float CATCHER_SIZE = 106.75f; - protected internal readonly Catcher MovableCatcher; - public Func> CreateDrawableRepresentation; + public readonly Catcher MovableCatcher; + private readonly CatchComboDisplay comboDisplay; + public Container ExplodingFruitTarget { set => MovableCatcher.ExplodingFruitTarget = value; } + private DrawableCatchHitObject lastPlateableFruit; + public CatcherArea(BeatmapDifficulty difficulty = null) { - RelativeSizeAxes = Axes.X; - Height = CATCHER_SIZE; - Child = MovableCatcher = new Catcher(difficulty) + Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); + Children = new Drawable[] { - AdditiveTarget = this, + comboDisplay = new CatchComboDisplay + { + RelativeSizeAxes = Axes.None, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopLeft, + Origin = Anchor.Centre, + Margin = new MarginPadding { Bottom = 350f }, + X = CatchPlayfield.CENTER_X + }, + MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }, }; } - private DrawableCatchHitObject lastPlateableFruit; - - public void OnResult(DrawableCatchHitObject fruit, JudgementResult result) + public void OnNewResult(DrawableCatchHitObject fruit, JudgementResult result) { + if (!result.Type.IsScorable()) + return; + void runAfterLoaded(Action action) { if (lastPlateableFruit == null) return; - // this is required to make this run after the last caught fruit runs UpdateState at least once. + // this is required to make this run after the last caught fruit runs updateState() at least once. // TODO: find a better alternative if (lastPlateableFruit.IsLoaded) action(); @@ -61,21 +69,24 @@ namespace osu.Game.Rulesets.Catch.UI lastPlateableFruit.OnLoadComplete += _ => action(); } - if (result.IsHit && fruit.CanBePlated) + if (result.IsHit && fruit.HitObject.CanBePlated) { + // create a new (cloned) fruit to stay on the plate. the original is faded out immediately. var caughtFruit = (DrawableCatchHitObject)CreateDrawableRepresentation?.Invoke(fruit.HitObject); if (caughtFruit == null) return; caughtFruit.RelativePositionAxes = Axes.None; caughtFruit.Position = new Vector2(MovableCatcher.ToLocalSpace(fruit.ScreenSpaceDrawQuad.Centre).X - MovableCatcher.DrawSize.X / 2, 0); + caughtFruit.IsOnPlate = true; caughtFruit.Anchor = Anchor.TopCentre; caughtFruit.Origin = Anchor.Centre; - caughtFruit.Scale *= 0.7f; + caughtFruit.Scale *= 0.5f; + caughtFruit.LifetimeStart = caughtFruit.HitObject.StartTime; caughtFruit.LifetimeEnd = double.MaxValue; - MovableCatcher.Add(caughtFruit); + MovableCatcher.PlaceOnPlate(caughtFruit); lastPlateableFruit = caughtFruit; if (!fruit.StaysOnPlate) @@ -84,11 +95,25 @@ namespace osu.Game.Rulesets.Catch.UI if (fruit.HitObject.LastInCombo) { - if (((CatchJudgement)result.Judgement).ShouldExplodeFor(result)) + if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result)) runAfterLoaded(() => MovableCatcher.Explode()); else MovableCatcher.Drop(); } + + comboDisplay.OnNewResult(fruit, result); + } + + public void OnRevertResult(DrawableCatchHitObject fruit, JudgementResult result) + => comboDisplay.OnRevertResult(fruit, result); + + public void OnReleased(CatchAction action) + { + } + + public bool AttemptCatch(CatchHitObject obj) + { + return MovableCatcher.AttemptCatch(obj); } protected override void UpdateAfterChildren() @@ -99,349 +124,8 @@ namespace osu.Game.Rulesets.Catch.UI if (state?.CatcherX != null) MovableCatcher.X = state.CatcherX.Value; - } - public bool OnReleased(CatchAction action) => false; - - public bool AttemptCatch(CatchHitObject obj) => MovableCatcher.AttemptCatch(obj); - - public static float GetCatcherSize(BeatmapDifficulty difficulty) - { - return CATCHER_SIZE / CatchPlayfield.BASE_WIDTH * (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); - } - - public class Catcher : Container, IKeyBindingHandler - { - /// - /// Width of the area that can be used to attempt catches during gameplay. - /// - internal float CatchWidth => CATCHER_SIZE * Math.Abs(Scale.X); - - private Container caughtFruit; - - public Container ExplodingFruitTarget; - - public Container AdditiveTarget; - - public Catcher(BeatmapDifficulty difficulty = null) - { - RelativePositionAxes = Axes.X; - X = 0.5f; - - Origin = Anchor.TopCentre; - Anchor = Anchor.TopLeft; - - Size = new Vector2(CATCHER_SIZE); - if (difficulty != null) - Scale = new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); - } - - [BackgroundDependencyLoader] - private void load() - { - Children = new[] - { - caughtFruit = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.BottomCentre, - }, - createCatcherSprite(), - }; - } - - private int currentDirection; - - private bool dashing; - - protected bool Dashing - { - get => dashing; - set - { - if (value == dashing) return; - - dashing = value; - - Trail |= dashing; - } - } - - private bool trail; - - /// - /// Activate or deactive the trail. Will be automatically deactivated when conditions to keep the trail displayed are no longer met. - /// - protected bool Trail - { - get => trail; - set - { - if (value == trail) return; - - trail = value; - - if (Trail) - beginTrail(); - } - } - - private void beginTrail() - { - Trail &= dashing || HyperDashing; - Trail &= AdditiveTarget != null; - - if (!Trail) return; - - var additive = createCatcherSprite(); - - additive.Anchor = Anchor; - additive.OriginPosition = additive.OriginPosition + new Vector2(DrawWidth / 2, 0); // also temporary to align sprite correctly. - additive.Position = Position; - additive.Scale = Scale; - additive.Colour = HyperDashing ? Color4.Red : Color4.White; - additive.RelativePositionAxes = RelativePositionAxes; - additive.Blending = BlendingMode.Additive; - - AdditiveTarget.Add(additive); - - additive.FadeTo(0.4f).FadeOut(800, Easing.OutQuint).Expire(); - - Scheduler.AddDelayed(beginTrail, HyperDashing ? 25 : 50); - } - - private Drawable createCatcherSprite() => new CatcherSprite(); - - /// - /// Add a caught fruit to the catcher's stack. - /// - /// The fruit that was caught. - public void Add(DrawableHitObject fruit) - { - float ourRadius = fruit.DrawSize.X / 2 * fruit.Scale.X; - float theirRadius = 0; - - const float allowance = 6; - - while (caughtFruit.Any(f => - f.LifetimeEnd == double.MaxValue && - Vector2Extensions.Distance(f.Position, fruit.Position) < (ourRadius + (theirRadius = f.DrawSize.X / 2 * f.Scale.X)) / (allowance / 2))) - { - float diff = (ourRadius + theirRadius) / allowance; - fruit.X += (RNG.NextSingle() - 0.5f) * 2 * diff; - fruit.Y -= RNG.NextSingle() * diff; - } - - fruit.X = MathHelper.Clamp(fruit.X, -CATCHER_SIZE / 2, CATCHER_SIZE / 2); - - caughtFruit.Add(fruit); - } - - /// - /// Let the catcher attempt to catch a fruit. - /// - /// The fruit to catch. - /// Whether the catch is possible. - public bool AttemptCatch(CatchHitObject fruit) - { - float halfCatchWidth = CatchWidth * 0.5f; - - // this stuff wil disappear once we move fruit to non-relative coordinate space in the future. - var catchObjectPosition = fruit.X * CatchPlayfield.BASE_WIDTH; - var catcherPosition = Position.X * CatchPlayfield.BASE_WIDTH; - - var validCatch = - catchObjectPosition >= catcherPosition - halfCatchWidth && - catchObjectPosition <= catcherPosition + halfCatchWidth; - - if (validCatch && fruit.HyperDash) - { - var target = fruit.HyperDashTarget; - double timeDifference = target.StartTime - fruit.StartTime; - double positionDifference = target.X * CatchPlayfield.BASE_WIDTH - catcherPosition; - double velocity = positionDifference / Math.Max(1.0, timeDifference - 1000.0 / 60.0); - - SetHyperDashState(Math.Abs(velocity), target.X); - } - else - { - SetHyperDashState(); - } - - return validCatch; - } - - private double hyperDashModifier = 1; - private int hyperDashDirection; - private float hyperDashTargetPosition; - - /// - /// Whether we are hyper-dashing or not. - /// - public bool HyperDashing => hyperDashModifier != 1; - - /// - /// Set hyper-dash state. - /// - /// The speed multiplier. If this is less or equals to 1, this catcher will be non-hyper-dashing state. - /// When this catcher crosses this position, this catcher ends hyper-dashing. - public void SetHyperDashState(double modifier = 1, float targetPosition = -1) - { - const float hyper_dash_transition_length = 180; - - bool previouslyHyperDashing = HyperDashing; - - if (modifier <= 1 || X == targetPosition) - { - hyperDashModifier = 1; - hyperDashDirection = 0; - - if (previouslyHyperDashing) - { - this.FadeColour(Color4.White, hyper_dash_transition_length, Easing.OutQuint); - this.FadeTo(1, hyper_dash_transition_length, Easing.OutQuint); - } - } - else - { - hyperDashModifier = modifier; - hyperDashDirection = Math.Sign(targetPosition - X); - hyperDashTargetPosition = targetPosition; - - if (!previouslyHyperDashing) - { - this.FadeColour(Color4.OrangeRed, hyper_dash_transition_length, Easing.OutQuint); - this.FadeTo(0.2f, hyper_dash_transition_length, Easing.OutQuint); - Trail = true; - } - } - } - - public bool OnPressed(CatchAction action) - { - switch (action) - { - case CatchAction.MoveLeft: - currentDirection--; - return true; - - case CatchAction.MoveRight: - currentDirection++; - return true; - - case CatchAction.Dash: - Dashing = true; - return true; - } - - return false; - } - - public bool OnReleased(CatchAction action) - { - switch (action) - { - case CatchAction.MoveLeft: - currentDirection++; - return true; - - case CatchAction.MoveRight: - currentDirection--; - return true; - - case CatchAction.Dash: - Dashing = false; - return true; - } - - return false; - } - - /// - /// 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 / 512; - - protected override void Update() - { - base.Update(); - - if (currentDirection == 0) return; - - var direction = Math.Sign(currentDirection); - - double dashModifier = Dashing ? 1 : 0.5; - double speed = BASE_SPEED * dashModifier * hyperDashModifier; - - Scale = new Vector2(Math.Abs(Scale.X) * direction, Scale.Y); - X = (float)MathHelper.Clamp(X + direction * Clock.ElapsedFrameTime * speed, 0, 1); - - // Correct overshooting. - if ((hyperDashDirection > 0 && hyperDashTargetPosition < X) || - (hyperDashDirection < 0 && hyperDashTargetPosition > X)) - { - X = hyperDashTargetPosition; - SetHyperDashState(); - } - } - - /// - /// Drop any fruit off the plate. - /// - public void Drop() - { - var fruit = caughtFruit.ToArray(); - - foreach (var f in fruit) - { - if (ExplodingFruitTarget != null) - { - f.Anchor = Anchor.TopLeft; - f.Position = caughtFruit.ToSpaceOfOtherDrawable(f.DrawPosition, ExplodingFruitTarget); - - caughtFruit.Remove(f); - - ExplodingFruitTarget.Add(f); - } - - f.MoveToY(f.Y + 75, 750, Easing.InSine); - f.FadeOut(750); - f.Expire(); - } - } - - /// - /// Explode any fruit off the plate. - /// - public void Explode() - { - foreach (var f in caughtFruit.ToArray()) - Explode(f); - } - - public void Explode(DrawableHitObject fruit) - { - var originalX = fruit.X * Scale.X; - - if (ExplodingFruitTarget != null) - { - fruit.Anchor = Anchor.TopLeft; - fruit.Position = caughtFruit.ToSpaceOfOtherDrawable(fruit.DrawPosition, ExplodingFruitTarget); - - if (!caughtFruit.Remove(fruit)) - // we may have already been removed by a previous operation (due to the weird OnLoadComplete scheduling). - // this avoids a crash on potentially attempting to Add a fruit to ExplodingFruitTarget twice. - return; - - ExplodingFruitTarget.Add(fruit); - } - - fruit.MoveToY(fruit.Y - 50, 250, Easing.OutSine).Then().MoveToY(fruit.Y + 50, 500, Easing.InSine); - fruit.MoveToX(fruit.X + originalX * 6, 1000); - fruit.FadeOut(750); - - fruit.Expire(); - } + comboDisplay.X = MovableCatcher.X; } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs b/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs index c0c1952064..ef69e3d2d1 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs @@ -3,31 +3,57 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Catch.UI { - public class CatcherSprite : CompositeDrawable + public class CatcherSprite : SkinnableDrawable { - public CatcherSprite() + protected override bool ApplySizeRestrictionsToDefault => true; + + public CatcherSprite(CatcherAnimationState state) + : base(new CatchSkinComponent(componentFromState(state)), _ => + new DefaultCatcherSprite(state), confineMode: ConfineMode.ScaleToFit) { + RelativeSizeAxes = Axes.None; Size = new Vector2(CatcherArea.CATCHER_SIZE); // Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling. - OriginPosition = new Vector2(-0.02f, 0.06f) * CatcherArea.CATCHER_SIZE; + OriginPosition = new Vector2(0.5f, 0.06f) * CatcherArea.CATCHER_SIZE; } - [BackgroundDependencyLoader] - private void load() + private static CatchSkinComponents componentFromState(CatcherAnimationState state) { - InternalChild = new SkinnableSprite(@"Play/Catch/fruit-catcher-idle") + switch (state) { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }; + case CatcherAnimationState.Fail: + return CatchSkinComponents.CatcherFail; + + case CatcherAnimationState.Kiai: + return CatchSkinComponents.CatcherKiai; + + default: + return CatchSkinComponents.CatcherIdle; + } + } + + private class DefaultCatcherSprite : Sprite + { + private readonly CatcherAnimationState state; + + public DefaultCatcherSprite(CatcherAnimationState state) + { + this.state = state; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Texture = textures.Get($"Gameplay/catch/fruit-catcher-{state.ToString().ToLower()}"); + } } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs new file mode 100644 index 0000000000..f7e9fd19a7 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.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 JetBrains.Annotations; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.UI +{ + /// + /// Represents a component responsible for displaying + /// the appropriate catcher trails when requested to. + /// + public class CatcherTrailDisplay : CompositeDrawable + { + private readonly Catcher catcher; + + private readonly Container dashTrails; + private readonly Container hyperDashTrails; + private readonly Container endGlowSprites; + + private Color4 hyperDashTrailsColour = Catcher.DEFAULT_HYPER_DASH_COLOUR; + + public Color4 HyperDashTrailsColour + { + get => hyperDashTrailsColour; + set + { + if (hyperDashTrailsColour == value) + return; + + hyperDashTrailsColour = value; + hyperDashTrails.Colour = hyperDashTrailsColour; + } + } + + private Color4 endGlowSpritesColour = Catcher.DEFAULT_HYPER_DASH_COLOUR; + + public Color4 EndGlowSpritesColour + { + get => endGlowSpritesColour; + set + { + if (endGlowSpritesColour == value) + return; + + endGlowSpritesColour = value; + endGlowSprites.Colour = endGlowSpritesColour; + } + } + + private bool trail; + + /// + /// Whether to start displaying trails following the catcher. + /// + public bool DisplayTrail + { + get => trail; + set + { + if (trail == value) + return; + + trail = value; + + if (trail) + displayTrail(); + } + } + + public CatcherTrailDisplay([NotNull] Catcher catcher) + { + this.catcher = catcher ?? throw new ArgumentNullException(nameof(catcher)); + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new[] + { + dashTrails = new Container { RelativeSizeAxes = Axes.Both }, + hyperDashTrails = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR }, + endGlowSprites = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR }, + }; + } + + /// + /// Displays a single end-glow catcher sprite. + /// + public void DisplayEndGlow() + { + var endGlow = createTrailSprite(endGlowSprites); + + endGlow.MoveToOffset(new Vector2(0, -10), 1200, Easing.In); + endGlow.ScaleTo(endGlow.Scale * 0.95f).ScaleTo(endGlow.Scale * 1.2f, 1200, Easing.In); + endGlow.FadeOut(1200); + endGlow.Expire(true); + } + + private void displayTrail() + { + if (!DisplayTrail) + return; + + var sprite = createTrailSprite(catcher.HyperDashing ? hyperDashTrails : dashTrails); + + sprite.FadeTo(0.4f).FadeOut(800, Easing.OutQuint); + sprite.Expire(true); + + Scheduler.AddDelayed(displayTrail, catcher.HyperDashing ? 25 : 50); + } + + private CatcherTrailSprite createTrailSprite(Container target) + { + var texture = (catcher.CurrentDrawableCatcher as TextureAnimation)?.CurrentFrame ?? ((Sprite)catcher.CurrentDrawableCatcher).Texture; + + var sprite = new CatcherTrailSprite(texture) + { + Anchor = catcher.Anchor, + Scale = catcher.Scale, + Blending = BlendingParameters.Additive, + RelativePositionAxes = catcher.RelativePositionAxes, + Position = catcher.Position + }; + + target.Add(sprite); + + return sprite; + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.cs new file mode 100644 index 0000000000..56cb7dbfda --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.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.Sprites; +using osu.Framework.Graphics.Textures; +using osuTK; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class CatcherTrailSprite : Sprite + { + public CatcherTrailSprite(Texture texture) + { + Texture = texture; + + Size = new Vector2(CatcherArea.CATCHER_SIZE); + + // Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling. + OriginPosition = new Vector2(0.5f, 0.06f) * CatcherArea.CATCHER_SIZE; + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs index f48b84e344..ebe45aa3ab 100644 --- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs @@ -8,12 +8,10 @@ using osu.Game.Configuration; using osu.Game.Input.Handlers; using osu.Game.Replays; using osu.Game.Rulesets.Catch.Objects; -using osu.Game.Rulesets.Catch.Objects.Drawable; +using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Replays; -using osu.Game.Rulesets.Catch.Scoring; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; @@ -25,17 +23,17 @@ namespace osu.Game.Rulesets.Catch.UI protected override bool UserScrollSpeedAdjustment => false; - public DrawableCatchRuleset(Ruleset ruleset, WorkingBeatmap beatmap, IReadOnlyList mods) + public DrawableCatchRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) { Direction.Value = ScrollingDirection.Down; TimeRange.Value = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450); } - public override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor(this); - protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new CatchFramedReplayInputHandler(replay); + protected override ReplayRecorder CreateReplayRecorder(Replay replay) => new CatchReplayRecorder(replay, (CatchPlayfield)Playfield); + protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty, CreateDrawableRepresentation); public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchPlayfieldAdjustmentContainer(); diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs new file mode 100644 index 0000000000..04a86f83be --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs @@ -0,0 +1,122 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class HitExplosion : CompositeDrawable + { + private readonly CircularContainer largeFaint; + + public HitExplosion(DrawableCatchHitObject fruit) + { + Size = new Vector2(20); + Anchor = Anchor.TopCentre; + Origin = Anchor.BottomCentre; + + Color4 objectColour = fruit.AccentColour.Value; + + // scale roughly in-line with visual appearance of notes + + const float angle_variangle = 15; // should be less than 45 + + const float roundness = 100; + + const float initial_height = 10; + + var colour = Interpolation.ValueAt(0.4f, objectColour, Color4.White, 0, 1); + + InternalChildren = new Drawable[] + { + largeFaint = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + // we want our size to be very small so the glow dominates it. + Size = new Vector2(0.8f), + Blending = BlendingParameters.Additive, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.1f, objectColour, Color4.White, 0, 1).Opacity(0.3f), + Roundness = 160, + Radius = 200, + }, + }, + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Blending = BlendingParameters.Additive, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.6f, objectColour, Color4.White, 0, 1), + Roundness = 20, + Radius = 50, + }, + }, + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Size = new Vector2(0.01f, initial_height), + Blending = BlendingParameters.Additive, + Rotation = RNG.NextSingle(-angle_variangle, angle_variangle), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour, + Roundness = roundness, + Radius = 40, + }, + }, + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Size = new Vector2(0.01f, initial_height), + Blending = BlendingParameters.Additive, + Rotation = RNG.NextSingle(-angle_variangle, angle_variangle), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour, + Roundness = roundness, + Radius = 40, + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + const double duration = 400; + + largeFaint + .ResizeTo(largeFaint.Size * new Vector2(5, 1), duration, Easing.OutQuint) + .FadeOut(duration * 2); + + this.FadeInFromZero(50).Then().FadeOut(duration, Easing.Out); + Expire(true); + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/ICatchComboCounter.cs b/osu.Game.Rulesets.Catch/UI/ICatchComboCounter.cs new file mode 100644 index 0000000000..cfb6879067 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/ICatchComboCounter.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; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.UI +{ + /// + /// An interface providing a set of methods to update the combo counter. + /// + public interface ICatchComboCounter : IDrawable + { + /// + /// Updates the counter to animate a transition from the old combo value it had to the current provided one. + /// + /// + /// This is called regardless of whether the clock is rewinding. + /// + /// The new combo value. + /// The colour of the object if hit, null on miss. + void UpdateCombo(int combo, Color4? hitObjectColour = null); + } +} diff --git a/osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj b/osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj index 883cac67d1..b19affbf9f 100644 --- a/osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj +++ b/osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj @@ -1,9 +1,7 @@  - - netstandard2.0 + netstandard2.1 Library - AnyCPU true catch the fruit. to the beat. diff --git a/osu.Game.Rulesets.Mania.Tests.Android/Properties/AndroidManifest.xml b/osu.Game.Rulesets.Mania.Tests.Android/Properties/AndroidManifest.xml index e6728c801d..de7935b2ef 100644 --- a/osu.Game.Rulesets.Mania.Tests.Android/Properties/AndroidManifest.xml +++ b/osu.Game.Rulesets.Mania.Tests.Android/Properties/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests.iOS/Application.cs b/osu.Game.Rulesets.Mania.Tests.iOS/Application.cs index 0362402320..c381ea585d 100644 --- a/osu.Game.Rulesets.Mania.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Mania.Tests.iOS/Application.cs @@ -5,7 +5,7 @@ using UIKit; namespace osu.Game.Rulesets.Mania.Tests.iOS { - public class Application + public static class Application { public static void Main(string[] args) { diff --git a/osu.Game.Rulesets.Mania.Tests.iOS/osu.Game.Rulesets.Mania.Tests.iOS.csproj b/osu.Game.Rulesets.Mania.Tests.iOS/osu.Game.Rulesets.Mania.Tests.iOS.csproj index 24abccb19d..88ad484bc1 100644 --- a/osu.Game.Rulesets.Mania.Tests.iOS/osu.Game.Rulesets.Mania.Tests.iOS.csproj +++ b/osu.Game.Rulesets.Mania.Tests.iOS/osu.Game.Rulesets.Mania.Tests.iOS.csproj @@ -1,6 +1,5 @@ - + - Debug iPhoneSimulator @@ -13,14 +12,6 @@ - - libbass.a - PreserveNewest - - - libbass_fx.a - PreserveNewest - Linker.xml @@ -41,5 +32,4 @@ - \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests/.vscode/launch.json b/osu.Game.Rulesets.Mania.Tests/.vscode/launch.json index 492f894484..0811c2724c 100644 --- a/osu.Game.Rulesets.Mania.Tests/.vscode/launch.json +++ b/osu.Game.Rulesets.Mania.Tests/.vscode/launch.json @@ -7,7 +7,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/bin/Debug/netcoreapp2.2/osu.Game.Rulesets.Mania.Tests.dll" + "${workspaceRoot}/bin/Debug/netcoreapp3.1/osu.Game.Rulesets.Mania.Tests.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build (Debug)", @@ -20,7 +20,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/bin/Release/netcoreapp2.2/osu.Game.Rulesets.Mania.Tests.dll" + "${workspaceRoot}/bin/Release/netcoreapp3.1/osu.Game.Rulesets.Mania.Tests.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build (Release)", diff --git a/osu.Game.Rulesets.Mania.Tests/.vscode/tasks.json b/osu.Game.Rulesets.Mania.Tests/.vscode/tasks.json index 608c4340ac..ca03924c70 100644 --- a/osu.Game.Rulesets.Mania.Tests/.vscode/tasks.json +++ b/osu.Game.Rulesets.Mania.Tests/.vscode/tasks.json @@ -11,9 +11,9 @@ "build", "--no-restore", "osu.Game.Rulesets.Mania.Tests.csproj", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" @@ -26,10 +26,10 @@ "build", "--no-restore", "osu.Game.Rulesets.Mania.Tests.csproj", - "/p:Configuration=Release", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaPlacementBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs similarity index 69% rename from osu.Game.Rulesets.Mania.Tests/ManiaPlacementBlueprintTestScene.cs rename to osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs index 4b3786c30a..ece523e84c 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaPlacementBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaPlacementBlueprintTestScene.cs @@ -7,28 +7,31 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; -using osu.Game.Rulesets.Mania.Edit; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Tests.Visual; -using osuTK; using osuTK.Graphics; -namespace osu.Game.Rulesets.Mania.Tests +namespace osu.Game.Rulesets.Mania.Tests.Editor { - [Cached(Type = typeof(IManiaHitObjectComposer))] - public abstract class ManiaPlacementBlueprintTestScene : PlacementBlueprintTestScene, IManiaHitObjectComposer + public abstract class ManiaPlacementBlueprintTestScene : PlacementBlueprintTestScene { private readonly Column column; [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); + [Cached(typeof(IScrollingInfo))] + private IScrollingInfo scrollingInfo; + protected ManiaPlacementBlueprintTestScene() { + scrollingInfo = ((ScrollingTestContainer)HitObjectContainer).ScrollingInfo; + Add(column = new Column(0) { Anchor = Anchor.Centre, @@ -38,21 +41,18 @@ namespace osu.Game.Rulesets.Mania.Tests }); } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + protected override SnapResult SnapForBlueprint(PlacementBlueprint blueprint) { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + var time = column.TimeAtScreenSpacePosition(InputManager.CurrentState.Mouse.Position); + var pos = column.ScreenSpacePositionAtTime(time); - dependencies.CacheAs(((ScrollingTestContainer)HitObjectContainer).ScrollingInfo); - - return dependencies; + return new SnapResult(pos, time, column); } protected override Container CreateHitObjectContainer() => new ScrollingTestContainer(ScrollingDirection.Down) { RelativeSizeAxes = Axes.Both }; protected override void AddHitObject(DrawableHitObject hitObject) => column.Add((DrawableManiaHitObject)hitObject); - public Column ColumnAt(Vector2 screenSpacePosition) => column; - - public int TotalColumns => 1; + public ManiaPlayfield Playfield => null; } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs similarity index 68% rename from osu.Game.Rulesets.Mania.Tests/ManiaSelectionBlueprintTestScene.cs rename to osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs index b598893e8c..176fbba921 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaSelectionBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs @@ -4,25 +4,20 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Timing; -using osu.Game.Rulesets.Mania.Edit; using osu.Game.Rulesets.Mania.UI; using osu.Game.Tests.Visual; -using osuTK; using osuTK.Graphics; -namespace osu.Game.Rulesets.Mania.Tests +namespace osu.Game.Rulesets.Mania.Tests.Editor { - [Cached(Type = typeof(IManiaHitObjectComposer))] - public abstract class ManiaSelectionBlueprintTestScene : SelectionBlueprintTestScene, IManiaHitObjectComposer + public abstract class ManiaSelectionBlueprintTestScene : SelectionBlueprintTestScene { [Cached(Type = typeof(IAdjustableClock))] private readonly IAdjustableClock clock = new StopwatchClock(); - private readonly Column column; - protected ManiaSelectionBlueprintTestScene() { - Add(column = new Column(0) + Add(new Column(0) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -31,8 +26,6 @@ namespace osu.Game.Rulesets.Mania.Tests }); } - public Column ColumnAt(Vector2 screenSpacePosition) => column; - - public int TotalColumns => 1; + public ManiaPlayfield Playfield => null; } } diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneEditor.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs similarity index 89% rename from osu.Game.Rulesets.Mania.Tests/TestSceneEditor.cs rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs index 7ed886be49..d3afbc63eb 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneEditor.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneEditor.cs @@ -8,15 +8,16 @@ using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.UI; using osu.Game.Tests.Visual; -namespace osu.Game.Rulesets.Mania.Tests +namespace osu.Game.Rulesets.Mania.Tests.Editor { [TestFixture] public class TestSceneEditor : EditorTestScene { private readonly Bindable direction = new Bindable(); + protected override Ruleset CreateEditorRuleset() => new ManiaRuleset(); + public TestSceneEditor() - : base(new ManiaRuleset()) { AddStep("upwards scroll", () => direction.Value = ManiaScrollingDirection.Up); AddStep("downwards scroll", () => direction.Value = ManiaScrollingDirection.Down); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNotePlacementBlueprint.cs similarity index 93% rename from osu.Game.Rulesets.Mania.Tests/TestSceneHoldNotePlacementBlueprint.cs rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNotePlacementBlueprint.cs index b4332264b9..87c74a12cf 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNotePlacementBlueprint.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -namespace osu.Game.Rulesets.Mania.Tests +namespace osu.Game.Rulesets.Mania.Tests.Editor { public class TestSceneHoldNotePlacementBlueprint : ManiaPlacementBlueprintTestScene { diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs similarity index 91% rename from osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteSelectionBlueprint.cs rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs index 622d840a0c..24f4c6858e 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs @@ -6,14 +6,13 @@ using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; -using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Tests.Visual; -namespace osu.Game.Rulesets.Mania.Tests +namespace osu.Game.Rulesets.Mania.Tests.Editor { public class TestSceneHoldNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene { @@ -39,6 +38,8 @@ namespace osu.Game.Rulesets.Mania.Tests AccentColour = { Value = OsuColour.Gray(0.3f) } } }; + + AddBlueprint(new HoldNoteSelectionBlueprint(drawableObject)); } protected override void Update() @@ -51,7 +52,5 @@ namespace osu.Game.Rulesets.Mania.Tests nested.Y = (float)(-finalPosition * content.DrawHeight); } } - - protected override SelectionBlueprint CreateBlueprint() => new HoldNoteSelectionBlueprint(drawableObject); } } diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs new file mode 100644 index 0000000000..654b752001 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Framework.Timing; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Edit; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests.Editor +{ + public class TestSceneManiaBeatSnapGrid : EditorClockTestScene + { + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo scrollingInfo = new ScrollingTestContainer.TestScrollingInfo(); + + [Cached(typeof(EditorBeatmap))] + private EditorBeatmap editorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition())); + + private readonly ManiaBeatSnapGrid beatSnapGrid; + + public TestSceneManiaBeatSnapGrid() + { + editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 200 }); + editorBeatmap.ControlPointInfo.Add(10000, new TimingControlPoint { BeatLength = 200 }); + + BeatDivisor.Value = 3; + + // Some sane defaults + scrollingInfo.Algorithm.Algorithm = ScrollVisualisationMethod.Constant; + scrollingInfo.Direction.Value = ScrollingDirection.Up; + scrollingInfo.TimeRange.Value = 1000; + + Children = new Drawable[] + { + Playfield = new ManiaPlayfield(new List + { + new StageDefinition { Columns = 4 }, + new StageDefinition { Columns = 3 } + }) + { + Clock = new FramedClock(new StopwatchClock()) + }, + new TestHitObjectComposer(Playfield) + { + Child = beatSnapGrid = new ManiaBeatSnapGrid() + } + }; + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + // We're providing a constant scroll algorithm. + float relativePosition = Playfield.Stages[0].HitObjectContainer.ToLocalSpace(e.ScreenSpaceMousePosition).Y / Playfield.Stages[0].HitObjectContainer.DrawHeight; + double timeValue = scrollingInfo.TimeRange.Value * relativePosition; + + beatSnapGrid.SelectionTimeRange = (timeValue, timeValue); + + return true; + } + + public ManiaPlayfield Playfield { get; } + } + + public class TestHitObjectComposer : HitObjectComposer + { + public override Playfield Playfield { get; } + public override IEnumerable HitObjects => Enumerable.Empty(); + public override bool CursorInPlacementArea => false; + + public TestHitObjectComposer(Playfield playfield) + { + Playfield = playfield; + } + + public Drawable Child + { + set => InternalChild = value; + } + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + { + throw new System.NotImplementedException(); + } + + public override float GetBeatSnapDistanceAt(double referenceTime) + { + throw new System.NotImplementedException(); + } + + public override float DurationToDistance(double referenceTime, double duration) + { + throw new System.NotImplementedException(); + } + + public override double DistanceToDuration(double referenceTime, float distance) + { + throw new System.NotImplementedException(); + } + + public override double GetSnappedDurationFromDistance(double referenceTime, float distance) + { + throw new System.NotImplementedException(); + } + + public override float GetSnappedDistanceFromDistance(double referenceTime, float distance) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs new file mode 100644 index 0000000000..c9551ee79e --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs @@ -0,0 +1,218 @@ +// 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.Framework.Utils; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Edit; +using osu.Game.Rulesets.Mania.Edit.Blueprints; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Mania.Tests.Editor +{ + public class TestSceneManiaHitObjectComposer : EditorClockTestScene + { + private TestComposer composer; + + [SetUp] + public void Setup() => Schedule(() => + { + BeatDivisor.Value = 8; + Clock.Seek(0); + + Child = composer = new TestComposer { RelativeSizeAxes = Axes.Both }; + }); + + [Test] + public void TestDragOffscreenSelectionVerticallyUpScroll() + { + DrawableHitObject lastObject = null; + double originalTime = 0; + Vector2 originalPosition = Vector2.Zero; + + setScrollStep(ScrollingDirection.Up); + + AddStep("seek to last object", () => + { + lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); + originalTime = lastObject.HitObject.StartTime; + Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + }); + + AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); + + AddStep("click last object", () => + { + originalPosition = lastObject.DrawPosition; + + InputManager.MoveMouseTo(lastObject); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move mouse downwards", () => + { + InputManager.MoveMouseTo(lastObject, new Vector2(0, lastObject.ScreenSpaceDrawQuad.Height * 4)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("hitobjects not moved columns", () => composer.EditorBeatmap.HitObjects.All(h => ((ManiaHitObject)h).Column == 0)); + AddAssert("hitobjects moved downwards", () => lastObject.DrawPosition.Y - originalPosition.Y > 0); + AddAssert("hitobject has moved time", () => lastObject.HitObject.StartTime == originalTime + 125); + } + + [Test] + public void TestDragOffscreenSelectionVerticallyDownScroll() + { + DrawableHitObject lastObject = null; + double originalTime = 0; + Vector2 originalPosition = Vector2.Zero; + + setScrollStep(ScrollingDirection.Down); + + AddStep("seek to last object", () => + { + lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); + originalTime = lastObject.HitObject.StartTime; + Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + }); + + AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); + + AddStep("click last object", () => + { + originalPosition = lastObject.DrawPosition; + + InputManager.MoveMouseTo(lastObject); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move mouse upwards", () => + { + InputManager.MoveMouseTo(lastObject, new Vector2(0, -lastObject.ScreenSpaceDrawQuad.Height * 4)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("hitobjects not moved columns", () => composer.EditorBeatmap.HitObjects.All(h => ((ManiaHitObject)h).Column == 0)); + AddAssert("hitobjects moved upwards", () => originalPosition.Y - lastObject.DrawPosition.Y > 0); + AddAssert("hitobject has moved time", () => lastObject.HitObject.StartTime == originalTime + 125); + } + + [Test] + public void TestDragOffscreenSelectionHorizontally() + { + DrawableHitObject lastObject = null; + Vector2 originalPosition = Vector2.Zero; + + setScrollStep(ScrollingDirection.Down); + + AddStep("seek to last object", () => + { + lastObject = this.ChildrenOfType().Single(d => d.HitObject == composer.EditorBeatmap.HitObjects.Last()); + Clock.Seek(composer.EditorBeatmap.HitObjects.Last().StartTime); + }); + + AddStep("select all objects", () => composer.EditorBeatmap.SelectedHitObjects.AddRange(composer.EditorBeatmap.HitObjects)); + + AddStep("click last object", () => + { + originalPosition = lastObject.DrawPosition; + + InputManager.MoveMouseTo(lastObject); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move mouse right", () => + { + var firstColumn = composer.Composer.Playfield.GetColumn(0); + var secondColumn = composer.Composer.Playfield.GetColumn(1); + + InputManager.MoveMouseTo(lastObject, new Vector2(secondColumn.ScreenSpaceDrawQuad.Centre.X - firstColumn.ScreenSpaceDrawQuad.Centre.X + 1, 0)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddAssert("hitobjects moved columns", () => composer.EditorBeatmap.HitObjects.All(h => ((ManiaHitObject)h).Column == 1)); + + // Todo: They'll move vertically by the height of a note since there's no snapping and the selection point is the middle of the note. + AddAssert("hitobjects not moved vertically", () => lastObject.DrawPosition.Y - originalPosition.Y <= DefaultNotePiece.NOTE_HEIGHT); + } + + [Test] + public void TestDragHoldNoteSelectionVertically() + { + setScrollStep(ScrollingDirection.Down); + + AddStep("setup beatmap", () => + { + composer.EditorBeatmap.Clear(); + composer.EditorBeatmap.Add(new HoldNote + { + Column = 1, + EndTime = 200 + }); + }); + + DrawableHoldNote holdNote = null; + + AddStep("grab hold note", () => + { + holdNote = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(holdNote); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("move drag upwards", () => + { + InputManager.MoveMouseTo(holdNote, new Vector2(0, -100)); + InputManager.ReleaseButton(MouseButton.Left); + }); + + 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); + } + + private void setScrollStep(ScrollingDirection direction) + => AddStep($"set scroll direction = {direction}", () => ((Bindable)composer.Composer.ScrollingInfo.Direction).Value = direction); + + private class TestComposer : CompositeDrawable + { + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + public readonly EditorBeatmap EditorBeatmap; + + public readonly ManiaHitObjectComposer Composer; + + public TestComposer() + { + InternalChildren = new Drawable[] + { + EditorBeatmap = new EditorBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 })) + { + BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo } + }, + Composer = new ManiaHitObjectComposer(new ManiaRuleset()) + }; + + for (int i = 0; i < 10; i++) + EditorBeatmap.Add(new Note { StartTime = 125 * i }); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.cs new file mode 100644 index 0000000000..36c34a8fb9 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNotePlacementBlueprint.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.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Testing; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.Edit.Blueprints; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects; +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 +{ + public class TestSceneNotePlacementBlueprint : ManiaPlacementBlueprintTestScene + { + [SetUp] + public void Setup() => Schedule(() => + { + this.ChildrenOfType().ForEach(c => c.Clear()); + + ResetPlacement(); + + ((ScrollingTestContainer)HitObjectContainer).Direction = ScrollingDirection.Down; + }); + + [Test] + public void TestPlaceBeforeCurrentTimeDownwards() + { + AddStep("move mouse before current time", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single().ScreenSpaceDrawQuad.BottomLeft - new Vector2(0, 10))); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("note start time < 0", () => getNote().StartTime < 0); + } + + [Test] + public void TestPlaceAfterCurrentTimeDownwards() + { + AddStep("move mouse after current time", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("note start time > 0", () => getNote().StartTime > 0); + } + + private Note getNote() => this.ChildrenOfType().FirstOrDefault()?.HitObject; + + protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableNote((Note)hitObject); + protected override PlacementBlueprint CreateBlueprint() => new NotePlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs similarity index 84% rename from osu.Game.Rulesets.Mania.Tests/TestSceneNoteSelectionBlueprint.cs rename to osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs index 6bb344f977..0e47a12a8e 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs @@ -5,7 +5,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; @@ -13,12 +12,10 @@ using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Tests.Visual; using osuTK; -namespace osu.Game.Rulesets.Mania.Tests +namespace osu.Game.Rulesets.Mania.Tests.Editor { public class TestSceneNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene { - private readonly DrawableNote drawableObject; - protected override Container Content => content ?? base.Content; private readonly Container content; @@ -27,6 +24,8 @@ namespace osu.Game.Rulesets.Mania.Tests var note = new Note { Column = 0 }; note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + DrawableNote drawableObject; + base.Content.Child = content = new ScrollingTestContainer(ScrollingDirection.Down) { Anchor = Anchor.Centre, @@ -34,8 +33,8 @@ namespace osu.Game.Rulesets.Mania.Tests Size = new Vector2(50, 20), Child = drawableObject = new DrawableNote(note) }; - } - protected override SelectionBlueprint CreateBlueprint() => new NoteSelectionBlueprint(drawableObject); + AddBlueprint(new NoteSelectionBlueprint(drawableObject)); + } } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs index 6b95975059..3d4bc4748b 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs @@ -4,42 +4,66 @@ using System; using System.Collections.Generic; using NUnit.Framework; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Mania.Tests { [TestFixture] + [Timeout(10000)] public class ManiaBeatmapConversionTest : BeatmapConversionTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; [TestCase("basic")] - public new void Test(string name) - { - base.Test(name); - } + [TestCase("zero-length-slider")] + public void Test(string name) => base.Test(name); protected override IEnumerable CreateConvertValue(HitObject hitObject) { yield return new ConvertValue { StartTime = hitObject.StartTime, - EndTime = (hitObject as IHasEndTime)?.EndTime ?? hitObject.StartTime, + EndTime = hitObject.GetEndTime(), Column = ((ManiaHitObject)hitObject).Column }; } - protected override ManiaConvertMapping CreateConvertMapping() => new ManiaConvertMapping(Converter); + private readonly Dictionary rngSnapshots = new Dictionary(); + + protected override void OnConversionGenerated(HitObject original, IEnumerable result, IBeatmapConverter beatmapConverter) + { + base.OnConversionGenerated(original, result, beatmapConverter); + + rngSnapshots[original] = new RngSnapshot(beatmapConverter); + } + + protected override ManiaConvertMapping CreateConvertMapping(HitObject source) => new ManiaConvertMapping(rngSnapshots[source]); protected override Ruleset CreateRuleset() => new ManiaRuleset(); } + public class RngSnapshot + { + public readonly uint RandomW; + public readonly uint RandomX; + public readonly uint RandomY; + public readonly uint RandomZ; + + public RngSnapshot(IBeatmapConverter converter) + { + var maniaConverter = (ManiaBeatmapConverter)converter; + RandomW = maniaConverter.Random.W; + RandomX = maniaConverter.Random.X; + RandomY = maniaConverter.Random.Y; + RandomZ = maniaConverter.Random.Z; + } + } + public class ManiaConvertMapping : ConvertMapping, IEquatable { public uint RandomW; @@ -51,20 +75,25 @@ namespace osu.Game.Rulesets.Mania.Tests { } - public ManiaConvertMapping(IBeatmapConverter converter) + public ManiaConvertMapping(RngSnapshot snapshot) { - var maniaConverter = (ManiaBeatmapConverter)converter; - RandomW = maniaConverter.Random.W; - RandomX = maniaConverter.Random.X; - RandomY = maniaConverter.Random.Y; - RandomZ = maniaConverter.Random.Z; + RandomW = snapshot.RandomW; + RandomX = snapshot.RandomX; + RandomY = snapshot.RandomY; + RandomZ = snapshot.RandomZ; + } + + public override void PostProcess() + { + base.PostProcess(); + Objects.Sort(); } public bool Equals(ManiaConvertMapping other) => other != null && RandomW == other.RandomW && RandomX == other.RandomX && RandomY == other.RandomY && RandomZ == other.RandomZ; public override bool Equals(ConvertMapping other) => base.Equals(other) && Equals(other as ManiaConvertMapping); } - public struct ConvertValue : IEquatable + public struct ConvertValue : IEquatable, IComparable { /// /// A sane value to account for osu!stable using ints everwhere. @@ -79,5 +108,15 @@ namespace osu.Game.Rulesets.Mania.Tests => Precision.AlmostEquals(StartTime, other.StartTime, conversion_lenience) && Precision.AlmostEquals(EndTime, other.EndTime, conversion_lenience) && Column == other.Column; + + public int CompareTo(ConvertValue other) + { + var result = StartTime.CompareTo(other.StartTime); + + if (result != 0) + return result; + + return Column.CompareTo(other.Column); + } } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.cs new file mode 100644 index 0000000000..c8feb4ae24 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapSampleConversionTest.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; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class ManiaBeatmapSampleConversionTest : BeatmapConversionTest, SampleConvertValue> + { + protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; + + [TestCase("convert-samples")] + [TestCase("mania-samples")] + [TestCase("slider-convert-samples")] + public void Test(string name) => base.Test(name); + + protected override IEnumerable CreateConvertValue(HitObject hitObject) + { + yield return new SampleConvertValue + { + StartTime = hitObject.StartTime, + EndTime = hitObject.GetEndTime(), + Column = ((ManiaHitObject)hitObject).Column, + Samples = getSampleNames(hitObject.Samples), + NodeSamples = getNodeSampleNames((hitObject as HoldNote)?.NodeSamples) + }; + } + + private IList getSampleNames(IList hitSampleInfo) + => hitSampleInfo.Select(sample => sample.LookupNames.First()).ToList(); + + private IList> getNodeSampleNames(List> hitSampleInfo) + => hitSampleInfo?.Select(getSampleNames) + .ToList(); + + protected override Ruleset CreateRuleset() => new ManiaRuleset(); + } + + public struct SampleConvertValue : IEquatable + { + /// + /// A sane value to account for osu!stable using ints everywhere. + /// + private const float conversion_lenience = 2; + + public double StartTime; + public double EndTime; + public int Column; + public IList Samples; + public IList> NodeSamples; + + public bool Equals(SampleConvertValue other) + => Precision.AlmostEquals(StartTime, other.StartTime, conversion_lenience) + && Precision.AlmostEquals(EndTime, other.EndTime, conversion_lenience) + && samplesEqual(Samples, other.Samples) + && nodeSamplesEqual(NodeSamples, other.NodeSamples); + + private static bool samplesEqual(ICollection firstSampleList, ICollection secondSampleList) + => firstSampleList.SequenceEqual(secondSampleList); + + private static bool nodeSamplesEqual(ICollection> firstSampleList, ICollection> secondSampleList) + { + if (firstSampleList == null && secondSampleList == null) + return true; + + // both items can't be null now, so if any single one is, then they're not equal + if (firstSampleList == null || secondSampleList == null) + return false; + + return firstSampleList.Count == secondSampleList.Count + // cannot use .Zip() without the selector function as it doesn't compile in android test project + && firstSampleList.Zip(secondSampleList, (first, second) => (first, second)) + .All(samples => samples.first.SequenceEqual(samples.second)); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs new file mode 100644 index 0000000000..40a6e1fdae --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaColumnTypeTest.cs @@ -0,0 +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.Collections.Generic; +using osu.Game.Rulesets.Mania.Beatmaps; +using NUnit.Framework; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class ManiaColumnTypeTest + { + [TestCase(new[] + { + ColumnType.Special + }, 1)] + [TestCase(new[] + { + ColumnType.Odd, + ColumnType.Even, + ColumnType.Even, + ColumnType.Odd + }, 4)] + [TestCase(new[] + { + ColumnType.Odd, + ColumnType.Even, + ColumnType.Odd, + ColumnType.Special, + ColumnType.Odd, + ColumnType.Even, + ColumnType.Odd + }, 7)] + public void Test(IEnumerable expected, int columns) + { + var definition = new StageDefinition + { + Columns = columns + }; + var results = getResults(definition); + Assert.AreEqual(expected, results); + } + + private IEnumerable getResults(StageDefinition definition) + { + for (var i = 0; i < definition.Columns; i++) + yield return definition.GetTypeOfColumn(i); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs index 2c36e81190..a25551f854 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Mania.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; - [TestCase(2.3683365342338796d, "diffcalc-test")] + [TestCase(2.3449735700206298d, "diffcalc-test")] public void Test(double expected, string name) => base.Test(expected, name); diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs b/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs index 909d0d45c6..9049bb3a82 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaInputTestScene.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Mania.Tests { } - protected override RulesetKeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new LocalKeyBindingContainer(ruleset, variant, unique); private class LocalKeyBindingContainer : RulesetKeyBindingContainer diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.cs new file mode 100644 index 0000000000..b22687a0a7 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyModConversionTest.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 NUnit.Framework; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class ManiaLegacyModConversionTest : LegacyModConversionTest + { + [TestCase(LegacyMods.Easy, new[] { typeof(ManiaModEasy) })] + [TestCase(LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(ManiaModHardRock), typeof(ManiaModDoubleTime) })] + [TestCase(LegacyMods.DoubleTime, new[] { typeof(ManiaModDoubleTime) })] + [TestCase(LegacyMods.Nightcore, new[] { typeof(ManiaModNightcore) })] + [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(ManiaModNightcore) })] + [TestCase(LegacyMods.Flashlight | LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(ManiaModFlashlight), typeof(ManiaModNightcore) })] + [TestCase(LegacyMods.Perfect, new[] { typeof(ManiaModPerfect) })] + [TestCase(LegacyMods.SuddenDeath, new[] { typeof(ManiaModSuddenDeath) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(ManiaModPerfect) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath | LegacyMods.DoubleTime, new[] { typeof(ManiaModDoubleTime), typeof(ManiaModPerfect) })] + [TestCase(LegacyMods.Random | LegacyMods.SuddenDeath, new[] { typeof(ManiaModRandom), typeof(ManiaModSuddenDeath) })] + [TestCase(LegacyMods.Flashlight | LegacyMods.Mirror, new[] { typeof(ManiaModFlashlight), typeof(ManiaModMirror) })] + public new void Test(LegacyMods legacyMods, Type[] expectedMods) => base.Test(legacyMods, expectedMods); + + protected override Ruleset CreateRuleset() => new ManiaRuleset(); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.cs new file mode 100644 index 0000000000..40bb83aece --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaLegacyReplayTest.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 NUnit.Framework; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Replays; + +namespace osu.Game.Rulesets.Mania.Tests +{ + [TestFixture] + public class ManiaLegacyReplayTest + { + [TestCase(ManiaAction.Key1)] + [TestCase(ManiaAction.Key1, ManiaAction.Key2)] + [TestCase(ManiaAction.Special1)] + [TestCase(ManiaAction.Key8)] + public void TestEncodeDecodeSingleStage(params ManiaAction[] actions) + { + var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 9 }); + + var frame = new ManiaReplayFrame(0, actions); + var legacyFrame = frame.ToLegacy(beatmap); + + var decodedFrame = new ManiaReplayFrame(); + decodedFrame.FromLegacy(legacyFrame, beatmap); + + Assert.That(decodedFrame.Actions, Is.EquivalentTo(frame.Actions)); + } + + [TestCase(ManiaAction.Key1)] + [TestCase(ManiaAction.Key1, ManiaAction.Key2)] + [TestCase(ManiaAction.Special1)] + [TestCase(ManiaAction.Special2)] + [TestCase(ManiaAction.Special1, ManiaAction.Special2)] + [TestCase(ManiaAction.Special1, ManiaAction.Key5)] + [TestCase(ManiaAction.Key8)] + public void TestEncodeDecodeDualStage(params ManiaAction[] actions) + { + var beatmap = new ManiaBeatmap(new StageDefinition { Columns = 5 }); + beatmap.Stages.Add(new StageDefinition { Columns = 5 }); + + var frame = new ManiaReplayFrame(0, actions); + var legacyFrame = frame.ToLegacy(beatmap); + + var decodedFrame = new ManiaReplayFrame(); + decodedFrame.FromLegacy(legacyFrame, beatmap); + + Assert.That(decodedFrame.Actions, Is.EquivalentTo(frame.Actions)); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.cs new file mode 100644 index 0000000000..f2cc254e38 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModInvert.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 NUnit.Framework; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public class TestSceneManiaModInvert : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [Test] + public void TestInversion() => CreateModTest(new ModTestData + { + Mod = new ManiaModInvert(), + PassCondition = () => Player.ScoreProcessor.JudgedHits >= 2 + }); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.cs new file mode 100644 index 0000000000..2e3b21aed7 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModPerfect.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.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests.Mods +{ + public class TestSceneManiaModPerfect : ModPerfectTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + public TestSceneManiaModPerfect() + : base(new ManiaModPerfect()) + { + } + + [TestCase(false)] + [TestCase(true)] + public void TestNote(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Note { StartTime = 1000 }), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestHoldNote(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new HoldNote { StartTime = 1000, EndTime = 3000 }), shouldMiss); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-custom-sample-bank.osu b/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-custom-sample-bank.osu new file mode 100644 index 0000000000..4f8e1b68dd --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-custom-sample-bank.osu @@ -0,0 +1,10 @@ +osu file format v14 + +[General] +Mode: 3 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,2,0:0:0:0: diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-normal-sample-bank.osu b/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-normal-sample-bank.osu new file mode 100644 index 0000000000..f22901e304 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/SampleLookups/mania-hitobject-beatmap-normal-sample-bank.osu @@ -0,0 +1,10 @@ +osu file format v14 + +[General] +Mode: 3 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,1,0:0:0:0: diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-key1@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-key1@2x.png new file mode 100644 index 0000000000..aa681f6f22 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-key1@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-stage-bottom@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-stage-bottom@2x.png new file mode 100644 index 0000000000..ca590eaf08 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/metrics-skin/mania-stage-bottom@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania-key1@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania-key1@2x.png new file mode 100644 index 0000000000..aa681f6f22 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania-key1@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit0@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit0@2x.png new file mode 100644 index 0000000000..2e7b9bc34f Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit0@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit100@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit100@2x.png new file mode 100644 index 0000000000..27ca7f8b42 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit100@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit200@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit200@2x.png new file mode 100644 index 0000000000..24ad926375 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit200@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300@2x.png new file mode 100644 index 0000000000..098561f980 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-0@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-0@2x.png new file mode 100644 index 0000000000..7e6501d1be Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-0@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-1@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-1@2x.png new file mode 100644 index 0000000000..f17b2b1e73 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit300g-1@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit50@2x.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit50@2x.png new file mode 100644 index 0000000000..1afec2f4a9 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/hit50@2x.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-left.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-left.png new file mode 100644 index 0000000000..03ca371c4e Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-left.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-right.png b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-right.png new file mode 100644 index 0000000000..45b7be0255 Binary files /dev/null and b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/mania/stage-right.png differ diff --git a/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini new file mode 100644 index 0000000000..36765d61bf --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Resources/special-skin/skin.ini @@ -0,0 +1,14 @@ +[General] +Version: 2.5 + +[Mania] +Keys: 4 +ColumnLineWidth: 3,1,3,1,1 +Hit0: mania/hit0 +Hit50: mania/hit50 +Hit100: mania/hit100 +Hit200: mania/hit200 +Hit300: mania/hit300 +Hit300g: mania/hit300g +StageLeft: mania/stage-left +StageRight: mania/stage-right \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.cs new file mode 100644 index 0000000000..8ba58e3af3 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ColumnTestContainer.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.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + /// + /// A container to be used in a to provide a resolvable dependency. + /// + public class ColumnTestContainer : Container + { + protected override Container Content => content; + + private readonly Container content; + + [Cached] + private readonly Column column; + + public ColumnTestContainer(int column, ManiaAction action, bool showColumn = false) + { + InternalChildren = new[] + { + this.column = new Column(column) + { + Action = { Value = action }, + AccentColour = Color4.Orange, + ColumnType = column % 2 == 0 ? ColumnType.Even : ColumnType.Odd, + Alpha = showColumn ? 1 : 0 + }, + content = new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4) + { + RelativeSizeAxes = Axes.Both + }, + this.column.TopLevelContainer.CreateProxy() + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.cs new file mode 100644 index 0000000000..d24c81dac6 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaHitObjectTestScene.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + /// + /// A test scene for a mania hitobject. + /// + public abstract class ManiaHitObjectTestScene : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Height = 0.7f, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new ColumnTestContainer(0, ManiaAction.Key1, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Width = 80, + Child = new ScrollingHitObjectContainer + { + RelativeSizeAxes = Axes.Both, + }.With(c => + { + c.Add(CreateHitObject().With(h => + { + h.HitObject.StartTime = START_TIME; + h.AccentColour.Value = Color4.Orange; + })); + }) + }, + new ColumnTestContainer(1, ManiaAction.Key2, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Width = 80, + Child = new ScrollingHitObjectContainer + { + RelativeSizeAxes = Axes.Both, + }.With(c => + { + c.Add(CreateHitObject().With(h => + { + h.HitObject.StartTime = START_TIME; + h.AccentColour.Value = Color4.Orange; + })); + }) + }, + } + }); + } + + protected abstract DrawableManiaHitObject CreateHitObject(); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs new file mode 100644 index 0000000000..1d84a2dfcb --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/ManiaSkinnableTestScene.cs @@ -0,0 +1,81 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Rulesets.UI.Scrolling.Algorithms; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + /// + /// A test scene for skinnable mania components. + /// + public abstract class ManiaSkinnableTestScene : SkinnableTestScene + { + protected const double START_TIME = 1000000000; + + [Cached(Type = typeof(IScrollingInfo))] + private readonly TestScrollingInfo scrollingInfo = new TestScrollingInfo(); + + protected override Ruleset CreateRulesetForSkinProvider() => new ManiaRuleset(); + + protected ManiaSkinnableTestScene() + { + scrollingInfo.Direction.Value = ScrollingDirection.Down; + + Add(new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray.Opacity(0.2f), + Depth = 1 + }); + } + + [Test] + public void TestScrollingDown() + { + AddStep("change direction to down", () => scrollingInfo.Direction.Value = ScrollingDirection.Down); + } + + [Test] + public void TestScrollingUp() + { + AddStep("change direction to up", () => scrollingInfo.Direction.Value = ScrollingDirection.Up); + } + + private class TestScrollingInfo : IScrollingInfo + { + public readonly Bindable Direction = new Bindable(); + + IBindable IScrollingInfo.Direction => Direction; + IBindable IScrollingInfo.TimeRange { get; } = new Bindable(1000); + IScrollAlgorithm IScrollingInfo.Algorithm { get; } = new ZeroScrollAlgorithm(); + } + + private class ZeroScrollAlgorithm : IScrollAlgorithm + { + public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) + => double.MinValue; + + public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) + => scrollLength; + + public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) + => (float)((time - START_TIME) / timeRange) * scrollLength; + + public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) + => 0; + + public void Reset() + { + } + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.cs new file mode 100644 index 0000000000..bde323f187 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnBackground.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneColumnBackground : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new ColumnTestContainer(0, ManiaAction.Key1) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, 0), _ => new DefaultColumnBackground()) + { + RelativeSizeAxes = Axes.Both + } + }, + new ColumnTestContainer(1, ManiaAction.Key2) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, 1), _ => 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 new file mode 100644 index 0000000000..4392666cb7 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneColumnHitObjectArea.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneColumnHitObjectArea : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new ColumnTestContainer(0, ManiaAction.Key1) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Child = new ColumnHitObjectArea(0, new HitObjectContainer()) + { + RelativeSizeAxes = Axes.Both + } + }, + new ColumnTestContainer(1, ManiaAction.Key2) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Child = new ColumnHitObjectArea(1, new HitObjectContainer()) + { + RelativeSizeAxes = Axes.Both + } + } + } + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.cs new file mode 100644 index 0000000000..a4d4ec50f8 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneDrawableJudgement.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; +using System.Linq; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneDrawableJudgement : ManiaSkinnableTestScene + { + public TestSceneDrawableJudgement() + { + var hitWindows = new ManiaHitWindows(); + + foreach (HitResult result in Enum.GetValues(typeof(HitResult)).OfType().Skip(1)) + { + if (hitWindows.IsHitResultAllowed(result)) + { + AddStep("Show " + result.GetDescription(), () => SetContents(() => + new DrawableManiaJudgement(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.cs new file mode 100644 index 0000000000..0c56f7bcf4 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHitExplosion.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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +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.Mania.Judgements; +using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + [TestFixture] + public class TestSceneHitExplosion : ManiaSkinnableTestScene + { + private readonly List> hitExplosionPools = new List>(); + + public TestSceneHitExplosion() + { + int runcount = 0; + + AddRepeatStep("explode", () => + { + runcount++; + + if (runcount % 15 > 12) + return; + + int poolIndex = 0; + + foreach (var c in CreatedDrawables.OfType()) + { + c.Add(hitExplosionPools[poolIndex].Get(e => + { + e.Apply(new JudgementResult(new HitObject(), runcount % 6 == 0 ? new HoldNoteTickJudgement() : new ManiaJudgement())); + + e.Anchor = Anchor.Centre; + e.Origin = Anchor.Centre; + })); + + poolIndex++; + } + }, 100); + } + + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => + { + var pool = new DrawablePool(5); + hitExplosionPools.Add(pool); + + return new ColumnTestContainer(0, ManiaAction.Key1) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.Y, + Y = -0.25f, + Size = new Vector2(Column.COLUMN_WIDTH, DefaultNotePiece.NOTE_HEIGHT), + Child = pool + }; + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.cs new file mode 100644 index 0000000000..9c4c2b3d5b --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneHoldNote.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.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneHoldNote : ManiaHitObjectTestScene + { + [Test] + public void TestHoldNote() + { + AddToggleStep("toggle hitting", v => + { + foreach (var holdNote in CreatedDrawables.SelectMany(d => d.ChildrenOfType())) + { + ((Bindable)holdNote.IsHitting).Value = v; + } + }); + } + + protected override DrawableManiaHitObject CreateHitObject() + { + var note = new HoldNote { Duration = 1000 }; + note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + return new DrawableHoldNote(note); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.cs new file mode 100644 index 0000000000..7e80419944 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneKeyArea.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneKeyArea : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new ColumnTestContainer(0, ManiaAction.Key1) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, 0), _ => new DefaultKeyArea()) + { + RelativeSizeAxes = Axes.Both + }, + }, + new ColumnTestContainer(1, ManiaAction.Key2) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Child = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, 1), _ => new DefaultKeyArea()) + { + RelativeSizeAxes = Axes.Both + }, + }, + } + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneNote.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneNote.cs new file mode 100644 index 0000000000..bc3bdf0bcb --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneNote.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.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Objects.Drawables; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneNote : ManiaHitObjectTestScene + { + protected override DrawableManiaHitObject CreateHitObject() + { + var note = new Note(); + note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + return new DrawableNote(note); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs new file mode 100644 index 0000000000..161eda650e --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.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.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestScenePlayfield : ManiaSkinnableTestScene + { + private List stageDefinitions = new List(); + + [Test] + public void TestSingleStage() + { + AddStep("create stage", () => + { + stageDefinitions = new List + { + new StageDefinition { Columns = 2 } + }; + + SetContents(() => new ManiaPlayfield(stageDefinitions)); + }); + } + + [Test] + public void TestDualStages() + { + AddStep("create stage", () => + { + stageDefinitions = new List + { + new StageDefinition { Columns = 2 }, + new StageDefinition { Columns = 2 } + }; + + SetContents(() => new ManiaPlayfield(stageDefinitions)); + }); + } + + protected override IBeatmap CreateBeatmapForSkinProvider() + { + var maniaBeatmap = (ManiaBeatmap)base.CreateBeatmapForSkinProvider(); + maniaBeatmap.Stages = stageDefinitions; + return maniaBeatmap; + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.cs new file mode 100644 index 0000000000..37b97a444a --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStage.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.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneStage : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => + { + ManiaAction normalAction = ManiaAction.Key1; + ManiaAction specialAction = ManiaAction.Special1; + + return new ManiaInputManager(new ManiaRuleset().RulesetInfo, 4) + { + Child = new Stage(0, new StageDefinition { Columns = 4 }, ref normalAction, ref specialAction) + }; + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.cs new file mode 100644 index 0000000000..a15fb392d6 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageBackground.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.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI.Components; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneStageBackground : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground, stageDefinition: new StageDefinition { Columns = 4 }), + _ => new DefaultStageBackground()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.cs new file mode 100644 index 0000000000..bceee1c599 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestSceneStageForeground.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.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Tests.Skinning +{ + public class TestSceneStageForeground : ManiaSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + SetContents(() => new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground, stageDefinition: new StageDefinition { Columns = 4 }), _ => null) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + }); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs index 20ac5eaa39..a5248c7712 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneAutoGeneration.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Testing; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Replays; @@ -12,8 +13,14 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Mania.Tests { [TestFixture] + [HeadlessTest] public class TestSceneAutoGeneration : OsuTestScene { + /// + /// The number of frames which are generated at the start of a replay regardless of hitobject content. + /// + private const int frame_offset = 1; + [Test] public void TestSingleNote() { @@ -26,11 +33,11 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == 3, "Replay must have 3 frames"); - Assert.AreEqual(1000, generated.Frames[1].Time, "Incorrect hit time"); - Assert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[2].Time, "Incorrect release time"); - Assert.IsTrue(checkContains(generated.Frames[1], ManiaAction.Special1), "Special1 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[2], ManiaAction.Special1), "Special1 has not been released"); + Assert.IsTrue(generated.Frames.Count == frame_offset + 2, "Replay must have 3 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"); + Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Special1), "Special1 has not been released"); } [Test] @@ -47,11 +54,11 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == 3, "Replay must have 3 frames"); - Assert.AreEqual(1000, generated.Frames[1].Time, "Incorrect hit time"); - Assert.AreEqual(3000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[2].Time, "Incorrect release time"); - Assert.IsTrue(checkContains(generated.Frames[1], ManiaAction.Special1), "Special1 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[2], ManiaAction.Special1), "Special1 has not been released"); + Assert.IsTrue(generated.Frames.Count == frame_offset + 2, "Replay must have 3 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.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"); } [Test] @@ -67,11 +74,11 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == 3, "Replay must have 3 frames"); - Assert.AreEqual(1000, generated.Frames[1].Time, "Incorrect hit time"); - Assert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[2].Time, "Incorrect release time"); - Assert.IsTrue(checkContains(generated.Frames[1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[2], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been released"); + Assert.IsTrue(generated.Frames.Count == frame_offset + 2, "Replay must have 3 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"); + Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been released"); } [Test] @@ -89,11 +96,13 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == 3, "Replay must have 3 frames"); - Assert.AreEqual(1000, generated.Frames[1].Time, "Incorrect hit time"); - Assert.AreEqual(3000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[2].Time, "Incorrect release time"); - Assert.IsTrue(checkContains(generated.Frames[1], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[2], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been released"); + Assert.IsTrue(generated.Frames.Count == frame_offset + 2, "Replay must have 3 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.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"); } [Test] @@ -110,15 +119,15 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == 5, "Replay must have 5 frames"); - Assert.AreEqual(1000, generated.Frames[1].Time, "Incorrect first note hit time"); - Assert.AreEqual(1000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[2].Time, "Incorrect first note release time"); - Assert.AreEqual(2000, generated.Frames[3].Time, "Incorrect second note hit time"); - Assert.AreEqual(2000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[4].Time, "Incorrect second note release time"); - Assert.IsTrue(checkContains(generated.Frames[1], ManiaAction.Key1), "Key1 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[2], ManiaAction.Key1), "Key1 has not been released"); - Assert.IsTrue(checkContains(generated.Frames[3], ManiaAction.Key2), "Key2 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[4], ManiaAction.Key2), "Key2 has not been released"); + Assert.IsTrue(generated.Frames.Count == frame_offset + 4, "Replay must have 4 generated 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"); + Assert.AreEqual(2000 + ManiaAutoGenerator.RELEASE_DELAY, 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.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released"); + Assert.IsTrue(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key2), "Key2 has not been pressed"); + Assert.IsFalse(checkContains(generated.Frames[frame_offset + 3], ManiaAction.Key2), "Key2 has not been released"); } [Test] @@ -137,16 +146,16 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == 5, "Replay must have 5 frames"); - Assert.AreEqual(1000, generated.Frames[1].Time, "Incorrect first note hit time"); - Assert.AreEqual(3000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[3].Time, "Incorrect first note release time"); - Assert.AreEqual(2000, generated.Frames[2].Time, "Incorrect second note hit time"); - Assert.AreEqual(4000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[4].Time, "Incorrect second note release time"); - Assert.IsTrue(checkContains(generated.Frames[1], ManiaAction.Key1), "Key1 has not been pressed"); - Assert.IsTrue(checkContains(generated.Frames[2], ManiaAction.Key1, ManiaAction.Key2), "Key1 & Key2 have not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[3], ManiaAction.Key1), "Key1 has not been released"); - Assert.IsTrue(checkContains(generated.Frames[3], ManiaAction.Key2), "Key2 has been released"); - Assert.IsFalse(checkContains(generated.Frames[4], ManiaAction.Key2), "Key2 has not been released"); + Assert.IsTrue(generated.Frames.Count == frame_offset + 4, "Replay must have 4 generated 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(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.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"); + Assert.IsTrue(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key2), "Key2 has been released"); + Assert.IsFalse(checkContains(generated.Frames[frame_offset + 3], ManiaAction.Key2), "Key2 has not been released"); } [Test] @@ -164,14 +173,14 @@ namespace osu.Game.Rulesets.Mania.Tests var generated = new ManiaAutoGenerator(beatmap).Generate(); - Assert.IsTrue(generated.Frames.Count == 4, "Replay must have 4 frames"); - Assert.AreEqual(1000, generated.Frames[1].Time, "Incorrect first note hit time"); - Assert.AreEqual(3000, generated.Frames[2].Time, "Incorrect second note press time + first note release time"); - Assert.AreEqual(3000 + ManiaAutoGenerator.RELEASE_DELAY, generated.Frames[3].Time, "Incorrect second note release time"); - Assert.IsTrue(checkContains(generated.Frames[1], ManiaAction.Key1), "Key1 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[2], ManiaAction.Key1), "Key1 has not been released"); - Assert.IsTrue(checkContains(generated.Frames[2], ManiaAction.Key2), "Key2 has not been pressed"); - Assert.IsFalse(checkContains(generated.Frames[3], ManiaAction.Key2), "Key2 has not been released"); + Assert.IsTrue(generated.Frames.Count == frame_offset + 3, "Replay must have 3 generated 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"); + Assert.IsTrue(checkContains(generated.Frames[frame_offset], ManiaAction.Key1), "Key1 has not been pressed"); + Assert.IsFalse(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key1), "Key1 has not been released"); + Assert.IsTrue(checkContains(generated.Frames[frame_offset + 1], ManiaAction.Key2), "Key2 has not been pressed"); + Assert.IsFalse(checkContains(generated.Frames[frame_offset + 2], ManiaAction.Key2), "Key2 has not been released"); } private bool checkContains(ReplayFrame frame, params ManiaAction[] actions) => actions.All(action => ((ManiaReplayFrame)frame).Actions.Contains(action)); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs index d94a986dae..d9b1ad22fa 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneColumn.cs @@ -12,7 +12,6 @@ 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.Mania.UI.Components; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Tests.Visual; @@ -24,14 +23,6 @@ namespace osu.Game.Rulesets.Mania.Tests [TestFixture] public class TestSceneColumn : ManiaInputTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Column), - typeof(ColumnBackground), - typeof(ColumnKeyArea), - typeof(ColumnHitObjectArea) - }; - [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs new file mode 100644 index 0000000000..5cb1519196 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -0,0 +1,363 @@ +// 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.Screens; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Replays; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestSceneHoldNoteInput : RateAdjustedBeatmapTestScene + { + private const double time_before_head = 250; + private const double time_head = 1500; + private const double time_during_hold_1 = 2500; + private const double time_tail = 4000; + private const double time_after_tail = 5250; + + private List judgementResults; + + /// + /// -----[ ]----- + /// o o + /// + [Test] + public void TestNoInput() + { + performTest(new List + { + new ManiaReplayFrame(time_before_head), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.LargeTickMiss); + assertTailJudgement(HitResult.Miss); + assertNoteJudgement(HitResult.IgnoreHit); + } + + /// + /// -----[ ]----- + /// x o + /// + [Test] + public void TestPressTooEarlyAndReleaseAfterTail() + { + performTest(new List + { + new ManiaReplayFrame(time_before_head, ManiaAction.Key1), + new ManiaReplayFrame(time_after_tail, ManiaAction.Key1), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.LargeTickMiss); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// x o + /// + [Test] + public void TestPressTooEarlyAndReleaseAtTail() + { + performTest(new List + { + new ManiaReplayFrame(time_before_head, ManiaAction.Key1), + new ManiaReplayFrame(time_tail), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.LargeTickMiss); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// xo x o + /// + [Test] + public void TestPressTooEarlyThenPressAtStartAndReleaseAfterTail() + { + performTest(new List + { + new ManiaReplayFrame(time_before_head, ManiaAction.Key1), + new ManiaReplayFrame(time_before_head + 10), + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.LargeTickHit); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// xo x o + /// + [Test] + public void TestPressTooEarlyThenPressAtStartAndReleaseAtTail() + { + performTest(new List + { + new ManiaReplayFrame(time_before_head, ManiaAction.Key1), + new ManiaReplayFrame(time_before_head + 10), + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.LargeTickHit); + assertTailJudgement(HitResult.Perfect); + } + + /// + /// -----[ ]----- + /// xo o + /// + [Test] + public void TestPressAtStartAndBreak() + { + performTest(new List + { + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_head + 10), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.LargeTickMiss); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// xo x o + /// + [Test] + public void TestPressAtStartThenBreakThenRepressAndReleaseAfterTail() + { + performTest(new List + { + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_head + 10), + new ManiaReplayFrame(time_during_hold_1, ManiaAction.Key1), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.LargeTickHit); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// xo x o o + /// + [Test] + public void TestPressAtStartThenBreakThenRepressAndReleaseAtTail() + { + performTest(new List + { + new ManiaReplayFrame(time_head, ManiaAction.Key1), + new ManiaReplayFrame(time_head + 10), + new ManiaReplayFrame(time_during_hold_1, ManiaAction.Key1), + new ManiaReplayFrame(time_tail), + }); + + assertHeadJudgement(HitResult.Perfect); + assertTickJudgement(HitResult.LargeTickHit); + assertTailJudgement(HitResult.Meh); + } + + /// + /// -----[ ]----- + /// x o + /// + [Test] + public void TestPressDuringNoteAndReleaseAfterTail() + { + performTest(new List + { + new ManiaReplayFrame(time_during_hold_1, ManiaAction.Key1), + new ManiaReplayFrame(time_after_tail), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.LargeTickHit); + assertTailJudgement(HitResult.Miss); + } + + /// + /// -----[ ]----- + /// x o o + /// + [Test] + public void TestPressDuringNoteAndReleaseAtTail() + { + performTest(new List + { + new ManiaReplayFrame(time_during_hold_1, ManiaAction.Key1), + new ManiaReplayFrame(time_tail), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.LargeTickHit); + assertTailJudgement(HitResult.Meh); + } + + /// + /// -----[ ]----- + /// xo o + /// + [Test] + public void TestPressAndReleaseAtTail() + { + performTest(new List + { + new ManiaReplayFrame(time_tail, ManiaAction.Key1), + new ManiaReplayFrame(time_tail + 10), + }); + + assertHeadJudgement(HitResult.Miss); + assertTickJudgement(HitResult.LargeTickMiss); + assertTailJudgement(HitResult.Meh); + } + + [Test] + public void TestMissReleaseAndHitSecondRelease() + { + var windows = new ManiaHitWindows(); + windows.SetDifficulty(10); + + var beatmap = new Beatmap + { + HitObjects = + { + new HoldNote + { + StartTime = 1000, + Duration = 500, + Column = 0, + }, + new HoldNote + { + StartTime = 1000 + 500 + windows.WindowFor(HitResult.Miss) + 10, + Duration = 500, + Column = 0, + }, + }, + BeatmapInfo = + { + BaseDifficulty = new BeatmapDifficulty + { + SliderTickRate = 4, + OverallDifficulty = 10, + }, + Ruleset = new ManiaRuleset().RulesetInfo + }, + }; + + performTest(new List + { + new ManiaReplayFrame(beatmap.HitObjects[1].StartTime, ManiaAction.Key1), + new ManiaReplayFrame(beatmap.HitObjects[1].GetEndTime()), + }, beatmap); + + AddAssert("first hold note missed", () => judgementResults.Where(j => beatmap.HitObjects[0].NestedHitObjects.Contains(j.HitObject)) + .All(j => !j.Type.IsHit())); + + AddAssert("second hold note missed", () => judgementResults.Where(j => beatmap.HitObjects[1].NestedHitObjects.Contains(j.HitObject)) + .All(j => j.Type.IsHit())); + } + + private void assertHeadJudgement(HitResult result) + => AddAssert($"head judged as {result}", () => judgementResults[0].Type == result); + + private void assertTailJudgement(HitResult result) + => AddAssert($"tail judged as {result}", () => judgementResults[^2].Type == result); + + private void assertNoteJudgement(HitResult result) + => AddAssert($"hold note judged as {result}", () => judgementResults[^1].Type == result); + + private void assertTickJudgement(HitResult result) + => AddAssert($"tick judged as {result}", () => judgementResults[6].Type == result); // arbitrary tick + + private ScoreAccessibleReplayPlayer currentPlayer; + + private void performTest(List frames, Beatmap beatmap = null) + { + if (beatmap == null) + { + beatmap = new Beatmap + { + HitObjects = + { + new HoldNote + { + StartTime = time_head, + Duration = time_tail - time_head, + Column = 0, + } + }, + BeatmapInfo = + { + BaseDifficulty = new BeatmapDifficulty { SliderTickRate = 4 }, + Ruleset = new ManiaRuleset().RulesetInfo + }, + }; + + beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + } + + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(beatmap); + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) judgementResults.Add(result); + }; + }; + + LoadScreen(currentPlayer = p); + judgementResults = new List(); + }); + + 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); + } + + private class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, false, false) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.cs new file mode 100644 index 0000000000..0d726e1a50 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaHitObjectSamples.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 System.Reflection; +using NUnit.Framework; +using osu.Framework.IO.Stores; +using osu.Game.Tests.Beatmaps; + +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))); + + /// + /// Tests that when a normal sample bank is used, the normal hitsound will be looked up. + /// + [Test] + public void TestManiaHitObjectNormalSampleBank() + { + const string expected_sample = "normal-hitnormal2"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("mania-hitobject-beatmap-normal-sample-bank.osu"); + + AssertBeatmapLookup(expected_sample); + } + + /// + /// Tests that when a custom sample bank is used, layered hitsounds are not played + /// (only the sample from the custom bank is looked up). + /// + [Test] + public void TestManiaHitObjectCustomSampleBank() + { + const string expected_sample = "normal-hitwhistle2"; + const string unwanted_sample = "normal-hitnormal2"; + + SetupSkins(expected_sample, unwanted_sample); + + CreateTestWithBeatmap("mania-hitobject-beatmap-custom-sample-bank.osu"); + + AssertBeatmapLookup(expected_sample); + AssertNoLookup(unwanted_sample); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.cs new file mode 100644 index 0000000000..a399b90585 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneManiaPlayer.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.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestSceneManiaPlayer : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneNotePlacementBlueprint.cs deleted file mode 100644 index d7b539a2a0..0000000000 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneNotePlacementBlueprint.cs +++ /dev/null @@ -1,18 +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.Game.Rulesets.Edit; -using osu.Game.Rulesets.Mania.Edit.Blueprints; -using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; - -namespace osu.Game.Rulesets.Mania.Tests -{ - public class TestSceneNotePlacementBlueprint : ManiaPlacementBlueprintTestScene - { - protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableNote((Note)hitObject); - protected override PlacementBlueprint CreateBlueprint() => new NotePlacementBlueprint(); - } -} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs index 031abb08e2..6b8f5d5d9d 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneNotes.cs @@ -1,8 +1,6 @@ -// 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 NUnit.Framework; using osu.Framework.Allocation; @@ -11,6 +9,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; @@ -29,30 +28,33 @@ namespace osu.Game.Rulesets.Mania.Tests [TestFixture] public class TestSceneNotes : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] + [Test] + public void TestVariousNotes() { - typeof(DrawableNote), - typeof(DrawableHoldNote) - }; + DrawableNote note1 = null; + DrawableNote note2 = null; + DrawableHoldNote holdNote1 = null; + DrawableHoldNote holdNote2 = null; - [BackgroundDependencyLoader] - private void load() - { - Child = new FillFlowContainer + AddStep("create notes", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(20), - Children = new[] + Child = new FillFlowContainer { - createNoteDisplay(ScrollingDirection.Down, 1, out var note1), - createNoteDisplay(ScrollingDirection.Up, 2, out var note2), - createHoldNoteDisplay(ScrollingDirection.Down, 1, out var holdNote1), - createHoldNoteDisplay(ScrollingDirection.Up, 2, out var holdNote2), - } - }; + Clock = new FramedClock(new ManualClock()), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(20), + Children = new[] + { + createNoteDisplay(ScrollingDirection.Down, 1, out note1), + createNoteDisplay(ScrollingDirection.Up, 2, out note2), + createHoldNoteDisplay(ScrollingDirection.Down, 1, out holdNote1), + createHoldNoteDisplay(ScrollingDirection.Up, 2, out holdNote2), + } + }; + }); AddAssert("note 1 facing downwards", () => verifyAnchors(note1, Anchor.y2)); AddAssert("note 2 facing upwards", () => verifyAnchors(note2, Anchor.y0)); @@ -62,7 +64,7 @@ namespace osu.Game.Rulesets.Mania.Tests private Drawable createNoteDisplay(ScrollingDirection direction, int identifier, out DrawableNote hitObject) { - var note = new Note { StartTime = 999999999 }; + var note = new Note { StartTime = 0 }; note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); return new ScrollingTestContainer(direction) @@ -77,7 +79,7 @@ namespace osu.Game.Rulesets.Mania.Tests private Drawable createHoldNoteDisplay(ScrollingDirection direction, int identifier, out DrawableHoldNote hitObject) { - var note = new HoldNote { StartTime = 999999999, Duration = 5000 }; + var note = new HoldNote { StartTime = 0, Duration = 5000 }; note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); return new ScrollingTestContainer(direction) @@ -133,7 +135,7 @@ namespace osu.Game.Rulesets.Mania.Tests Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.Both, Width = 1.25f, - Colour = Color4.Black.Opacity(0.5f) + Colour = Color4.Green.Opacity(0.5f) }, content = new Container { RelativeSizeAxes = Axes.Both } } @@ -162,7 +164,7 @@ namespace osu.Game.Rulesets.Mania.Tests foreach (var obj in content.OfType()) { - if (!(obj.HitObject is IHasEndTime endTime)) + if (!(obj.HitObject is IHasDuration endTime)) continue; foreach (var nested in obj.NestedHitObjects) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs new file mode 100644 index 0000000000..e8c2472c3b --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs @@ -0,0 +1,118 @@ +// 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.TypeExtensions; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestSceneOutOfOrderHits : RateAdjustedBeatmapTestScene + { + [Test] + public void TestPreviousHitWindowDoesNotExtendPastNextObject() + { + var objects = new List(); + var frames = new List(); + + for (int i = 0; i < 7; i++) + { + double time = 1000 + i * 100; + + objects.Add(new Note { StartTime = time }); + + // don't hit the first note + if (i > 0) + { + frames.Add(new ManiaReplayFrame(time + 10, ManiaAction.Key1)); + frames.Add(new ManiaReplayFrame(time + 11)); + } + } + + performTest(objects, frames); + + addJudgementAssert(objects[0], HitResult.Miss); + + for (int i = 1; i < 7; i++) + { + addJudgementAssert(objects[i], HitResult.Perfect); + addJudgementOffsetAssert(objects[i], 10); + } + } + + private void addJudgementAssert(ManiaHitObject hitObject, HitResult result) + { + AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", + () => judgementResults.Single(r => r.HitObject == hitObject).Type == result); + } + + private void addJudgementOffsetAssert(ManiaHitObject hitObject, double offset) + { + AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judged at {offset}", + () => Precision.AlmostEquals(judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, offset, 100)); + } + + private ScoreAccessibleReplayPlayer currentPlayer; + private List judgementResults; + + private void performTest(List hitObjects, List frames) + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(new ManiaBeatmap(new StageDefinition { Columns = 4 }) + { + HitObjects = hitObjects, + BeatmapInfo = + { + Ruleset = new ManiaRuleset().RulesetInfo + }, + }); + + Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) judgementResults.Add(result); + }; + }; + + LoadScreen(currentPlayer = p); + judgementResults = new List(); + }); + + 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); + } + + private class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, false, false) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs b/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs new file mode 100644 index 0000000000..8698ba3abd --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/TestScenePlayfieldCoveringContainer.cs @@ -0,0 +1,56 @@ +// 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.Shapes; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Tests +{ + public class TestScenePlayfieldCoveringContainer : OsuTestScene + { + private readonly ScrollingTestContainer scrollingContainer; + private readonly PlayfieldCoveringWrapper cover; + + public TestScenePlayfieldCoveringContainer() + { + Child = scrollingContainer = new ScrollingTestContainer(ScrollingDirection.Down) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(300, 500), + Child = cover = new PlayfieldCoveringWrapper(new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Orange + }) + { + RelativeSizeAxes = Axes.Both, + } + }; + } + + [Test] + public void TestScrollingDownwards() + { + AddStep("set down scroll", () => scrollingContainer.Direction = ScrollingDirection.Down); + AddStep("set coverage = 0.5", () => cover.Coverage = 0.5f); + AddStep("set coverage = 0.8f", () => cover.Coverage = 0.8f); + AddStep("set coverage = 0.2f", () => cover.Coverage = 0.2f); + } + + [Test] + public void TestScrollingUpwards() + { + AddStep("set up scroll", () => scrollingContainer.Direction = ScrollingDirection.Up); + AddStep("set coverage = 0.5", () => cover.Coverage = 0.5f); + AddStep("set coverage = 0.8f", () => cover.Coverage = 0.8f); + AddStep("set coverage = 0.2f", () => cover.Coverage = 0.2f); + } + } +} diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs index 395e6daf0a..7376a90f17 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneStage.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Mania.Tests [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); - private readonly List stages = new List(); + private readonly List stages = new List(); private FillFlowContainer fill; @@ -66,6 +66,8 @@ namespace osu.Game.Rulesets.Mania.Tests AddAssert("check note anchors", () => notesInStageAreAnchored(stages[0], Anchor.TopCentre)); AddAssert("check note anchors", () => notesInStageAreAnchored(stages[1], Anchor.BottomCentre)); + AddAssert("check bar anchors", () => barsInStageAreAnchored(stages[0], Anchor.TopCentre)); + AddAssert("check bar anchors", () => barsInStageAreAnchored(stages[1], Anchor.BottomCentre)); AddStep("flip direction", () => { @@ -75,9 +77,13 @@ namespace osu.Game.Rulesets.Mania.Tests AddAssert("check note anchors", () => notesInStageAreAnchored(stages[0], Anchor.BottomCentre)); AddAssert("check note anchors", () => notesInStageAreAnchored(stages[1], Anchor.TopCentre)); + AddAssert("check bar anchors", () => barsInStageAreAnchored(stages[0], Anchor.BottomCentre)); + AddAssert("check bar anchors", () => barsInStageAreAnchored(stages[1], Anchor.TopCentre)); } - private bool notesInStageAreAnchored(ManiaStage stage, Anchor anchor) => stage.Columns.SelectMany(c => c.AllHitObjects).All(o => o.Anchor == anchor); + private bool notesInStageAreAnchored(Stage stage, Anchor anchor) => stage.Columns.SelectMany(c => c.AllHitObjects).All(o => o.Anchor == anchor); + + private bool barsInStageAreAnchored(Stage stage, Anchor anchor) => stage.AllHitObjects.Where(obj => obj is DrawableBarLine).All(o => o.Anchor == anchor); private void createNote() { @@ -114,8 +120,7 @@ namespace osu.Game.Rulesets.Mania.Tests var obj = new BarLine { StartTime = Time.Current + 2000, - ControlPoint = new TimingControlPoint(), - BeatIndex = major ? 0 : 1 + Major = major, }; obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); @@ -128,7 +133,7 @@ namespace osu.Game.Rulesets.Mania.Tests { var specialAction = ManiaAction.Special1; - var stage = new ManiaStage(0, new StageDefinition { Columns = 2 }, ref action, ref specialAction); + var stage = new Stage(0, new StageDefinition { Columns = 2 }, ref action, ref specialAction); stages.Add(stage); return new ScrollingTestContainer(direction) 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 df5131dd8b..892f27d27f 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,14 +2,14 @@ - + - + WinExe - netcoreapp2.2 + netcoreapp3.1 diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs b/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.cs new file mode 100644 index 0000000000..8f904530bc --- /dev/null +++ b/osu.Game.Rulesets.Mania/Beatmaps/ColumnType.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. + +namespace osu.Game.Rulesets.Mania.Beatmaps +{ + public enum ColumnType + { + Even, + Odd, + Special + } +} diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs index dc24a344e9..93a9ce3dbd 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; @@ -22,13 +21,20 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// public int TotalColumns => Stages.Sum(g => g.Columns); + /// + /// The total number of columns that were present in this before any user adjustments. + /// + public readonly int OriginalTotalColumns; + /// /// Creates a new . /// /// The initial stages. - public ManiaBeatmap(StageDefinition defaultStage) + /// The total number of columns present before any user adjustments. Defaults to the total columns in . + public ManiaBeatmap(StageDefinition defaultStage, int? originalTotalColumns = null) { Stages.Add(defaultStage); + OriginalTotalColumns = originalTotalColumns ?? defaultStage.Columns; } public override IEnumerable GetStatistics() @@ -41,14 +47,14 @@ namespace osu.Game.Rulesets.Mania.Beatmaps new BeatmapStatistic { Name = @"Note Count", + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles), Content = notes.ToString(), - Icon = FontAwesome.Regular.Circle }, new BeatmapStatistic { Name = @"Hold Note Count", + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), Content = holdnotes.ToString(), - Icon = FontAwesome.Regular.Circle }, }; } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index e10602312e..7a0e3b2b76 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -5,7 +5,8 @@ using osu.Game.Rulesets.Mania.Objects; using System; using System.Linq; using System.Collections.Generic; -using osu.Framework.MathUtils; +using System.Threading; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; @@ -13,7 +14,6 @@ using osu.Game.Rulesets.Mania.Beatmaps.Patterns; using osu.Game.Rulesets.Mania.MathUtils; using osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy; using osuTK; -using osu.Game.Audio; namespace osu.Game.Rulesets.Mania.Beatmaps { @@ -24,12 +24,12 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// private const int max_notes_for_density = 7; - protected override IEnumerable ValidConversionTypes { get; } = new[] { typeof(IHasXPosition) }; - public int TargetColumns; public bool Dual; public readonly bool IsForCurrentRuleset; + private readonly int originalTargetColumns; + // Internal for testing purposes internal FastRandom Random { get; private set; } @@ -37,10 +37,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps private ManiaBeatmap beatmap; - public ManiaBeatmapConverter(IBeatmap beatmap) - : base(beatmap) + public ManiaBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) + : base(beatmap, ruleset) { - IsForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(new ManiaRuleset().RulesetInfo); + IsForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo); var roundedCircleSize = Math.Round(beatmap.BeatmapInfo.BaseDifficulty.CircleSize); var roundedOverallDifficulty = Math.Round(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); @@ -49,15 +49,15 @@ namespace osu.Game.Rulesets.Mania.Beatmaps { TargetColumns = (int)Math.Max(1, roundedCircleSize); - if (TargetColumns >= 10) + if (TargetColumns > ManiaRuleset.MAX_STAGE_KEYS) { - TargetColumns = TargetColumns / 2; + TargetColumns /= 2; Dual = true; } } else { - float percentSliderOrSpinner = (float)beatmap.HitObjects.Count(h => h is IHasEndTime) / beatmap.HitObjects.Count; + float percentSliderOrSpinner = (float)beatmap.HitObjects.Count(h => h is IHasDuration) / beatmap.HitObjects.Count; if (percentSliderOrSpinner < 0.2) TargetColumns = 7; else if (percentSliderOrSpinner < 0.3 || roundedCircleSize >= 5) @@ -67,21 +67,25 @@ namespace osu.Game.Rulesets.Mania.Beatmaps else TargetColumns = Math.Max(4, Math.Min((int)roundedOverallDifficulty + 1, 7)); } + + originalTargetColumns = TargetColumns; } - protected override Beatmap ConvertBeatmap(IBeatmap original) + public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition); + + protected override Beatmap ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken) { BeatmapDifficulty difficulty = original.BeatmapInfo.BaseDifficulty; - int seed = (int)Math.Round(difficulty.DrainRate + difficulty.CircleSize) * 20 + (int)(difficulty.OverallDifficulty * 41.2) + (int)Math.Round(difficulty.ApproachRate); + int seed = (int)MathF.Round(difficulty.DrainRate + difficulty.CircleSize) * 20 + (int)(difficulty.OverallDifficulty * 41.2) + (int)MathF.Round(difficulty.ApproachRate); Random = new FastRandom(seed); - return base.ConvertBeatmap(original); + return base.ConvertBeatmap(original, cancellationToken); } protected override Beatmap CreateBeatmap() { - beatmap = new ManiaBeatmap(new StageDefinition { Columns = TargetColumns }); + beatmap = new ManiaBeatmap(new StageDefinition { Columns = TargetColumns }, originalTargetColumns); if (Dual) beatmap.Stages.Add(new StageDefinition { Columns = TargetColumns }); @@ -89,7 +93,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps return beatmap; } - protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap) + protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) { if (original is ManiaHitObject maniaOriginal) { @@ -116,7 +120,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps prevNoteTimes.RemoveAt(0); prevNoteTimes.Add(newNoteTime); - density = (prevNoteTimes[prevNoteTimes.Count - 1] - prevNoteTimes[0]) / prevNoteTimes.Count; + if (prevNoteTimes.Count >= 2) + density = (prevNoteTimes[^1] - prevNoteTimes[0]) / prevNoteTimes.Count; } private double lastTime; @@ -156,37 +161,46 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// The hit objects generated. private IEnumerable generateConverted(HitObject original, IBeatmap originalBeatmap) { - var endTimeData = original as IHasEndTime; - var distanceData = original as IHasDistance; - var positionData = original as IHasPosition; - Patterns.PatternGenerator conversion = null; - if (distanceData != null) + switch (original) { - var generator = new DistanceObjectPatternGenerator(Random, original, beatmap, lastPattern, originalBeatmap); - conversion = generator; - - for (double time = original.StartTime; !Precision.DefinitelyBigger(time, generator.EndTime); time += generator.SegmentDuration) + case IHasDistance _: { - recordNote(time, positionData?.Position ?? Vector2.Zero); - computeDensity(time); + var generator = new DistanceObjectPatternGenerator(Random, original, beatmap, lastPattern, originalBeatmap); + conversion = generator; + + var positionData = original as IHasPosition; + + for (int i = 0; i <= generator.SpanCount; i++) + { + double time = original.StartTime + generator.SegmentDuration * i; + + recordNote(time, positionData?.Position ?? Vector2.Zero); + computeDensity(time); + } + + break; } - } - else if (endTimeData != null) - { - conversion = new EndTimeObjectPatternGenerator(Random, original, beatmap, originalBeatmap); - recordNote(endTimeData.EndTime, new Vector2(256, 192)); - computeDensity(endTimeData.EndTime); - } - else if (positionData != null) - { - computeDensity(original.StartTime); + case IHasDuration endTimeData: + { + conversion = new EndTimeObjectPatternGenerator(Random, original, beatmap, lastPattern, originalBeatmap); - conversion = new HitObjectPatternGenerator(Random, original, beatmap, lastPattern, lastTime, lastPosition, density, lastStair, originalBeatmap); + recordNote(endTimeData.EndTime, new Vector2(256, 192)); + computeDensity(endTimeData.EndTime); + break; + } - recordNote(original.StartTime, positionData.Position); + case IHasPosition positionData: + { + computeDensity(original.StartTime); + + conversion = new HitObjectPatternGenerator(Random, original, beatmap, lastPattern, lastTime, lastPosition, density, lastStair, originalBeatmap); + + recordNote(original.StartTime, positionData.Position); + break; + } } if (conversion == null) @@ -219,25 +233,24 @@ namespace osu.Game.Rulesets.Mania.Beatmaps private Pattern generate() { - var endTimeData = HitObject as IHasEndTime; var positionData = HitObject as IHasXPosition; int column = GetColumn(positionData?.X ?? 0); var pattern = new Pattern(); - if (endTimeData != null) + if (HitObject is IHasDuration endTimeData) { pattern.Add(new HoldNote { StartTime = HitObject.StartTime, Duration = endTimeData.Duration, Column = column, - Head = { Samples = sampleInfoListAt(HitObject.StartTime) }, - Tail = { Samples = sampleInfoListAt(endTimeData.EndTime) }, + Samples = HitObject.Samples, + NodeSamples = (HitObject as IHasRepeats)?.NodeSamples ?? defaultNodeSamples }); } - else if (positionData != null) + else if (HitObject is IHasXPosition) { pattern.Add(new Note { @@ -250,23 +263,15 @@ namespace osu.Game.Rulesets.Mania.Beatmaps return pattern; } - /// - /// Retrieves the sample info list at a point in time. - /// - /// The time to retrieve the sample info list from. - /// - private List sampleInfoListAt(double time) - { - var curveData = HitObject as IHasCurve; - - if (curveData == null) - return HitObject.Samples; - - double segmentTime = (curveData.EndTime - HitObject.StartTime) / curveData.SpanCount(); - - int index = (int)(segmentTime == 0 ? 0 : (time - HitObject.StartTime) / segmentTime); - return curveData.NodeSamples[index]; - } + /// + /// osu!mania-specific beatmaps in stable only play samples at the start of the hold note. + /// + private List> defaultNodeSamples + => new List> + { + HitObject.Samples, + new List() + }; } } } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs index ea418eedb4..30d33de06e 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; -using osu.Framework.MathUtils; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.MathUtils; @@ -12,6 +12,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { @@ -25,10 +26,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// private const float osu_base_scoring_distance = 100; - public readonly double EndTime; - public readonly double SegmentDuration; - - private readonly int spanCount; + public readonly int StartTime; + public readonly int EndTime; + public readonly int SegmentDuration; + public readonly int SpanCount; private PatternType convertType; @@ -42,20 +43,26 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy var distanceData = hitObject as IHasDistance; var repeatsData = hitObject as IHasRepeats; - spanCount = repeatsData?.SpanCount() ?? 1; + Debug.Assert(distanceData != null); TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime); DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(hitObject.StartTime); - // The true distance, accounting for any repeats - double distance = (distanceData?.Distance ?? 0) * spanCount; - // The velocity of the osu! hit object - calculated as the velocity of a slider - double osuVelocity = osu_base_scoring_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier / timingPoint.BeatLength; - // The duration of the osu! hit object - double osuDuration = distance / osuVelocity; + double beatLength; +#pragma warning disable 618 + if (difficultyPoint is LegacyBeatmapDecoder.LegacyDifficultyControlPoint legacyDifficultyPoint) +#pragma warning restore 618 + beatLength = timingPoint.BeatLength * legacyDifficultyPoint.BpmMultiplier; + else + beatLength = timingPoint.BeatLength / difficultyPoint.SpeedMultiplier; - EndTime = hitObject.StartTime + osuDuration; - SegmentDuration = (EndTime - HitObject.StartTime) / spanCount; + SpanCount = repeatsData?.SpanCount() ?? 1; + StartTime = (int)Math.Round(hitObject.StartTime); + + // This matches stable's calculation. + EndTime = (int)Math.Floor(StartTime + distanceData.Distance * beatLength * SpanCount * 0.01 / beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier); + + SegmentDuration = (EndTime - StartTime) / SpanCount; } public override IEnumerable Generate() @@ -77,7 +84,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy foreach (var obj in originalPattern.HitObjects) { - if (!Precision.AlmostEquals(EndTime, (obj as IHasEndTime)?.EndTime ?? obj.StartTime)) + if (EndTime != (int)Math.Round(obj.GetEndTime())) intermediatePattern.Add(obj); else endTimePattern.Add(obj); @@ -92,35 +99,35 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (TotalColumns == 1) { var pattern = new Pattern(); - addToPattern(pattern, 0, HitObject.StartTime, EndTime); + addToPattern(pattern, 0, StartTime, EndTime); return pattern; } - if (spanCount > 1) + if (SpanCount > 1) { if (SegmentDuration <= 90) - return generateRandomHoldNotes(HitObject.StartTime, 1); + return generateRandomHoldNotes(StartTime, 1); if (SegmentDuration <= 120) { convertType |= PatternType.ForceNotStack; - return generateRandomNotes(HitObject.StartTime, spanCount + 1); + return generateRandomNotes(StartTime, SpanCount + 1); } if (SegmentDuration <= 160) - return generateStair(HitObject.StartTime); + return generateStair(StartTime); if (SegmentDuration <= 200 && ConversionDifficulty > 3) - return generateRandomMultipleNotes(HitObject.StartTime); + return generateRandomMultipleNotes(StartTime); - double duration = EndTime - HitObject.StartTime; + double duration = EndTime - StartTime; if (duration >= 4000) - return generateNRandomNotes(HitObject.StartTime, 0.23, 0, 0); + return generateNRandomNotes(StartTime, 0.23, 0, 0); - if (SegmentDuration > 400 && spanCount < TotalColumns - 1 - RandomStart) - return generateTiledHoldNotes(HitObject.StartTime); + if (SegmentDuration > 400 && SpanCount < TotalColumns - 1 - RandomStart) + return generateTiledHoldNotes(StartTime); - return generateHoldAndNormalNotes(HitObject.StartTime); + return generateHoldAndNormalNotes(StartTime); } if (SegmentDuration <= 110) @@ -129,37 +136,37 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy convertType |= PatternType.ForceNotStack; else convertType &= ~PatternType.ForceNotStack; - return generateRandomNotes(HitObject.StartTime, SegmentDuration < 80 ? 1 : 2); + return generateRandomNotes(StartTime, SegmentDuration < 80 ? 1 : 2); } if (ConversionDifficulty > 6.5) { if (convertType.HasFlag(PatternType.LowProbability)) - return generateNRandomNotes(HitObject.StartTime, 0.78, 0.3, 0); + return generateNRandomNotes(StartTime, 0.78, 0.3, 0); - return generateNRandomNotes(HitObject.StartTime, 0.85, 0.36, 0.03); + return generateNRandomNotes(StartTime, 0.85, 0.36, 0.03); } if (ConversionDifficulty > 4) { if (convertType.HasFlag(PatternType.LowProbability)) - return generateNRandomNotes(HitObject.StartTime, 0.43, 0.08, 0); + return generateNRandomNotes(StartTime, 0.43, 0.08, 0); - return generateNRandomNotes(HitObject.StartTime, 0.56, 0.18, 0); + return generateNRandomNotes(StartTime, 0.56, 0.18, 0); } if (ConversionDifficulty > 2.5) { if (convertType.HasFlag(PatternType.LowProbability)) - return generateNRandomNotes(HitObject.StartTime, 0.3, 0, 0); + return generateNRandomNotes(StartTime, 0.3, 0, 0); - return generateNRandomNotes(HitObject.StartTime, 0.37, 0.08, 0); + return generateNRandomNotes(StartTime, 0.37, 0.08, 0); } if (convertType.HasFlag(PatternType.LowProbability)) - return generateNRandomNotes(HitObject.StartTime, 0.17, 0, 0); + return generateNRandomNotes(StartTime, 0.17, 0, 0); - return generateNRandomNotes(HitObject.StartTime, 0.27, 0, 0); + return generateNRandomNotes(StartTime, 0.27, 0, 0); } /// @@ -168,7 +175,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// Start time of each hold note. /// Number of hold notes. /// The containing the hit objects. - private Pattern generateRandomHoldNotes(double startTime, int noteCount) + private Pattern generateRandomHoldNotes(int startTime, int noteCount) { // - - - - // ■ - ■ ■ @@ -203,7 +210,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// The start time. /// The number of notes. /// The containing the hit objects. - private Pattern generateRandomNotes(double startTime, int noteCount) + private Pattern generateRandomNotes(int startTime, int noteCount) { // - - - - // x - - - @@ -235,7 +242,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The start time. /// The containing the hit objects. - private Pattern generateStair(double startTime) + private Pattern generateStair(int startTime) { // - - - - // x - - - @@ -251,7 +258,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy int column = GetColumn((HitObject as IHasXPosition)?.X ?? 0, true); bool increasing = Random.NextDouble() > 0.5; - for (int i = 0; i <= spanCount; i++) + for (int i = 0; i <= SpanCount; i++) { addToPattern(pattern, column, startTime, startTime); startTime += SegmentDuration; @@ -287,7 +294,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The start time. /// The containing the hit objects. - private Pattern generateRandomMultipleNotes(double startTime) + private Pattern generateRandomMultipleNotes(int startTime) { // - - - - // x - - - @@ -302,7 +309,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy int nextColumn = GetColumn((HitObject as IHasXPosition)?.X ?? 0, true); - for (int i = 0; i <= spanCount; i++) + for (int i = 0; i <= SpanCount; i++) { addToPattern(pattern, nextColumn, startTime, startTime); @@ -330,7 +337,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// The probability required for 3 hold notes to be generated. /// The probability required for 4 hold notes to be generated. /// The containing the hit objects. - private Pattern generateNRandomNotes(double startTime, double p2, double p3, double p4) + private Pattern generateNRandomNotes(int startTime, double p2, double p3, double p4) { // - - - - // ■ - ■ ■ @@ -364,10 +371,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy break; } - bool isDoubleSample(HitSampleInfo sample) => sample.Name == HitSampleInfo.HIT_CLAP || sample.Name == HitSampleInfo.HIT_FINISH; + static bool isDoubleSample(HitSampleInfo sample) => sample.Name == HitSampleInfo.HIT_CLAP || sample.Name == HitSampleInfo.HIT_FINISH; bool canGenerateTwoNotes = !convertType.HasFlag(PatternType.LowProbability); - canGenerateTwoNotes &= HitObject.Samples.Any(isDoubleSample) || sampleInfoListAt(HitObject.StartTime).Any(isDoubleSample); + canGenerateTwoNotes &= HitObject.Samples.Any(isDoubleSample) || sampleInfoListAt(StartTime).Any(isDoubleSample); if (canGenerateTwoNotes) p2 = 1; @@ -380,7 +387,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The first hold note start time. /// The containing the hit objects. - private Pattern generateTiledHoldNotes(double startTime) + private Pattern generateTiledHoldNotes(int startTime) { // - - - - // ■ ■ ■ ■ @@ -393,7 +400,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy var pattern = new Pattern(); - int columnRepeat = Math.Min(spanCount, TotalColumns); + int columnRepeat = Math.Min(SpanCount, TotalColumns); + + // Due to integer rounding, this is not guaranteed to be the same as EndTime (the class-level variable). + int endTime = startTime + SegmentDuration * SpanCount; int nextColumn = GetColumn((HitObject as IHasXPosition)?.X ?? 0, true); if (convertType.HasFlag(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) @@ -402,7 +412,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy for (int i = 0; i < columnRepeat; i++) { nextColumn = FindAvailableColumn(nextColumn, pattern); - addToPattern(pattern, nextColumn, startTime, EndTime); + addToPattern(pattern, nextColumn, startTime, endTime); startTime += SegmentDuration; } @@ -414,7 +424,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The start time of notes. /// The containing the hit objects. - private Pattern generateHoldAndNormalNotes(double startTime) + private Pattern generateHoldAndNormalNotes(int startTime) { // - - - - // ■ x x - @@ -447,9 +457,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy var rowPattern = new Pattern(); - for (int i = 0; i <= spanCount; i++) + for (int i = 0; i <= SpanCount; i++) { - if (!(ignoreHead && startTime == HitObject.StartTime)) + if (!(ignoreHead && startTime == StartTime)) { for (int j = 0; j < noteCount; j++) { @@ -472,17 +482,21 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The time to retrieve the sample info list from. /// - private List sampleInfoListAt(double time) + private IList sampleInfoListAt(int time) => nodeSamplesAt(time)?.First() ?? HitObject.Samples; + + /// + /// Retrieves the list of node samples that occur at time greater than or equal to . + /// + /// The time to retrieve node samples at. + private List> nodeSamplesAt(int time) { - var curveData = HitObject as IHasCurve; + if (!(HitObject is IHasPathWithRepeats curveData)) + return null; - if (curveData == null) - return HitObject.Samples; + var index = SegmentDuration == 0 ? 0 : (time - StartTime) / SegmentDuration; - double segmentTime = (EndTime - HitObject.StartTime) / spanCount; - - int index = (int)(segmentTime == 0 ? 0 : (time - HitObject.StartTime) / segmentTime); - return curveData.NodeSamples[index]; + // avoid slicing the list & creating copies, if at all possible. + return index == 0 ? curveData.NodeSamples : curveData.NodeSamples.Skip(index).ToList(); } /// @@ -492,7 +506,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// The column to add the note to. /// The start time of the note. /// The end time of the note (set to for a non-hold note). - private void addToPattern(Pattern pattern, int column, double startTime, double endTime) + private void addToPattern(Pattern pattern, int column, int startTime, int endTime) { ManiaHitObject newObject; @@ -507,16 +521,14 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy } else { - var holdNote = new HoldNote + newObject = new HoldNote { StartTime = startTime, - Column = column, Duration = endTime - startTime, - Head = { Samples = sampleInfoListAt(startTime) }, - Tail = { Samples = sampleInfoListAt(endTime) } + Column = column, + Samples = HitObject.Samples, + NodeSamples = nodeSamplesAt(startTime) }; - - newObject = holdNote; } pattern.Add(newObject); diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs index b3be08e1f7..f816a70ab3 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs @@ -14,12 +14,17 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { internal class EndTimeObjectPatternGenerator : PatternGenerator { - private readonly double endTime; + private readonly int endTime; + private readonly PatternType convertType; - public EndTimeObjectPatternGenerator(FastRandom random, HitObject hitObject, ManiaBeatmap beatmap, IBeatmap originalBeatmap) - : base(random, hitObject, beatmap, new Pattern(), originalBeatmap) + public EndTimeObjectPatternGenerator(FastRandom random, HitObject hitObject, ManiaBeatmap beatmap, Pattern previousPattern, IBeatmap originalBeatmap) + : base(random, hitObject, beatmap, previousPattern, originalBeatmap) { - endTime = (HitObject as IHasEndTime)?.EndTime ?? 0; + endTime = (int)((HitObject as IHasDuration)?.EndTime ?? 0); + + convertType = PreviousPattern.ColumnWithObjects == TotalColumns + ? PatternType.None + : PatternType.ForceNotStack; } public override IEnumerable Generate() @@ -40,18 +45,25 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy break; case 8: - addToPattern(pattern, FindAvailableColumn(GetRandomColumn(), PreviousPattern), generateHold); + addToPattern(pattern, getRandomColumn(), generateHold); break; default: - if (TotalColumns > 0) - addToPattern(pattern, GetRandomColumn(), generateHold); + addToPattern(pattern, getRandomColumn(0), generateHold); break; } return pattern; } + private int getRandomColumn(int? lowerBound = null) + { + if ((convertType & PatternType.ForceNotStack) > 0) + return FindAvailableColumn(GetRandomColumn(lowerBound), lowerBound, patterns: PreviousPattern); + + return FindAvailableColumn(GetRandomColumn(lowerBound), lowerBound); + } + /// /// Constructs and adds a note to a pattern. /// @@ -64,21 +76,14 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (holdNote) { - var hold = new HoldNote + newObject = new HoldNote { StartTime = HitObject.StartTime, + Duration = endTime - HitObject.StartTime, Column = column, - Duration = endTime - HitObject.StartTime + Samples = HitObject.Samples, + NodeSamples = (HitObject as IHasRepeats)?.NodeSamples }; - - if (hold.Head.Samples == null) - hold.Head.Samples = new List(); - - hold.Head.Samples.Add(new HitSampleInfo { Name = HitSampleInfo.HIT_NORMAL }); - - hold.Tail.Samples = HitObject.Samples; - - newObject = hold; } else { diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs index decd159ee9..bc4ab55767 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs @@ -12,6 +12,7 @@ using osu.Game.Rulesets.Mania.MathUtils; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osu.Framework.Extensions.IEnumerableExtensions; namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { @@ -88,15 +89,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy public override IEnumerable Generate() { - yield return generate(); - } - - private Pattern generate() - { - var pattern = new Pattern(); - - try + Pattern generateCore() { + var pattern = new Pattern(); + if (TotalColumns == 1) { addToPattern(pattern, 0); @@ -109,8 +105,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { // Generate a new pattern by copying the last hit objects in reverse-column order for (int i = RandomStart; i < TotalColumns; i++) + { if (PreviousPattern.ColumnHasObject(i)) addToPattern(pattern, RandomStart + TotalColumns - i - 1); + } return pattern; } @@ -132,8 +130,10 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { // Generate a new pattern by placing on the already filled columns for (int i = RandomStart; i < TotalColumns; i++) + { if (PreviousPattern.ColumnHasObject(i)) addToPattern(pattern, i); + } return pattern; } @@ -164,54 +164,56 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy } if (convertType.HasFlag(PatternType.KeepSingle)) - return pattern = generateRandomNotes(1); + return generateRandomNotes(1); if (convertType.HasFlag(PatternType.Mirror)) { if (ConversionDifficulty > 6.5) - return pattern = generateRandomPatternWithMirrored(0.12, 0.38, 0.12); + return generateRandomPatternWithMirrored(0.12, 0.38, 0.12); if (ConversionDifficulty > 4) - return pattern = generateRandomPatternWithMirrored(0.12, 0.17, 0); + return generateRandomPatternWithMirrored(0.12, 0.17, 0); - return pattern = generateRandomPatternWithMirrored(0.12, 0, 0); + return generateRandomPatternWithMirrored(0.12, 0, 0); } if (ConversionDifficulty > 6.5) { if (convertType.HasFlag(PatternType.LowProbability)) - return pattern = generateRandomPattern(0.78, 0.42, 0, 0); + return generateRandomPattern(0.78, 0.42, 0, 0); - return pattern = generateRandomPattern(1, 0.62, 0, 0); + return generateRandomPattern(1, 0.62, 0, 0); } if (ConversionDifficulty > 4) { if (convertType.HasFlag(PatternType.LowProbability)) - return pattern = generateRandomPattern(0.35, 0.08, 0, 0); + return generateRandomPattern(0.35, 0.08, 0, 0); - return pattern = generateRandomPattern(0.52, 0.15, 0, 0); + return generateRandomPattern(0.52, 0.15, 0, 0); } if (ConversionDifficulty > 2) { if (convertType.HasFlag(PatternType.LowProbability)) - return pattern = generateRandomPattern(0.18, 0, 0, 0); + return generateRandomPattern(0.18, 0, 0, 0); - return pattern = generateRandomPattern(0.45, 0, 0, 0); + return generateRandomPattern(0.45, 0, 0, 0); } - return pattern = generateRandomPattern(0, 0, 0, 0); + return generateRandomPattern(0, 0, 0, 0); } - finally + + var p = generateCore(); + + foreach (var obj in p.HitObjects) { - foreach (var obj in pattern.HitObjects) - { - if (convertType.HasFlag(PatternType.Stair) && obj.Column == TotalColumns - 1) - StairType = PatternType.ReverseStair; - if (convertType.HasFlag(PatternType.ReverseStair) && obj.Column == RandomStart) - StairType = PatternType.Stair; - } + if (convertType.HasFlag(PatternType.Stair) && obj.Column == TotalColumns - 1) + StairType = PatternType.ReverseStair; + if (convertType.HasFlag(PatternType.ReverseStair) && obj.Column == RandomStart) + StairType = PatternType.Stair; } + + return p.Yield(); } /// @@ -299,8 +301,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy var pattern = new Pattern(); - bool addToCentre; - int noteCount = getRandomNoteCountMirrored(centreProbability, p2, p3, out addToCentre); + int noteCount = getRandomNoteCountMirrored(centreProbability, p2, p3, out var addToCentre); int columnLimit = (TotalColumns % 2 == 0 ? TotalColumns : TotalColumns - 1) / 2; int nextColumn = GetRandomColumn(upperBound: columnLimit); @@ -380,8 +381,6 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// The amount of notes to be generated. The note to be added to the centre column will NOT be part of this count. private int getRandomNoteCountMirrored(double centreProbability, double p2, double p3, out bool addToCentre) { - addToCentre = false; - switch (TotalColumns) { case 2: @@ -398,7 +397,11 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy case 4: centreProbability = 0; - p2 = Math.Min(p2 * 2, 0.2); + + // Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x). + // But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer), + // so it needs to be converted to from a probability and then back after the multiplication. + p2 = 1 - Math.Max((1 - p2) * 2, 0.8); p3 = 0; break; @@ -409,11 +412,20 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy case 6: centreProbability = 0; - p2 = Math.Min(p2 * 2, 0.5); - p3 = Math.Min(p3 * 2, 0.15); + + // Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x). + // But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer), + // so it needs to be converted to from a probability and then back after the multiplication. + p2 = 1 - Math.Max((1 - p2) * 2, 0.5); + p3 = 1 - Math.Max((1 - p3) * 2, 0.85); break; } + // The stable values were allowed to exceed 1, which indicate <0% probability. + // These values needs to be clamped otherwise GetRandomNoteCount() will throw an exception. + p2 = Math.Clamp(p2, 0, 1); + p3 = Math.Clamp(p3, 0, 1); + double centreVal = Random.NextDouble(); int noteCount = GetRandomNoteCount(p2, p3); diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs index fba52dfc32..fb58d805a9 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternGenerator.cs @@ -7,7 +7,6 @@ using JetBrains.Annotations; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.MathUtils; using osu.Game.Rulesets.Objects; -using osuTK; namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { @@ -54,11 +53,11 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (allowSpecial && TotalColumns == 8) { const float local_x_divisor = 512f / 7; - return MathHelper.Clamp((int)Math.Floor(position / local_x_divisor), 0, 6) + 1; + return Math.Clamp((int)MathF.Floor(position / local_x_divisor), 0, 6) + 1; } float localXDivisor = 512f / TotalColumns; - return MathHelper.Clamp((int)Math.Floor(position / localXDivisor), 0, TotalColumns - 1); + return Math.Clamp((int)MathF.Floor(position / localXDivisor), 0, TotalColumns - 1); } /// @@ -113,7 +112,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy drainTime = 10000; BeatmapDifficulty difficulty = OriginalBeatmap.BeatmapInfo.BaseDifficulty; - conversionDifficulty = ((difficulty.DrainRate + MathHelper.Clamp(difficulty.ApproachRate, 4, 7)) / 1.5 + (double)OriginalBeatmap.HitObjects.Count / drainTime * 9f) / 38f * 5f / 1.15; + conversionDifficulty = ((difficulty.DrainRate + Math.Clamp(difficulty.ApproachRate, 4, 7)) / 1.5 + (double)OriginalBeatmap.HitObjects.Count / drainTime * 9f) / 38f * 5f / 1.15; conversionDifficulty = Math.Min(conversionDifficulty.Value, 12); return conversionDifficulty.Value; @@ -139,7 +138,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// A function to retrieve the next column. If null, a randomisation scheme will be used. /// A function to perform additional validation checks to determine if a column is a valid candidate for a . /// The minimum column index. If null, is used. - /// The maximum column index. If null, is used. + /// The maximum column index. If null, TotalColumns is used. /// A list of patterns for which the validity of a column should be checked against. /// A column is not a valid candidate if a occupies the same column in any of the patterns. /// A column which has passed the check and for which there are no @@ -148,9 +147,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy protected int FindAvailableColumn(int initialColumn, int? lowerBound = null, int? upperBound = null, Func nextColumn = null, [InstantHandle] Func validation = null, params Pattern[] patterns) { - lowerBound = lowerBound ?? RandomStart; - upperBound = upperBound ?? TotalColumns; - nextColumn = nextColumn ?? (_ => GetRandomColumn(lowerBound, upperBound)); + lowerBound ??= RandomStart; + upperBound ??= TotalColumns; + nextColumn ??= (_ => GetRandomColumn(lowerBound, upperBound)); // Check for the initial column if (isValid(initialColumn)) @@ -184,7 +183,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// Returns a random column index in the range [, ). /// /// The minimum column index. If null, is used. - /// The maximum column index. If null, is used. + /// The maximum column index. If null, is used. protected int GetRandomColumn(int? lowerBound = null, int? upperBound = null) => Random.Next(lowerBound ?? RandomStart, upperBound ?? TotalColumns); /// diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternType.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternType.cs index a3cd455886..e4a28167ec 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternType.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/PatternType.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// Keep the same as last row. /// - ForceStack = 1 << 0, + ForceStack = 1, /// /// Keep different from last row. diff --git a/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs b/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs index dff7cb72ce..3052fc7d34 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/StageDefinition.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.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Beatmaps @@ -20,6 +21,20 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// /// The 0-based column index. /// Whether the column is a special column. - public bool IsSpecialColumn(int column) => Columns % 2 == 1 && column == Columns / 2; + public readonly bool IsSpecialColumn(int column) => Columns % 2 == 1 && column == Columns / 2; + + /// + /// Get the type of column given a column index. + /// + /// The 0-based column index. + /// The type of the column. + public readonly ColumnType GetTypeOfColumn(int column) + { + if (IsSpecialColumn(column)) + return ColumnType.Special; + + int distanceToEdge = Math.Min(column, (Columns - 1) - column); + return distanceToEdge % 2 == 0 ? ColumnType.Odd : ColumnType.Even; + } } } diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index b591f9da22..756f2b7b2f 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.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.Configuration.Tracking; using osu.Game.Configuration; using osu.Game.Rulesets.Configuration; @@ -19,13 +20,14 @@ namespace osu.Game.Rulesets.Mania.Configuration { base.InitialiseDefaults(); - Set(ManiaRulesetSetting.ScrollTime, 2250.0, 50.0, 10000.0, 50.0); + Set(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 5); Set(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings { - new TrackedSetting(ManiaRulesetSetting.ScrollTime, v => new SettingDescription(v, "Scroll Time", $"{v}ms")) + new TrackedSetting(ManiaRulesetSetting.ScrollTime, + v => new SettingDescription(v, "Scroll Speed", $"{(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / v)} ({v}ms)")) }; } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs index 3ff665d2c8..0b58d1efc6 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs @@ -8,5 +8,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty public class ManiaDifficultyAttributes : DifficultyAttributes { public double GreatHitWindow; + public double ScoreMultiplier; } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index 4a9c22d339..ade830764d 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.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.Game.Beatmaps; @@ -10,8 +11,13 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; using osu.Game.Rulesets.Mania.Difficulty.Skills; +using osu.Game.Rulesets.Mania.MathUtils; using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Difficulty { @@ -20,11 +26,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty private const double star_scaling_factor = 0.018; private readonly bool isForCurrentRuleset; + private readonly double originalOverallDifficulty; public ManiaDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) : base(ruleset, beatmap) { isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo); + originalOverallDifficulty = beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty; } protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) @@ -32,65 +40,38 @@ namespace osu.Game.Rulesets.Mania.Difficulty if (beatmap.HitObjects.Count == 0) return new ManiaDifficultyAttributes { Mods = mods, Skills = skills }; + HitWindows hitWindows = new ManiaHitWindows(); + hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + return new ManiaDifficultyAttributes { - StarRating = difficultyValue(skills) * star_scaling_factor, + StarRating = skills[0].DifficultyValue() * star_scaling_factor, Mods = mods, // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future - GreatHitWindow = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / clockRate, + GreatHitWindow = (int)Math.Ceiling(getHitWindow300(mods) / clockRate), + ScoreMultiplier = getScoreMultiplier(beatmap, mods), + MaxCombo = beatmap.HitObjects.Sum(h => h is HoldNote ? 2 : 1), Skills = skills }; } - private double difficultyValue(Skill[] skills) - { - // Preprocess the strains to find the maximum overall + individual (aggregate) strain from each section - var overall = skills.OfType().Single(); - var aggregatePeaks = new List(Enumerable.Repeat(0.0, overall.StrainPeaks.Count)); - - foreach (var individual in skills.OfType()) - { - for (int i = 0; i < individual.StrainPeaks.Count; i++) - { - double aggregate = individual.StrainPeaks[i] + overall.StrainPeaks[i]; - - if (aggregate > aggregatePeaks[i]) - aggregatePeaks[i] = aggregate; - } - } - - aggregatePeaks.Sort((a, b) => b.CompareTo(a)); // Sort from highest to lowest strain. - - double difficulty = 0; - double weight = 1; - - // Difficulty is the weighted sum of the highest strains from every section. - foreach (double strain in aggregatePeaks) - { - difficulty += strain * weight; - weight *= 0.9; - } - - return difficulty; - } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { - for (int i = 1; i < beatmap.HitObjects.Count; i++) - yield return new ManiaDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate); + var sortedObjects = beatmap.HitObjects.ToArray(); + + LegacySortHelper.Sort(sortedObjects, Comparer.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); + + for (int i = 1; i < sortedObjects.Length; i++) + yield return new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate); } - protected override Skill[] CreateSkills(IBeatmap beatmap) + // Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required. + protected override IEnumerable SortObjects(IEnumerable input) => input; + + protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] { - int columnCount = ((ManiaBeatmap)beatmap).TotalColumns; - - var skills = new List { new Overall(columnCount) }; - - for (int i = 0; i < columnCount; i++) - skills.Add(new Individual(i, columnCount)); - - return skills.ToArray(); - } + new Strain(((ManiaBeatmap)beatmap).TotalColumns) + }; protected override Mod[] DifficultyAdjustmentMods { @@ -115,12 +96,73 @@ namespace osu.Game.Rulesets.Mania.Difficulty new ManiaModKey3(), new ManiaModKey4(), new ManiaModKey5(), + new MultiMod(new ManiaModKey5(), new ManiaModDualStages()), new ManiaModKey6(), + new MultiMod(new ManiaModKey6(), new ManiaModDualStages()), new ManiaModKey7(), + new MultiMod(new ManiaModKey7(), new ManiaModDualStages()), new ManiaModKey8(), + new MultiMod(new ManiaModKey8(), new ManiaModDualStages()), new ManiaModKey9(), + new MultiMod(new ManiaModKey9(), new ManiaModDualStages()), }).ToArray(); } } + + private int getHitWindow300(Mod[] mods) + { + if (isForCurrentRuleset) + { + double od = Math.Min(10.0, Math.Max(0, 10.0 - originalOverallDifficulty)); + return applyModAdjustments(34 + 3 * od, mods); + } + + if (Math.Round(originalOverallDifficulty) > 4) + return applyModAdjustments(34, mods); + + return applyModAdjustments(47, mods); + + static int applyModAdjustments(double value, Mod[] mods) + { + if (mods.Any(m => m is ManiaModHardRock)) + value /= 1.4; + else if (mods.Any(m => m is ManiaModEasy)) + value *= 1.4; + + if (mods.Any(m => m is ManiaModDoubleTime)) + value *= 1.5; + else if (mods.Any(m => m is ManiaModHalfTime)) + value *= 0.75; + + return (int)value; + } + } + + private double getScoreMultiplier(IBeatmap beatmap, Mod[] mods) + { + double scoreMultiplier = 1; + + foreach (var m in mods) + { + switch (m) + { + case ManiaModNoFail _: + case ManiaModEasy _: + case ManiaModHalfTime _: + scoreMultiplier *= 0.5; + break; + } + } + + var maniaBeatmap = (ManiaBeatmap)beatmap; + int diff = maniaBeatmap.TotalColumns - maniaBeatmap.OriginalTotalColumns; + + if (diff > 0) + scoreMultiplier *= 0.9; + else if (diff < 0) + scoreMultiplier *= 0.9 + 0.04 * diff; + + return scoreMultiplier; + } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index b99bddee96..00bec18a45 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Game.Beatmaps; +using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -28,8 +28,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty private int countMeh; private int countMiss; - public ManiaPerformanceCalculator(Ruleset ruleset, WorkingBeatmap beatmap, ScoreInfo score) - : base(ruleset, beatmap, score) + public ManiaPerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) + : base(ruleset, attributes, score) { } @@ -37,12 +37,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty { mods = Score.Mods; scaledScore = Score.TotalScore; - countPerfect = Convert.ToInt32(Score.Statistics[HitResult.Perfect]); - countGreat = Convert.ToInt32(Score.Statistics[HitResult.Great]); - countGood = Convert.ToInt32(Score.Statistics[HitResult.Good]); - countOk = Convert.ToInt32(Score.Statistics[HitResult.Ok]); - countMeh = Convert.ToInt32(Score.Statistics[HitResult.Meh]); - countMiss = Convert.ToInt32(Score.Statistics[HitResult.Miss]); + countPerfect = Score.Statistics.GetOrDefault(HitResult.Perfect); + countGreat = Score.Statistics.GetOrDefault(HitResult.Great); + countGood = Score.Statistics.GetOrDefault(HitResult.Good); + countOk = Score.Statistics.GetOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetOrDefault(HitResult.Miss); if (mods.Any(m => !m.Ranked)) return 0; diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Individual.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Individual.cs deleted file mode 100644 index 059cd39641..0000000000 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Individual.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.Linq; -using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; -using osu.Game.Rulesets.Mania.Objects; - -namespace osu.Game.Rulesets.Mania.Difficulty.Skills -{ - public class Individual : Skill - { - protected override double SkillMultiplier => 1; - protected override double StrainDecayBase => 0.125; - - private readonly double[] holdEndTimes; - - private readonly int column; - - public Individual(int column, int columnCount) - { - this.column = column; - - holdEndTimes = new double[columnCount]; - } - - protected override double StrainValueOf(DifficultyHitObject current) - { - var maniaCurrent = (ManiaDifficultyHitObject)current; - var endTime = (maniaCurrent.BaseObject as HoldNote)?.EndTime ?? maniaCurrent.BaseObject.StartTime; - - try - { - if (maniaCurrent.BaseObject.Column != column) - return 0; - - // We give a slight bonus if something is held meanwhile - return holdEndTimes.Any(t => t > endTime) ? 2.5 : 2; - } - finally - { - holdEndTimes[maniaCurrent.BaseObject.Column] = endTime; - } - } - } -} diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Overall.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Overall.cs deleted file mode 100644 index ed25173d38..0000000000 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Overall.cs +++ /dev/null @@ -1,56 +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.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; -using osu.Game.Rulesets.Mania.Objects; - -namespace osu.Game.Rulesets.Mania.Difficulty.Skills -{ - public class Overall : Skill - { - protected override double SkillMultiplier => 1; - protected override double StrainDecayBase => 0.3; - - private readonly double[] holdEndTimes; - - private readonly int columnCount; - - public Overall(int columnCount) - { - this.columnCount = columnCount; - - holdEndTimes = new double[columnCount]; - } - - protected override double StrainValueOf(DifficultyHitObject current) - { - var maniaCurrent = (ManiaDifficultyHitObject)current; - var endTime = (maniaCurrent.BaseObject as HoldNote)?.EndTime ?? maniaCurrent.BaseObject.StartTime; - - double holdFactor = 1.0; // Factor in case something else is held - double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly - - for (int i = 0; i < columnCount; i++) - { - // If there is at least one other overlapping end or note, then we get an addition, buuuuuut... - if (current.BaseObject.StartTime < holdEndTimes[i] && endTime > holdEndTimes[i]) - 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 one - if (endTime == holdEndTimes[i]) - holdAddition = 0; - - // We give a slight bonus if something is held meanwhile - if (holdEndTimes[i] > endTime) - holdFactor = 1.25; - } - - holdEndTimes[maniaCurrent.BaseObject.Column] = endTime; - - return (1 + holdAddition) * holdFactor; - } - } -} diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs new file mode 100644 index 0000000000..7ebc1ff752 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.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; +using osu.Framework.Utils; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Mania.Difficulty.Skills +{ + public class Strain : Skill + { + private const double individual_decay_base = 0.125; + private const double overall_decay_base = 0.30; + + protected override double SkillMultiplier => 1; + protected override double StrainDecayBase => 1; + + private readonly double[] holdEndTimes; + private readonly double[] individualStrains; + + private double individualStrain; + private double overallStrain; + + public Strain(int totalColumns) + { + holdEndTimes = new double[totalColumns]; + individualStrains = new double[totalColumns]; + overallStrain = 1; + } + + protected override double StrainValueOf(DifficultyHitObject current) + { + var maniaCurrent = (ManiaDifficultyHitObject)current; + var endTime = maniaCurrent.BaseObject.GetEndTime(); + var column = maniaCurrent.BaseObject.Column; + + double holdFactor = 1.0; // Factor to all additional strains in case something else is held + double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly + + // Fill up the holdEndTimes array + 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)) + 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 + if (Precision.AlmostEquals(endTime, holdEndTimes[i], 1)) + holdAddition = 0; + + // We give a slight bonus to everything if something is held meanwhile + if (Precision.DefinitelyBigger(holdEndTimes[i], endTime, 1)) + holdFactor = 1.25; + + // Decay individual strains + individualStrains[i] = applyDecay(individualStrains[i], current.DeltaTime, individual_decay_base); + } + + holdEndTimes[column] = endTime; + + // Increase individual strain in own column + individualStrains[column] += 2.0 * holdFactor; + individualStrain = individualStrains[column]; + + overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base) + (1 + holdAddition) * holdFactor; + + return individualStrain + overallStrain - CurrentStrain; + } + + 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); + + private double applyDecay(double value, double deltaTime, double decayBase) + => value * Math.Pow(decayBase, deltaTime / 1000); + } +} diff --git a/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs new file mode 100644 index 0000000000..8d39e08b26 --- /dev/null +++ b/osu.Game.Rulesets.Mania/DualStageVariantGenerator.cs @@ -0,0 +1,64 @@ +// 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.Bindings; + +namespace osu.Game.Rulesets.Mania +{ + public class DualStageVariantGenerator + { + private readonly int singleStageVariant; + private readonly InputKey[] stage1LeftKeys; + private readonly InputKey[] stage1RightKeys; + private readonly InputKey[] stage2LeftKeys; + private readonly InputKey[] stage2RightKeys; + + public DualStageVariantGenerator(int singleStageVariant) + { + this.singleStageVariant = singleStageVariant; + + // 10K is special because it expands towards the centre of the keyboard (VM/BN), rather than towards the edges of the keyboard. + if (singleStageVariant == 10) + { + stage1LeftKeys = new[] { InputKey.Q, InputKey.W, InputKey.E, InputKey.R, InputKey.V }; + stage1RightKeys = new[] { InputKey.M, InputKey.I, InputKey.O, InputKey.P, InputKey.BracketLeft }; + + stage2LeftKeys = new[] { InputKey.S, InputKey.D, InputKey.F, InputKey.G, InputKey.B }; + stage2RightKeys = new[] { InputKey.N, InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + } + else + { + stage1LeftKeys = new[] { InputKey.Q, InputKey.W, InputKey.E, InputKey.R }; + stage1RightKeys = new[] { InputKey.I, InputKey.O, InputKey.P, InputKey.BracketLeft }; + + stage2LeftKeys = new[] { InputKey.S, InputKey.D, InputKey.F, InputKey.G }; + stage2RightKeys = new[] { InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + } + } + + public IEnumerable GenerateMappings() + { + var stage1Bindings = new VariantMappingGenerator + { + LeftKeys = stage1LeftKeys, + RightKeys = stage1RightKeys, + SpecialKey = InputKey.V, + SpecialAction = ManiaAction.Special1, + NormalActionStart = ManiaAction.Key1 + }.GenerateKeyBindingsFor(singleStageVariant, out var nextNormal); + + var stage2Bindings = new VariantMappingGenerator + { + LeftKeys = stage2LeftKeys, + RightKeys = stage2RightKeys, + SpecialKey = InputKey.B, + SpecialAction = ManiaAction.Special2, + NormalActionStart = nextNormal + }.GenerateKeyBindingsFor(singleStageVariant, out _); + + return stage1Bindings.Concat(stage2Bindings); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs index b99a1157f3..5fa687298a 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditBodyPiece.cs @@ -2,20 +2,22 @@ // 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.Rulesets.Mania.Objects.Drawables.Pieces; namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { - public class EditBodyPiece : BodyPiece + public class EditBodyPiece : DefaultBodyPiece { [BackgroundDependencyLoader] private void load(OsuColour colours) { - AccentColour = colours.Yellow; + AccentColour.Value = colours.Yellow; Background.Alpha = 0.5f; - Foreground.Alpha = 0; } + + protected override Drawable CreateForeground() => base.CreateForeground().With(d => d.Alpha = 0); } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs index 6f85fd9167..8773a39939 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/Components/EditNotePiece.cs @@ -12,12 +12,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints.Components { public EditNotePiece() { - Height = NotePiece.NOTE_HEIGHT; + Height = DefaultNotePiece.NOTE_HEIGHT; CornerRadius = 5; Masking = true; - InternalChild = new NotePiece(); + InternalChild = new DefaultNotePiece(); } [BackgroundDependencyLoader] diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteSelectionBlueprint.cs new file mode 100644 index 0000000000..4e73883de0 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteSelectionBlueprint.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 osu.Framework.Graphics; +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 + { + protected new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject; + + private readonly HoldNotePosition position; + + public HoldNoteNoteSelectionBlueprint(DrawableHoldNote holdNote, HoldNotePosition position) + : base(holdNote) + { + this.position = position; + InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X }; + + Select(); + } + + protected override void Update() + { + base.Update(); + + // Todo: This shouldn't exist, mania should not reference the drawable hitobject directly. + if (DrawableObject.IsLoaded) + { + DrawableNote note = position == HoldNotePosition.Start ? (DrawableNote)DrawableObject.Head : DrawableObject.Tail; + + Anchor = note.Anchor; + Origin = note.Origin; + + Size = note.DrawSize; + 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 26115311f7..b5ec1e1a2a 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -2,11 +2,15 @@ // 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.Input.Events; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.UI.Scrolling; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { @@ -16,6 +20,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints private readonly EditNotePiece headPiece; private readonly EditNotePiece tailPiece; + [Resolved] + private IScrollingInfo scrollingInfo { get; set; } + public HoldNotePlacementBlueprint() : base(new HoldNote()) { @@ -35,8 +42,21 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints if (Column != null) { - headPiece.Y = PositionAt(HitObject.StartTime); - tailPiece.Y = PositionAt(HitObject.EndTime); + headPiece.Y = Parent.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.StartTime)).Y; + tailPiece.Y = Parent.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.EndTime)).Y; + + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Down: + headPiece.Y -= headPiece.DrawHeight / 2; + tailPiece.Y -= tailPiece.DrawHeight / 2; + break; + + case ScrollingDirection.Up: + headPiece.Y += headPiece.DrawHeight / 2; + tailPiece.Y += tailPiece.DrawHeight / 2; + break; + } } var topPosition = new Vector2(headPiece.DrawPosition.X, Math.Min(headPiece.DrawPosition.Y, tailPiece.DrawPosition.Y)); @@ -47,28 +67,40 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints bodyPiece.Height = (bottomPosition - topPosition).Y; } + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button != MouseButton.Left) + return; + + base.OnMouseUp(e); + EndPlacement(true); + } + private double originalStartTime; - protected override bool OnMouseMove(MouseMoveEvent e) + public override void UpdatePosition(SnapResult result) { - base.OnMouseMove(e); + base.UpdatePosition(result); - if (PlacementBegun) + if (PlacementActive) { - var endTime = TimeAt(e.ScreenSpaceMousePosition); - - HitObject.StartTime = endTime < originalStartTime ? endTime : originalStartTime; - HitObject.Duration = Math.Abs(endTime - originalStartTime); + if (result.Time is double endTime) + { + HitObject.StartTime = endTime < originalStartTime ? endTime : originalStartTime; + HitObject.Duration = Math.Abs(endTime - originalStartTime); + } } else { - headPiece.Width = tailPiece.Width = SnappedWidth; - headPiece.X = tailPiece.X = SnappedMousePosition.X; + if (result.Playfield != null) + { + headPiece.Width = tailPiece.Width = result.Playfield.DrawWidth; + headPiece.X = tailPiece.X = ToLocalSpace(result.ScreenSpacePosition).X; + } - originalStartTime = HitObject.StartTime = TimeAt(e.ScreenSpaceMousePosition); + if (result.Time is double startTime) + originalStartTime = HitObject.StartTime = startTime; } - - return true; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePosition.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePosition.cs new file mode 100644 index 0000000000..219dad566d --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePosition.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. + +namespace osu.Game.Rulesets.Mania.Edit.Blueprints +{ + public enum HoldNotePosition + { + Start, + End + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index d64c5dbc6a..1737c4d2e5 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -4,81 +4,79 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; 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.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.UI.Scrolling; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint { - public new DrawableHoldNote HitObject => (DrawableHoldNote)base.HitObject; + public new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject; private readonly IBindable direction = new Bindable(); - private readonly BodyPiece body; + [Resolved] + private OsuColour colours { get; set; } public HoldNoteSelectionBlueprint(DrawableHoldNote hold) : base(hold) { - InternalChildren = new Drawable[] - { - new HoldNoteNoteSelectionBlueprint(hold.Head), - new HoldNoteNoteSelectionBlueprint(hold.Tail), - body = new BodyPiece - { - AccentColour = Color4.Transparent - }, - }; } [BackgroundDependencyLoader] - private void load(OsuColour colours, IScrollingInfo scrollingInfo) + private void load(IScrollingInfo scrollingInfo) { - body.BorderColour = colours.Yellow; - direction.BindTo(scrollingInfo.Direction); } + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + new HoldNoteNoteSelectionBlueprint(DrawableObject, HoldNotePosition.Start), + new HoldNoteNoteSelectionBlueprint(DrawableObject, HoldNotePosition.End), + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 1, + BorderColour = colours.Yellow, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + } + }; + } + protected override void Update() { base.Update(); - Size = HitObject.DrawSize + new Vector2(0, HitObject.Tail.DrawHeight); + // Todo: This shouldn't exist, mania should not reference the drawable hitobject directly. + if (DrawableObject.IsLoaded) + { + Size = DrawableObject.DrawSize + new Vector2(0, DrawableObject.Tail.DrawHeight); - // This is a side-effect of not matching the hitobject's anchors/origins, which is kinda hard to do - // When scrolling upwards our origin is already at the top of the head note (which is the intended location), - // but when scrolling downwards our origin is at the _bottom_ of the tail note (where we need to be at the _top_ of the tail note) - if (direction.Value == ScrollingDirection.Down) - Y -= HitObject.Tail.DrawHeight; + // This is a side-effect of not matching the hitobject's anchors/origins, which is kinda hard to do + // When scrolling upwards our origin is already at the top of the head note (which is the intended location), + // but when scrolling downwards our origin is at the _bottom_ of the tail note (where we need to be at the _top_ of the tail note) + if (direction.Value == ScrollingDirection.Down) + Y -= DrawableObject.Tail.DrawHeight; + } } public override Quad SelectionQuad => ScreenSpaceDrawQuad; - private class HoldNoteNoteSelectionBlueprint : NoteSelectionBlueprint - { - public HoldNoteNoteSelectionBlueprint(DrawableNote note) - : base(note) - { - Select(); - } - - protected override void Update() - { - base.Update(); - - Anchor = HitObject.Anchor; - Origin = HitObject.Origin; - - Position = HitObject.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; - } + public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.Head.ScreenSpaceDrawQuad.Centre; } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs index d3779e2e18..27a279e044 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs @@ -1,42 +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.Input; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.Mania.UI; -using osu.Game.Rulesets.UI.Scrolling; -using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public abstract class ManiaPlacementBlueprint : PlacementBlueprint, - IRequireHighFrequencyMousePosition // the playfield could be moving behind us + public abstract class ManiaPlacementBlueprint : PlacementBlueprint where T : ManiaHitObject { protected new T HitObject => (T)base.HitObject; - protected Column Column; + private Column column; - /// - /// The current mouse position, snapped to the closest column. - /// - protected Vector2 SnappedMousePosition { get; private set; } + public Column Column + { + get => column; + set + { + if (value == column) + return; - /// - /// The width of the closest column to the current mouse position. - /// - protected float SnappedWidth { get; private set; } - - [Resolved] - private IManiaHitObjectComposer composer { get; set; } - - [Resolved] - private IScrollingInfo scrollingInfo { get; set; } + column = value; + HitObject.Column = column.Index; + } + } protected ManiaPlacementBlueprint(T hitObject) : base(hitObject) @@ -46,73 +38,22 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints protected override bool OnMouseDown(MouseDownEvent e) { + if (e.Button != MouseButton.Left) + return false; + if (Column == null) - return base.OnMouseDown(e); + return false; - HitObject.StartTime = TimeAt(e.ScreenSpaceMousePosition); - HitObject.Column = Column.Index; - - BeginPlacement(); + BeginPlacement(true); return true; } - protected override bool OnMouseUp(MouseUpEvent e) + public override void UpdatePosition(SnapResult result) { - EndPlacement(); - return base.OnMouseUp(e); - } + base.UpdatePosition(result); - protected override bool OnMouseMove(MouseMoveEvent e) - { - if (!PlacementBegun) - Column = ColumnAt(e.ScreenSpaceMousePosition); - - if (Column == null) return false; - - SnappedWidth = Column.DrawWidth; - - // Snap to the column - var parentPos = Parent.ToLocalSpace(Column.ToScreenSpace(new Vector2(Column.DrawWidth / 2, 0))); - SnappedMousePosition = new Vector2(parentPos.X, e.MousePosition.Y); - return true; - } - - protected double TimeAt(Vector2 screenSpacePosition) - { - if (Column == null) - return 0; - - var hitObjectContainer = Column.HitObjectContainer; - - // If we're scrolling downwards, a position of 0 is actually further away from the hit target - // so we need to flip the vertical coordinate in the hitobject container's space - var hitObjectPos = Column.HitObjectContainer.ToLocalSpace(applyPositionOffset(screenSpacePosition, false)).Y; - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - hitObjectPos = hitObjectContainer.DrawHeight - hitObjectPos; - - return scrollingInfo.Algorithm.TimeAt(hitObjectPos, - EditorClock.CurrentTime, - scrollingInfo.TimeRange.Value, - hitObjectContainer.DrawHeight); - } - - protected float PositionAt(double time) - { - var pos = scrollingInfo.Algorithm.PositionAt(time, - EditorClock.CurrentTime, - scrollingInfo.TimeRange.Value, - Column.HitObjectContainer.DrawHeight); - - return applyPositionOffset(Column.HitObjectContainer.ToSpaceOfOtherDrawable(new Vector2(0, pos), Parent), true).Y; - } - - protected Column ColumnAt(Vector2 screenSpacePosition) - => composer.ColumnAt(applyPositionOffset(screenSpacePosition, false)); - - private Vector2 applyPositionOffset(Vector2 position, bool reverse) - { - position.Y += (scrollingInfo.Direction.Value == ScrollingDirection.Up && !reverse ? -1 : 1) * NotePiece.NOTE_HEIGHT / 2; - return position; + if (!PlacementActive) + 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 d3c12b1944..384f49d9b2 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs @@ -3,8 +3,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Input.Events; -using osu.Framework.Timing; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables; @@ -13,67 +11,35 @@ using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { - public class ManiaSelectionBlueprint : SelectionBlueprint + public abstract class ManiaSelectionBlueprint : OverlaySelectionBlueprint { - public Vector2 ScreenSpaceDragPosition { get; private set; } - public Vector2 DragPosition { get; private set; } - - protected new DrawableManiaHitObject HitObject => (DrawableManiaHitObject)base.HitObject; - - protected IClock EditorClock { get; private set; } + public new DrawableManiaHitObject DrawableObject => (DrawableManiaHitObject)base.DrawableObject; [Resolved] private IScrollingInfo scrollingInfo { get; set; } - [Resolved] - private IManiaHitObjectComposer composer { get; set; } - - public ManiaSelectionBlueprint(DrawableHitObject hitObject) - : base(hitObject) + protected ManiaSelectionBlueprint(DrawableHitObject drawableObject) + : base(drawableObject) { RelativeSizeAxes = Axes.None; } - [BackgroundDependencyLoader] - private void load(IAdjustableClock clock) - { - EditorClock = clock; - } - protected override void Update() { base.Update(); - Position = Parent.ToLocalSpace(HitObject.ToScreenSpace(Vector2.Zero)); - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - ScreenSpaceDragPosition = e.ScreenSpaceMousePosition; - DragPosition = HitObject.ToLocalSpace(e.ScreenSpaceMousePosition); - - return base.OnMouseDown(e); - } - - protected override bool OnDrag(DragEvent e) - { - var result = base.OnDrag(e); - - ScreenSpaceDragPosition = e.ScreenSpaceMousePosition; - DragPosition = HitObject.ToLocalSpace(e.ScreenSpaceMousePosition); - - return result; + Position = Parent.ToLocalSpace(DrawableObject.ToScreenSpace(Vector2.Zero)); } public override void Show() { - HitObject.AlwaysAlive = true; + DrawableObject.AlwaysAlive = true; base.Show(); } public override void Hide() { - HitObject.AlwaysAlive = false; + DrawableObject.AlwaysAlive = false; base.Hide(); } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs index 32c6a6fd07..684004b558 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs @@ -2,29 +2,48 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; +using osuTK.Input; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public class NotePlacementBlueprint : ManiaPlacementBlueprint { + private readonly EditNotePiece piece; + public NotePlacementBlueprint() : base(new Note()) { - Origin = Anchor.Centre; + RelativeSizeAxes = Axes.Both; - AutoSizeAxes = Axes.Y; - - InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X }; + InternalChild = piece = new EditNotePiece { Origin = Anchor.Centre }; } - protected override void Update() + public override void UpdatePosition(SnapResult result) { - base.Update(); + base.UpdatePosition(result); - Width = SnappedWidth; - Position = SnappedMousePosition; + if (result.Playfield != null) + { + piece.Width = result.Playfield.DrawWidth; + piece.Position = ToLocalSpace(result.ScreenSpacePosition); + } + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button != MouseButton.Left) + return false; + + base.OnMouseDown(e); + + // Place the note immediately. + EndPlacement(true); + + return true; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs index d345b14e84..2bff33c4cf 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs @@ -19,7 +19,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.Update(); - Size = HitObject.DrawSize; + // Todo: This shouldn't exist, mania should not reference the drawable hitobject directly. + if (DrawableObject.IsLoaded) + Size = DrawableObject.DrawSize; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditRuleset.cs b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditRuleset.cs index e5f379f608..445df79f6f 100644 --- a/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditRuleset.cs +++ b/osu.Game.Rulesets.Mania/Edit/DrawableManiaEditRuleset.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Mania.Edit { public new IScrollingInfo ScrollingInfo => base.ScrollingInfo; - public DrawableManiaEditRuleset(Ruleset ruleset, WorkingBeatmap beatmap, IReadOnlyList mods) + public DrawableManiaEditRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) : base(ruleset, beatmap, mods) { } diff --git a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs index 295bf417c4..a5f10ed436 100644 --- a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs +++ b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.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 osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mania.Edit.Blueprints; @@ -14,6 +16,8 @@ namespace osu.Game.Rulesets.Mania.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); + public override PlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Mania/Edit/IManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/IManiaHitObjectComposer.cs deleted file mode 100644 index f64bab1fae..0000000000 --- a/osu.Game.Rulesets.Mania/Edit/IManiaHitObjectComposer.cs +++ /dev/null @@ -1,15 +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.Game.Rulesets.Mania.UI; -using osuTK; - -namespace osu.Game.Rulesets.Mania.Edit -{ - public interface IManiaHitObjectComposer - { - Column ColumnAt(Vector2 screenSpacePosition); - - int TotalColumns { get; } - } -} diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs new file mode 100644 index 0000000000..afc08dcc96 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs @@ -0,0 +1,211 @@ +// 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.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Edit +{ + /// + /// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor. + /// + public class ManiaBeatSnapGrid : Component + { + private const double visible_range = 750; + + /// + /// The range of time values of the current selection. + /// + public (double start, double end)? SelectionTimeRange + { + set + { + if (value == selectionTimeRange) + return; + + selectionTimeRange = value; + lineCache.Invalidate(); + } + } + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private IScrollingInfo scrollingInfo { get; set; } + + [Resolved] + private Bindable working { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } + + private readonly List grids = new List(); + + private readonly Cached lineCache = new Cached(); + + private (double start, double end)? selectionTimeRange; + + [BackgroundDependencyLoader] + private void load(HitObjectComposer composer) + { + foreach (var stage in ((ManiaPlayfield)composer.Playfield).Stages) + { + foreach (var column in stage.Columns) + { + var lineContainer = new ScrollingHitObjectContainer(); + + grids.Add(lineContainer); + column.UnderlayElements.Add(lineContainer); + } + } + + beatDivisor.BindValueChanged(_ => createLines(), true); + } + + protected override void Update() + { + base.Update(); + + if (!lineCache.IsValid) + { + lineCache.Validate(); + createLines(); + } + } + + private readonly Stack availableLines = new Stack(); + + private void createLines() + { + foreach (var grid in grids) + { + foreach (var line in grid.Objects.OfType()) + availableLines.Push(line); + + grid.Clear(false); + } + + if (selectionTimeRange == null) + return; + + var range = selectionTimeRange.Value; + + var timingPoint = beatmap.ControlPointInfo.TimingPointAt(range.start - visible_range); + + double time = timingPoint.Time; + int beat = 0; + + // progress time until in the visible range. + while (time < range.start - visible_range) + { + time += timingPoint.BeatLength / beatDivisor.Value; + beat++; + } + + while (time < range.end + visible_range) + { + var nextTimingPoint = beatmap.ControlPointInfo.TimingPointAt(time); + + // switch to the next timing point if we have reached it. + if (nextTimingPoint.Time > timingPoint.Time) + { + beat = 0; + time = nextTimingPoint.Time; + timingPoint = nextTimingPoint; + } + + Color4 colour = BindableBeatDivisor.GetColourFor( + BindableBeatDivisor.GetDivisorForBeatIndex(Math.Max(1, beat), beatDivisor.Value), colours); + + foreach (var grid in grids) + { + if (!availableLines.TryPop(out var line)) + line = new DrawableGridLine(); + + line.HitObject.StartTime = time; + line.Colour = colour; + + grid.Add(line); + } + + beat++; + time += timingPoint.BeatLength / beatDivisor.Value; + } + + foreach (var grid in grids) + { + // required to update ScrollingHitObjectContainer's cache. + grid.UpdateSubTree(); + + foreach (var line in grid.Objects.OfType()) + { + time = line.HitObject.StartTime; + + if (time >= range.start && time <= range.end) + line.Alpha = 1; + else + { + double timeSeparation = time < range.start ? range.start - time : time - range.end; + line.Alpha = (float)Math.Max(0, 1 - timeSeparation / visible_range); + } + } + } + } + + private class DrawableGridLine : DrawableHitObject + { + [Resolved] + private IScrollingInfo scrollingInfo { get; set; } + + private readonly IBindable direction = new Bindable(); + + public DrawableGridLine() + : base(new HitObject()) + { + RelativeSizeAxes = Axes.X; + Height = 2; + + AddInternal(new Box { RelativeSizeAxes = Axes.Both }); + } + + [BackgroundDependencyLoader] + private void load() + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + Origin = Anchor = direction.NewValue == ScrollingDirection.Up + ? Anchor.TopLeft + : Anchor.BottomLeft; + } + + protected override void UpdateInitialTransforms() + { + // don't perform any fading – we are handling that ourselves. + LifetimeEnd = HitObject.StartTime + visible_range; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.cs new file mode 100644 index 0000000000..cea27498c3 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBlueprintContainer.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 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.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Mania.Edit +{ + public class ManiaBlueprintContainer : ComposeBlueprintContainer + { + public ManiaBlueprintContainer(IEnumerable drawableHitObjects) + : base(drawableHitObjects) + { + } + + public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) + { + switch (hitObject) + { + case DrawableNote note: + return new NoteSelectionBlueprint(note); + + case DrawableHoldNote holdNote: + return new HoldNoteSelectionBlueprint(holdNote); + } + + return base.CreateBlueprintFor(hitObject); + } + + protected override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index 2729621ab3..7e2469a794 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -5,73 +5,120 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.Objects.Drawables; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; -using osu.Game.Rulesets.Mania.Edit.Blueprints; +using osu.Framework.Input; +using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; using osuTK; namespace osu.Game.Rulesets.Mania.Edit { - [Cached(Type = typeof(IManiaHitObjectComposer))] - public class ManiaHitObjectComposer : HitObjectComposer, IManiaHitObjectComposer + public class ManiaHitObjectComposer : HitObjectComposer { - protected new DrawableManiaEditRuleset DrawableRuleset { get; private set; } + private DrawableManiaEditRuleset drawableRuleset; + private ManiaBeatSnapGrid beatSnapGrid; + private InputManager inputManager; public ManiaHitObjectComposer(Ruleset ruleset) : base(ruleset) { } - /// - /// Retrieves the column that intersects a screen-space position. - /// - /// The screen-space position. - /// The column which intersects with . - public Column ColumnAt(Vector2 screenSpacePosition) => DrawableRuleset.GetColumnByPosition(screenSpacePosition); + [BackgroundDependencyLoader] + private void load() + { + AddInternal(beatSnapGrid = new ManiaBeatSnapGrid()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } private DependencyContainer dependencies; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - public int TotalColumns => ((ManiaPlayfield)DrawableRuleset.Playfield).TotalColumns; + public new ManiaPlayfield Playfield => ((ManiaPlayfield)drawableRuleset.Playfield); - protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, WorkingBeatmap beatmap, IReadOnlyList mods) + public IScrollingInfo ScrollingInfo => drawableRuleset.ScrollingInfo; + + protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => + Playfield.GetColumnByPosition(screenSpacePosition); + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) { - DrawableRuleset = new DrawableManiaEditRuleset(ruleset, beatmap, mods); + var result = base.SnapScreenSpacePositionToValidTime(screenSpacePosition); + + switch (ScrollingInfo.Direction.Value) + { + case ScrollingDirection.Down: + result.ScreenSpacePosition -= new Vector2(0, getNoteHeight() / 2); + break; + + case ScrollingDirection.Up: + result.ScreenSpacePosition += new Vector2(0, getNoteHeight() / 2); + break; + } + + return result; + } + + private float getNoteHeight() => + Playfield.GetColumn(0).ToScreenSpace(new Vector2(DefaultNotePiece.NOTE_HEIGHT)).Y - + Playfield.GetColumn(0).ToScreenSpace(Vector2.Zero).Y; + + protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + { + drawableRuleset = new DrawableManiaEditRuleset(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); + dependencies.CacheAs(drawableRuleset.ScrollingInfo); - return DrawableRuleset; + return drawableRuleset; } + protected override ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects) + => new ManiaBlueprintContainer(hitObjects); + protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { new NoteCompositionTool(), new HoldNoteCompositionTool() }; - public override SelectionHandler CreateSelectionHandler() => new ManiaSelectionHandler(); - - public override SelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) + protected override void UpdateAfterChildren() { - switch (hitObject) + base.UpdateAfterChildren(); + + if (BlueprintContainer.CurrentTool is SelectTool) { - case DrawableNote note: - return new NoteSelectionBlueprint(note); - - case DrawableHoldNote holdNote: - return new HoldNoteSelectionBlueprint(holdNote); + if (EditorBeatmap.SelectedHitObjects.Any()) + { + beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime())); + } + else + beatSnapGrid.SelectionTimeRange = null; + } + else + { + var result = SnapScreenSpacePositionToValidTime(inputManager.CurrentState.Mouse.Position); + if (result.Time is double time) + beatSnapGrid.SelectionTimeRange = (time, time); + else + beatSnapGrid.SelectionTimeRange = null; } - - return base.CreateBlueprintFor(hitObject); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index 6f49c7f0c4..50629f41a9 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -1,17 +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 System; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Input.Events; -using osu.Framework.Timing; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Edit.Blueprints; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; -using osuTK; namespace osu.Game.Rulesets.Mania.Edit { @@ -21,94 +18,34 @@ namespace osu.Game.Rulesets.Mania.Edit private IScrollingInfo scrollingInfo { get; set; } [Resolved] - private IManiaHitObjectComposer composer { get; set; } + private HitObjectComposer composer { get; set; } - private IClock editorClock; - - [BackgroundDependencyLoader] - private void load(IAdjustableClock clock) + public override bool HandleMovement(MoveSelectionEvent moveEvent) { - editorClock = clock; + var maniaBlueprint = (ManiaSelectionBlueprint)moveEvent.Blueprint; + int lastColumn = maniaBlueprint.DrawableObject.HitObject.Column; + + performColumnMovement(lastColumn, moveEvent); + + return true; } - public override void HandleDrag(SelectionBlueprint blueprint, DragEvent dragEvent) + private void performColumnMovement(int lastColumn, MoveSelectionEvent moveEvent) { - adjustOrigins((ManiaSelectionBlueprint)blueprint); - performDragMovement(dragEvent); - performColumnMovement(dragEvent); + var maniaPlayfield = ((ManiaHitObjectComposer)composer).Playfield; - base.HandleDrag(blueprint, dragEvent); - } - - /// - /// Ensures that the position of hitobjects remains centred to the mouse position. - /// E.g. The hitobject position will change if the editor scrolls while a hitobject is dragged. - /// - /// The that received the drag event. - private void adjustOrigins(ManiaSelectionBlueprint reference) - { - var referenceParent = (HitObjectContainer)reference.HitObject.Parent; - - float offsetFromReferenceOrigin = reference.DragPosition.Y - reference.HitObject.OriginPosition.Y; - float targetPosition = referenceParent.ToLocalSpace(reference.ScreenSpaceDragPosition).Y - offsetFromReferenceOrigin; - - // Flip the vertical coordinate space when scrolling downwards - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - targetPosition = targetPosition - referenceParent.DrawHeight; - - float movementDelta = targetPosition - reference.HitObject.Position.Y; - - foreach (var b in SelectedBlueprints.OfType()) - b.HitObject.Y += movementDelta; - } - - private void performDragMovement(DragEvent dragEvent) - { - foreach (var b in SelectedBlueprints) - { - var hitObject = b.HitObject; - - var objectParent = (HitObjectContainer)hitObject.Parent; - - // Using the hitobject position is required since AdjustPosition can be invoked multiple times per frame - // without the position having been updated by the parenting ScrollingHitObjectContainer - hitObject.Y += dragEvent.Delta.Y; - - float targetPosition; - - // If we're scrolling downwards, a position of 0 is actually further away from the hit target - // so we need to flip the vertical coordinate in the hitobject container's space - if (scrollingInfo.Direction.Value == ScrollingDirection.Down) - targetPosition = -hitObject.Position.Y; - else - targetPosition = hitObject.Position.Y; - - objectParent.Remove(hitObject); - - hitObject.HitObject.StartTime = scrollingInfo.Algorithm.TimeAt(targetPosition, - editorClock.CurrentTime, - scrollingInfo.TimeRange.Value, - objectParent.DrawHeight); - - objectParent.Add(hitObject); - } - } - - private void performColumnMovement(DragEvent dragEvent) - { - var lastColumn = composer.ColumnAt(dragEvent.ScreenSpaceLastMousePosition); - var currentColumn = composer.ColumnAt(dragEvent.ScreenSpaceMousePosition); - if (lastColumn == null || currentColumn == null) + var currentColumn = maniaPlayfield.GetColumnByPosition(moveEvent.ScreenSpacePosition); + if (currentColumn == null) return; - int columnDelta = currentColumn.Index - lastColumn.Index; + int columnDelta = currentColumn.Index - lastColumn; if (columnDelta == 0) return; int minColumn = int.MaxValue; int maxColumn = int.MinValue; - foreach (var obj in SelectedHitObjects.OfType()) + foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType()) { if (obj.Column < minColumn) minColumn = obj.Column; @@ -116,9 +53,9 @@ namespace osu.Game.Rulesets.Mania.Edit maxColumn = obj.Column; } - columnDelta = MathHelper.Clamp(columnDelta, -minColumn, composer.TotalColumns - 1 - maxColumn); + columnDelta = Math.Clamp(columnDelta, -minColumn, maniaPlayfield.TotalColumns - 1 - maxColumn); - foreach (var obj in SelectedHitObjects.OfType()) + foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType()) obj.Column += columnDelta; } } diff --git a/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs deleted file mode 100644 index 30b0f09a94..0000000000 --- a/osu.Game.Rulesets.Mania/Edit/Masks/ManiaSelectionBlueprint.cs +++ /dev/null @@ -1,18 +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.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects.Drawables; - -namespace osu.Game.Rulesets.Mania.Edit.Masks -{ - public abstract class ManiaSelectionBlueprint : SelectionBlueprint - { - protected ManiaSelectionBlueprint(DrawableHitObject hitObject) - : base(hitObject) - { - RelativeSizeAxes = Axes.None; - } - } -} diff --git a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs index 50b5f9a8fe..9f54152596 100644 --- a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs +++ b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.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 osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mania.Edit.Blueprints; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Mania.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); + public override PlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Mania/Judgements/HoldNoteJudgement.cs b/osu.Game.Rulesets.Mania/Judgements/HoldNoteJudgement.cs deleted file mode 100644 index e8b48768a1..0000000000 --- a/osu.Game.Rulesets.Mania/Judgements/HoldNoteJudgement.cs +++ /dev/null @@ -1,14 +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.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Mania.Judgements -{ - public class HoldNoteJudgement : ManiaJudgement - { - public override bool AffectsCombo => false; - - protected override int NumericResultFor(HitResult result) => 0; - } -} diff --git a/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs b/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs index b9c6e3a7f7..ee6cbbc828 100644 --- a/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs +++ b/osu.Game.Rulesets.Mania/Judgements/HoldNoteTickJudgement.cs @@ -7,20 +7,6 @@ namespace osu.Game.Rulesets.Mania.Judgements { public class HoldNoteTickJudgement : ManiaJudgement { - public override bool AffectsCombo => false; - - protected override int NumericResultFor(HitResult result) => 20; - - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - case HitResult.Miss: - return 0; - - default: - return 0.040; - } - } + public override HitResult MaxResult => HitResult.LargeTickHit; } } diff --git a/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs b/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs index 0e4c811945..d28b7bdf58 100644 --- a/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/Judgements/ManiaJudgement.cs @@ -8,52 +8,33 @@ namespace osu.Game.Rulesets.Mania.Judgements { public class ManiaJudgement : Judgement { - protected override int NumericResultFor(HitResult result) - { - switch (result) - { - default: - return 0; - - case HitResult.Meh: - return 50; - - case HitResult.Ok: - return 100; - - case HitResult.Good: - return 200; - - case HitResult.Great: - case HitResult.Perfect: - return 300; - } - } - protected override double HealthIncreaseFor(HitResult result) { switch (result) { - case HitResult.Miss: - return -0.125; + case HitResult.LargeTickHit: + return DEFAULT_MAX_HEALTH_INCREASE * 0.1; + + case HitResult.LargeTickMiss: + return -DEFAULT_MAX_HEALTH_INCREASE * 0.1; case HitResult.Meh: - return 0.005; + return -DEFAULT_MAX_HEALTH_INCREASE * 0.5; case HitResult.Ok: - return 0.010; + return -DEFAULT_MAX_HEALTH_INCREASE * 0.3; case HitResult.Good: - return 0.035; + return DEFAULT_MAX_HEALTH_INCREASE * 0.1; case HitResult.Great: - return 0.055; + return DEFAULT_MAX_HEALTH_INCREASE * 0.8; case HitResult.Perfect: - return 0.065; + return DEFAULT_MAX_HEALTH_INCREASE; default: - return 0; + return base.HealthIncreaseFor(result); } } } diff --git a/osu.Game.Rulesets.Mania/ManiaInputManager.cs b/osu.Game.Rulesets.Mania/ManiaInputManager.cs index 292990fd7e..186fc4b15d 100644 --- a/osu.Game.Rulesets.Mania/ManiaInputManager.cs +++ b/osu.Game.Rulesets.Mania/ManiaInputManager.cs @@ -78,5 +78,11 @@ namespace osu.Game.Rulesets.Mania [Description("Key 18")] Key18, + + [Description("Key 19")] + Key19, + + [Description("Key 20")] + Key20, } } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index d83033f9c6..b92e042686 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -13,7 +13,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Replays.Types; using osu.Game.Beatmaps.Legacy; @@ -26,26 +25,53 @@ using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Difficulty; using osu.Game.Rulesets.Mania.Edit; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mania.Skinning; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets.Mania { - public class ManiaRuleset : Ruleset + public class ManiaRuleset : Ruleset, ILegacyRuleset { - public override DrawableRuleset CreateDrawableRulesetWith(WorkingBeatmap beatmap, IReadOnlyList mods) => new DrawableManiaRuleset(this, beatmap, mods); - public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap); - public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new ManiaPerformanceCalculator(this, beatmap, score); + /// + /// The maximum number of supported keys in a single stage. + /// + public const int MAX_STAGE_KEYS = 10; + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableManiaRuleset(this, beatmap, mods); + + public override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor(); + + public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new DrainingHealthProcessor(drainStartTime, 0.2); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap, this); + + public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => new ManiaPerformanceCalculator(this, attributes, score); + + public const string SHORT_NAME = "mania"; public override HitObjectComposer CreateHitObjectComposer() => new ManiaHitObjectComposer(this); - public override IEnumerable ConvertLegacyMods(LegacyMods mods) + public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new ManiaLegacySkinTransformer(source, beatmap); + + public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { if (mods.HasFlag(LegacyMods.Nightcore)) yield return new ManiaModNightcore(); else if (mods.HasFlag(LegacyMods.DoubleTime)) yield return new ManiaModDoubleTime(); - if (mods.HasFlag(LegacyMods.Autoplay)) + if (mods.HasFlag(LegacyMods.Perfect)) + yield return new ManiaModPerfect(); + else if (mods.HasFlag(LegacyMods.SuddenDeath)) + yield return new ManiaModSuddenDeath(); + + if (mods.HasFlag(LegacyMods.Cinema)) + yield return new ManiaModCinema(); + else if (mods.HasFlag(LegacyMods.Autoplay)) yield return new ManiaModAutoplay(); if (mods.HasFlag(LegacyMods.Easy)) @@ -96,14 +122,68 @@ namespace osu.Game.Rulesets.Mania if (mods.HasFlag(LegacyMods.NoFail)) yield return new ManiaModNoFail(); - if (mods.HasFlag(LegacyMods.Perfect)) - yield return new ManiaModPerfect(); - if (mods.HasFlag(LegacyMods.Random)) yield return new ManiaModRandom(); - if (mods.HasFlag(LegacyMods.SuddenDeath)) - yield return new ManiaModSuddenDeath(); + if (mods.HasFlag(LegacyMods.Mirror)) + yield return new ManiaModMirror(); + } + + public override LegacyMods ConvertToLegacyMods(Mod[] mods) + { + var value = base.ConvertToLegacyMods(mods); + + foreach (var mod in mods) + { + switch (mod) + { + case ManiaModKey1 _: + value |= LegacyMods.Key1; + break; + + case ManiaModKey2 _: + value |= LegacyMods.Key2; + break; + + case ManiaModKey3 _: + value |= LegacyMods.Key3; + break; + + case ManiaModKey4 _: + value |= LegacyMods.Key4; + break; + + case ManiaModKey5 _: + value |= LegacyMods.Key5; + break; + + case ManiaModKey6 _: + value |= LegacyMods.Key6; + break; + + case ManiaModKey7 _: + value |= LegacyMods.Key7; + break; + + case ManiaModKey8 _: + value |= LegacyMods.Key8; + break; + + case ManiaModKey9 _: + value |= LegacyMods.Key9; + break; + + case ManiaModFadeIn _: + value |= LegacyMods.FadeIn; + break; + + case ManiaModMirror _: + value |= LegacyMods.Mirror; + break; + } + } + + return value; } public override IEnumerable GetModsFor(ModType type) @@ -137,40 +217,45 @@ namespace osu.Game.Rulesets.Mania new ManiaModKey7(), new ManiaModKey8(), new ManiaModKey9(), + new ManiaModKey10(), new ManiaModKey1(), new ManiaModKey2(), new ManiaModKey3()), new ManiaModRandom(), new ManiaModDualStages(), new ManiaModMirror(), + new ManiaModDifficultyAdjust(), + new ManiaModInvert(), }; case ModType.Automation: return new Mod[] { - new MultiMod(new ManiaModAutoplay(), new ModCinema()), + new MultiMod(new ManiaModAutoplay(), new ManiaModCinema()), }; case ModType.Fun: return new Mod[] { - new MultiMod(new ModWindUp(), new ModWindDown()) + new MultiMod(new ModWindUp(), new ModWindDown()) }; default: - return new Mod[] { }; + return Array.Empty(); } } public override string Description => "osu!mania"; - public override string ShortName => "mania"; + public override string ShortName => SHORT_NAME; + + public override string PlayingVerb => "Smashing keys"; public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetMania }; public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new ManiaDifficultyCalculator(this, beatmap); - public override int? LegacyID => 3; + public int LegacyID => 3; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new ManiaReplayFrame(); @@ -178,18 +263,13 @@ namespace osu.Game.Rulesets.Mania public override RulesetSettingsSubsection CreateSettings() => new ManiaSettingsSubsection(this); - public ManiaRuleset(RulesetInfo rulesetInfo = null) - : base(rulesetInfo) - { - } - public override IEnumerable AvailableVariants { get { - for (int i = 1; i <= 9; i++) + for (int i = 1; i <= MAX_STAGE_KEYS; i++) yield return (int)PlayfieldType.Single + i; - for (int i = 2; i <= 18; i += 2) + for (int i = 2; i <= MAX_STAGE_KEYS * 2; i += 2) yield return (int)PlayfieldType.Dual + i; } } @@ -199,76 +279,13 @@ namespace osu.Game.Rulesets.Mania switch (getPlayfieldType(variant)) { case PlayfieldType.Single: - return new VariantMappingGenerator - { - LeftKeys = new[] - { - InputKey.A, - InputKey.S, - InputKey.D, - InputKey.F - }, - RightKeys = new[] - { - InputKey.J, - InputKey.K, - InputKey.L, - InputKey.Semicolon - }, - SpecialKey = InputKey.Space, - SpecialAction = ManiaAction.Special1, - NormalActionStart = ManiaAction.Key1, - }.GenerateKeyBindingsFor(variant, out _); + return new SingleStageVariantGenerator(variant).GenerateMappings(); case PlayfieldType.Dual: - int keys = getDualStageKeyCount(variant); - - var stage1Bindings = new VariantMappingGenerator - { - LeftKeys = new[] - { - InputKey.Number1, - InputKey.Number2, - InputKey.Number3, - InputKey.Number4, - }, - RightKeys = new[] - { - InputKey.Z, - InputKey.X, - InputKey.C, - InputKey.V - }, - SpecialKey = InputKey.Tilde, - SpecialAction = ManiaAction.Special1, - NormalActionStart = ManiaAction.Key1 - }.GenerateKeyBindingsFor(keys, out var nextNormal); - - var stage2Bindings = new VariantMappingGenerator - { - LeftKeys = new[] - { - InputKey.Number7, - InputKey.Number8, - InputKey.Number9, - InputKey.Number0 - }, - RightKeys = new[] - { - InputKey.O, - InputKey.P, - InputKey.BracketLeft, - InputKey.BracketRight - }, - SpecialKey = InputKey.BackSlash, - SpecialAction = ManiaAction.Special2, - NormalActionStart = nextNormal - }.GenerateKeyBindingsFor(keys, out _); - - return stage1Bindings.Concat(stage2Bindings); + return new DualStageVariantGenerator(getDualStageKeyCount(variant)).GenerateMappings(); } - return new KeyBinding[0]; + return Array.Empty(); } public override string GetVariantName(int variant) @@ -302,58 +319,55 @@ namespace osu.Game.Rulesets.Mania return (PlayfieldType)Enum.GetValues(typeof(PlayfieldType)).Cast().OrderByDescending(i => i).First(v => variant >= v); } - private class VariantMappingGenerator + protected override IEnumerable GetValidHitResults() { - /// - /// All the s available to the left hand. - /// - public InputKey[] LeftKeys; - - /// - /// All the s available to the right hand. - /// - public InputKey[] RightKeys; - - /// - /// The for the special key. - /// - public InputKey SpecialKey; - - /// - /// The at which the normal columns should begin. - /// - public ManiaAction NormalActionStart; - - /// - /// The for the special column. - /// - public ManiaAction SpecialAction; - - /// - /// Generates a list of s for a specific number of columns. - /// - /// The number of columns that need to be bound. - /// The next to use for normal columns. - /// The keybindings. - public IEnumerable GenerateKeyBindingsFor(int columns, out ManiaAction nextNormalAction) + return new[] { - ManiaAction currentNormalAction = NormalActionStart; + HitResult.Perfect, + HitResult.Great, + HitResult.Good, + HitResult.Ok, + HitResult.Meh, - var bindings = new List(); - - for (int i = LeftKeys.Length - columns / 2; i < LeftKeys.Length; i++) - bindings.Add(new KeyBinding(LeftKeys[i], currentNormalAction++)); - - if (columns % 2 == 1) - bindings.Add(new KeyBinding(SpecialKey, SpecialAction)); - - for (int i = 0; i < columns / 2; i++) - bindings.Add(new KeyBinding(RightKeys[i], currentNormalAction++)); - - nextNormalAction = currentNormalAction; - return bindings; - } + HitResult.LargeTickHit, + }; } + + public override string GetDisplayNameForHitResult(HitResult result) + { + switch (result) + { + case HitResult.LargeTickHit: + return "hold tick"; + } + + return base.GetDisplayNameForHitResult(result); + } + + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] + { + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(score.HitEvents) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + }, + new StatisticRow + { + Columns = new[] + { + new StatisticItem(string.Empty, new SimpleStatisticTable(3, new SimpleStatisticItem[] + { + new UnstableRate(score.HitEvents) + })) + } + } + }; } public enum PlayfieldType diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index 2ebfd0cfc1..de77af8306 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -29,12 +29,13 @@ namespace osu.Game.Rulesets.Mania new SettingsEnumDropdown { LabelText = "Scrolling direction", - Bindable = config.GetBindable(ManiaRulesetSetting.ScrollDirection) + Current = config.GetBindable(ManiaRulesetSetting.ScrollDirection) }, new SettingsSlider { LabelText = "Scroll speed", - Bindable = config.GetBindable(ManiaRulesetSetting.ScrollTime) + Current = config.GetBindable(ManiaRulesetSetting.ScrollTime), + KeyboardStep = 5 }, }; } diff --git a/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs b/osu.Game.Rulesets.Mania/ManiaSkinComponent.cs new file mode 100644 index 0000000000..f078345fc1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/ManiaSkinComponent.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.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Skinning; + +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 . + /// + public readonly StageDefinition? StageDefinition; + + /// + /// 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) + : base(component) + { + TargetColumn = targetColumn; + StageDefinition = stageDefinition; + } + + protected override string RulesetPrefix => ManiaRuleset.SHORT_NAME; + + protected override string ComponentName => Component.ToString().ToLower(); + } + + public enum ManiaSkinComponents + { + ColumnBackground, + HitTarget, + KeyArea, + Note, + HoldNoteHead, + HoldNoteTail, + HoldNoteBody, + HitExplosion, + StageBackground, + StageForeground, + } +} diff --git a/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs b/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs new file mode 100644 index 0000000000..0f4829028f --- /dev/null +++ b/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.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; +using System.Collections.Generic; +using System.Diagnostics.Contracts; + +namespace osu.Game.Rulesets.Mania.MathUtils +{ + /// + /// Provides access to .NET4.0 unstable sorting methods. + /// + /// + /// Source: https://referencesource.microsoft.com/#mscorlib/system/collections/generic/arraysorthelper.cs + /// Copyright (c) Microsoft Corporation. All rights reserved. + /// + internal static class LegacySortHelper + { + private const int quick_sort_depth_threshold = 32; + + public static void Sort(T[] keys, IComparer comparer) + { + if (keys == null) + throw new ArgumentNullException(nameof(keys)); + + if (keys.Length == 0) + return; + + comparer ??= Comparer.Default; + depthLimitedQuickSort(keys, 0, keys.Length - 1, comparer, quick_sort_depth_threshold); + } + + private static void depthLimitedQuickSort(T[] keys, int left, int right, IComparer comparer, int depthLimit) + { + do + { + if (depthLimit == 0) + { + heapsort(keys, left, right, comparer); + return; + } + + int i = left; + int j = right; + + // pre-sort the low, middle (pivot), and high values in place. + // this improves performance in the face of already sorted data, or + // data that is made up of multiple sorted runs appended together. + int middle = i + ((j - i) >> 1); + swapIfGreater(keys, comparer, i, middle); // swap the low with the mid point + swapIfGreater(keys, comparer, i, j); // swap the low with the high + swapIfGreater(keys, comparer, middle, j); // swap the middle with the high + + T x = keys[middle]; + + do + { + while (comparer.Compare(keys[i], x) < 0) i++; + while (comparer.Compare(x, keys[j]) < 0) j--; + Contract.Assert(i >= left && j <= right, "(i>=left && j<=right) Sort failed - Is your IComparer bogus?"); + if (i > j) break; + + if (i < j) + { + T key = keys[i]; + keys[i] = keys[j]; + keys[j] = key; + } + + i++; + j--; + } while (i <= j); + + // The next iteration of the while loop is to "recursively" sort the larger half of the array and the + // following calls recrusively sort the smaller half. So we subtrack one from depthLimit here so + // both sorts see the new value. + depthLimit--; + + if (j - left <= right - i) + { + if (left < j) depthLimitedQuickSort(keys, left, j, comparer, depthLimit); + left = i; + } + else + { + if (i < right) depthLimitedQuickSort(keys, i, right, comparer, depthLimit); + right = j; + } + } while (left < right); + } + + private static void heapsort(T[] keys, int lo, int hi, IComparer comparer) + { + Contract.Requires(keys != null); + Contract.Requires(comparer != null); + Contract.Requires(lo >= 0); + Contract.Requires(hi > lo); + Contract.Requires(hi < keys.Length); + + int n = hi - lo + 1; + + for (int i = n / 2; i >= 1; i = i - 1) + { + downHeap(keys, i, n, lo, comparer); + } + + for (int i = n; i > 1; i = i - 1) + { + swap(keys, lo, lo + i - 1); + downHeap(keys, 1, i - 1, lo, comparer); + } + } + + private static void downHeap(T[] keys, int i, int n, int lo, IComparer comparer) + { + Contract.Requires(keys != null); + Contract.Requires(comparer != null); + Contract.Requires(lo >= 0); + Contract.Requires(lo < keys.Length); + + T d = keys[lo + i - 1]; + + while (i <= n / 2) + { + var child = 2 * i; + + if (child < n && comparer.Compare(keys[lo + child - 1], keys[lo + child]) < 0) + { + child++; + } + + if (!(comparer.Compare(d, keys[lo + child - 1]) < 0)) + break; + + keys[lo + i - 1] = keys[lo + child - 1]; + i = child; + } + + keys[lo + i - 1] = d; + } + + private static void swap(T[] a, int i, int j) + { + if (i != j) + { + T t = a[i]; + a[i] = a[j]; + a[j] = t; + } + } + + private static void swapIfGreater(T[] keys, IComparer comparer, int a, int b) + { + if (a != b) + { + if (comparer.Compare(keys[a], keys[b]) > 0) + { + T key = keys[a]; + keys[a] = keys[b]; + keys[b] = key; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs index 13fdd74113..8fd5950dfb 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs @@ -39,6 +39,7 @@ namespace osu.Game.Rulesets.Mania.Mods typeof(ManiaModKey7), typeof(ManiaModKey8), typeof(ManiaModKey9), + typeof(ManiaModKey10), }.Except(new[] { GetType() }).ToArray(); } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.cs new file mode 100644 index 0000000000..02c1fc1b79 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCinema.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.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mania.Replays; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModCinema : ModCinema + { + public override Score CreateReplayScore(IBeatmap beatmap) => new Score + { + ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus!" } }, + Replay = new ManiaAutoGenerator((ManiaBeatmap)beatmap).Generate(), + }; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIMod.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.cs similarity index 50% rename from osu.Game/Online/API/Requests/Responses/APIMod.cs rename to osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.cs index b9da4f49ee..0817f8f9fc 100644 --- a/osu.Game/Online/API/Requests/Responses/APIMod.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDifficultyAdjust.cs @@ -3,12 +3,9 @@ using osu.Game.Rulesets.Mods; -namespace osu.Game.Online.API.Requests.Responses +namespace osu.Game.Rulesets.Mania.Mods { - public class APIMod : IMod + public class ManiaModDifficultyAdjust : ModDifficultyAdjust { - public string Acronym { get; set; } - - public bool Equals(IMod other) => Acronym == other?.Acronym; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index 39185e6a57..cbdcd49c5b 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -1,23 +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; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModFadeIn : Mod + public class ManiaModFadeIn : ManiaModHidden { public override string Name => "Fade In"; public override string Acronym => "FI"; - public override IconUsage Icon => OsuIcon.ModHidden; - public override ModType Type => ModType.DifficultyIncrease; + public override IconUsage? Icon => OsuIcon.ModHidden; public override string Description => @"Keys appear out of nowhere!"; - public override double ScoreMultiplier => 1; - public override bool Ranked => true; - public override Type[] IncompatibleMods => new[] { typeof(ModFlashlight) }; + + protected override CoverExpandDirection ExpandDirection => CoverExpandDirection.AlongScroll; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs index 6893e1e73b..86a00271e9 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFlashlight.cs @@ -3,8 +3,8 @@ using System; using osu.Framework.Bindables; -using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Layout; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; using osuTK; @@ -22,21 +22,13 @@ namespace osu.Game.Rulesets.Mania.Mods private class ManiaFlashlight : Flashlight { - private readonly Cached flashlightProperties = new Cached(); + private readonly LayoutValue flashlightProperties = new LayoutValue(Invalidation.DrawSize); public ManiaFlashlight() { FlashlightSize = new Vector2(0, default_flashlight_size); - } - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.DrawSize) > 0) - { - flashlightProperties.Invalidate(); - } - - return base.Invalidate(invalidation, source, shallPropagate); + AddLayout(flashlightProperties); } protected override void Update() diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs index 66b90984b4..4bdb15526f 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs @@ -2,15 +2,44 @@ // 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.UI; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModHidden : ModHidden + public class ManiaModHidden : ModHidden, IApplicableToDrawableRuleset { 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 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; + })); + } + } } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs new file mode 100644 index 0000000000..1ea45c295c --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.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 System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics.Sprites; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModInvert : Mod, IApplicableAfterBeatmapConversion + { + public override string Name => "Invert"; + + public override string Acronym => "IN"; + public override double ScoreMultiplier => 1; + + public override string Description => "Hold the keys. To the beat."; + + public override IconUsage? Icon => FontAwesome.Solid.YinYang; + + public override ModType Type => ModType.Conversion; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + var newObjects = new List(); + + foreach (var column in maniaBeatmap.HitObjects.GroupBy(h => h.Column)) + { + var newColumnObjects = new List(); + + var locations = column.OfType().Select(n => (startTime: n.StartTime, samples: n.Samples)) + .Concat(column.OfType().SelectMany(h => new[] + { + (startTime: h.StartTime, samples: h.GetNodeSamples(0)), + (startTime: h.EndTime, samples: h.GetNodeSamples(1)) + })) + .OrderBy(h => h.startTime).ToList(); + + for (int i = 0; i < locations.Count - 1; i++) + { + // Full duration of the hold note. + double duration = locations[i + 1].startTime - locations[i].startTime; + + // Beat length at the end of the hold note. + double beatLength = beatmap.ControlPointInfo.TimingPointAt(locations[i + 1].startTime).BeatLength; + + // Decrease the duration by at most a 1/4 beat to ensure there's no instantaneous notes. + duration = Math.Max(duration / 2, duration - beatLength / 4); + + newColumnObjects.Add(new HoldNote + { + Column = column.Key, + StartTime = locations[i].startTime, + Duration = duration, + NodeSamples = new List> { locations[i].samples, Array.Empty() } + }); + } + + newObjects.AddRange(newColumnObjects); + } + + maniaBeatmap.HitObjects = newObjects.OrderBy(h => h.StartTime).ToList(); + + // No breaks + maniaBeatmap.Breaks.Clear(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs new file mode 100644 index 0000000000..684370fc3d --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.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. + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModKey10 : ManiaKeyMod + { + public override int KeyCount => 10; + public override string Name => "Ten Keys"; + public override string Acronym => "10K"; + public override string Description => @"Play with ten keys."; + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs index 17f4098420..485595cea9 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs @@ -10,7 +10,7 @@ using osu.Game.Rulesets.Mania.Beatmaps; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModMirror : Mod, IApplicableToBeatmap + public class ManiaModMirror : Mod, IApplicableToBeatmap { public override string Name => "Mirror"; public override string Acronym => "MR"; @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override double ScoreMultiplier => 1; public override bool Ranked => true; - public void ApplyToBeatmap(Beatmap beatmap) + public void ApplyToBeatmap(IBeatmap beatmap) { var availableColumns = ((ManiaBeatmap)beatmap).TotalColumns; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs index 2d94fb6af5..4cc712060c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModNightcore.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 osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModNightcore : ModNightcore + public class ManiaModNightcore : ModNightcore { public override double ScoreMultiplier => 1; } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs index ba16140644..699c58c373 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs @@ -3,26 +3,19 @@ using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics.Sprites; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModRandom : Mod, IApplicableToBeatmap + public class ManiaModRandom : ModRandom, IApplicableToBeatmap { - public override string Name => "Random"; - public override string Acronym => "RD"; - public override ModType Type => ModType.Conversion; - public override IconUsage Icon => OsuIcon.Dice; public override string Description => @"Shuffle around the keys!"; - public override double ScoreMultiplier => 1; - public void ApplyToBeatmap(Beatmap beatmap) + public void ApplyToBeatmap(IBeatmap beatmap) { var availableColumns = ((ManiaBeatmap)beatmap).TotalColumns; var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(item => RNG.Next()).ToList(); diff --git a/osu.Game.Rulesets.Mania/Objects/BarLine.cs b/osu.Game.Rulesets.Mania/Objects/BarLine.cs index 4c644a8f09..09a746042b 100644 --- a/osu.Game.Rulesets.Mania/Objects/BarLine.cs +++ b/osu.Game.Rulesets.Mania/Objects/BarLine.cs @@ -1,21 +1,15 @@ -// 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.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Mania.Objects { - public class BarLine : ManiaHitObject + public class BarLine : ManiaHitObject, IBarLine { - /// - /// The control point which this bar line is part of. - /// - public TimingControlPoint ControlPoint; + public bool Major { get; set; } - /// - /// The index of the beat which this bar line represents within the control point. - /// This is a "major" bar line if % == 0. - /// - public int BeatIndex; + public override Judgement CreateJudgement() => new IgnoreJudgement(); } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs index 9c3197504f..074cbf6bd6 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableBarLine.cs @@ -1,10 +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 osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Game.Rulesets.Objects.Drawables; +using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Objects.Drawables @@ -40,9 +39,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Colour = new Color4(255, 204, 33, 255), }); - bool isMajor = barLine.BeatIndex % (int)barLine.ControlPoint.TimeSignature == 0; - - if (isMajor) + if (barLine.Major) { AddInternal(new EquilateralTriangle { @@ -65,12 +62,14 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables }); } - if (!isMajor && barLine.BeatIndex % 2 == 1) + if (!barLine.Major) Alpha = 0.2f; } - protected override void UpdateState(ArmedState state) + protected override void UpdateInitialTransforms() { } + + protected override void UpdateStartTimeStateTransforms() => this.FadeOut(150); } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index 952c6e128e..d9d740c145 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -1,15 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; +using System; using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Objects.Drawables { @@ -20,228 +22,283 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { public override bool DisplayResult => false; - public readonly DrawableNote Head; - public readonly DrawableNote Tail; + public IBindable IsHitting => isHitting; - private readonly BodyPiece bodyPiece; + private readonly Bindable isHitting = new Bindable(); + + public DrawableHoldNoteHead Head => headContainer.Child; + public DrawableHoldNoteTail Tail => tailContainer.Child; + + private readonly Container headContainer; + private readonly Container tailContainer; + private readonly 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; + + /// + /// 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 readonly SkinnableDrawable bodyPiece; /// /// Time at which the user started holding this hold note. Null if the user is not holding this hold note. /// - private double? holdStartTime; + public double? HoldStartTime { get; private set; } /// /// Whether the hold note has been released too early and shouldn't give full score for the release. /// - private bool hasBroken; + public bool HasBroken { get; private set; } + + /// + /// Whether the hold note has been released potentially without having caused a break. + /// + private double? releaseTime; public DrawableHoldNote(HoldNote hitObject) : base(hitObject) { - Container tickContainer; RelativeSizeAxes = Axes.X; + Container maskedContents; + AddRangeInternal(new Drawable[] { - bodyPiece = new BodyPiece - { - RelativeSizeAxes = Axes.X, - }, - tickContainer = new Container + sizingContainer = new Container { RelativeSizeAxes = Axes.Both, - ChildrenEnumerable = HitObject.NestedHitObjects.OfType().Select(tick => new DrawableHoldNoteTick(tick) + Children = new Drawable[] { - HoldStartTime = () => holdStartTime - }) + maskingContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Child = maskedContents = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + } + }, + headContainer = new Container { RelativeSizeAxes = Axes.Both } + } }, - Head = new DrawableHeadNote(this) + bodyPiece = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HoldNoteBody, hitObject.Column), _ => new DefaultBodyPiece { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre + RelativeSizeAxes = Axes.Both, + }) + { + RelativeSizeAxes = Axes.X }, - Tail = new DrawableTailNote(this) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - } + tickContainer = new Container { RelativeSizeAxes = Axes.Both }, + tailContainer = new Container { RelativeSizeAxes = Axes.Both }, }); - foreach (var tick in tickContainer) - AddNested(tick); - - AddNested(Head); - AddNested(Tail); - - AccentColour.BindValueChanged(colour => + maskedContents.AddRange(new[] { - bodyPiece.AccentColour = colour.NewValue; - Head.AccentColour.Value = colour.NewValue; - Tail.AccentColour.Value = colour.NewValue; - tickContainer.ForEach(t => t.AccentColour.Value = colour.NewValue); - }, true); + bodyPiece.CreateProxy(), + tickContainer.CreateProxy(), + tailContainer.CreateProxy(), + }); + } + + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) + { + case DrawableHoldNoteHead head: + headContainer.Child = head; + break; + + case DrawableHoldNoteTail tail: + tailContainer.Child = tail; + break; + + case DrawableHoldNoteTick tick: + tickContainer.Add(tick); + break; + } + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + headContainer.Clear(); + tailContainer.Clear(); + tickContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case TailNote _: + return new DrawableHoldNoteTail(this) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AccentColour = { BindTarget = AccentColour } + }; + + case Note _: + return new DrawableHoldNoteHead(this) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AccentColour = { BindTarget = AccentColour } + }; + + case HoldNoteTick tick: + return new DrawableHoldNoteTick(tick) + { + HoldStartTime = () => HoldStartTime, + AccentColour = { BindTarget = AccentColour } + }; + } + + return base.CreateNestedHitObject(hitObject); } protected override void OnDirectionChanged(ValueChangedEvent e) { base.OnDirectionChanged(e); - bodyPiece.Anchor = bodyPiece.Origin = e.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; + if (e.NewValue == ScrollingDirection.Up) + { + bodyPiece.Anchor = bodyPiece.Origin = Anchor.TopLeft; + sizingContainer.Anchor = sizingContainer.Origin = Anchor.BottomLeft; + } + else + { + bodyPiece.Anchor = bodyPiece.Origin = Anchor.BottomLeft; + sizingContainer.Anchor = sizingContainer.Origin = Anchor.TopLeft; + } } - protected override void CheckForResult(bool userTriggered, double timeOffset) + public override void PlaySamples() { - if (Tail.AllJudged) - ApplyResult(r => r.Type = HitResult.Perfect); + // Samples are played by the head/tail notes. + } + + public override void OnKilled() + { + base.OnKilled(); + (bodyPiece.Drawable as IHoldNoteBody)?.Recycle(); } protected override void Update() { base.Update(); - // Make the body piece not lie under the head note + if (Time.Current < releaseTime) + releaseTime = null; + + // Pad the full size container so its contents (i.e. the masking container) reach under the tail. + // This is required for the tail to not be masked away, since it lies outside the bounds of the hold note. + sizingContainer.Padding = new MarginPadding + { + Top = Direction.Value == ScrollingDirection.Down ? -Tail.Height : 0, + Bottom = Direction.Value == ScrollingDirection.Up ? -Tail.Height : 0, + }; + + // Pad the masking container to the starting position of the body piece (half-way under the head). + // This is required to make the body start getting masked immediately as soon as the note is held. + maskingContainer.Padding = new MarginPadding + { + Top = Direction.Value == ScrollingDirection.Up ? Head.Height / 2 : 0, + Bottom = Direction.Value == ScrollingDirection.Down ? Head.Height / 2 : 0, + }; + + // Position and resize the body to lie half-way under the head and the tail notes. bodyPiece.Y = (Direction.Value == ScrollingDirection.Up ? 1 : -1) * Head.Height / 2; bodyPiece.Height = DrawHeight - Head.Height / 2 + Tail.Height / 2; + + // 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) + { + // 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); + sizingContainer.Height = Math.Clamp(1 - yOffset / DrawHeight, 0, 1); + } } - protected void BeginHold() + protected override void CheckForResult(bool userTriggered, double timeOffset) { - holdStartTime = Time.Current; - bodyPiece.Hitting = true; - } + if (Tail.AllJudged) + { + ApplyResult(r => r.Type = r.Judgement.MaxResult); + endHold(); + } - protected void EndHold() - { - holdStartTime = null; - bodyPiece.Hitting = false; + if (Tail.Judged && !Tail.IsHit) + HasBroken = true; } public bool OnPressed(ManiaAction action) { - // Make sure the action happened within the body of the hold note - if (Time.Current < HitObject.StartTime || Time.Current > HitObject.EndTime) + if (AllJudged) return false; if (action != Action.Value) return false; - // The user has pressed during the body of the hold note, after the head note and its hit windows have passed - // and within the limited range of the above if-statement. This state will be managed by the head note if the - // user has pressed during the hit windows of the head note. - BeginHold(); + if (CheckHittable?.Invoke(this, Time.Current) == false) + return false; + + // The tail has a lenience applied to it which is factored into the miss window (i.e. the miss judgement will be delayed). + // But the hold cannot ever be started within the late-lenience window, so we should skip trying to begin the hold during that time. + // Note: Unlike below, we use the tail's start time to determine the time offset. + if (Time.Current > Tail.HitObject.StartTime && !Tail.HitObject.HitWindows.CanBeHit(Time.Current - Tail.HitObject.StartTime)) + return false; + + beginHoldAt(Time.Current - Head.HitObject.StartTime); + Head.UpdateResult(); + return true; } - public bool OnReleased(ManiaAction action) + private void beginHoldAt(double timeOffset) { - // Make sure that the user started holding the key during the hold note - if (!holdStartTime.HasValue) - return false; + if (timeOffset < -Head.HitObject.HitWindows.WindowFor(HitResult.Miss)) + return; + + HoldStartTime = Time.Current; + isHitting.Value = true; + } + + public void OnReleased(ManiaAction action) + { + if (AllJudged) + return; if (action != Action.Value) - return false; + return; - EndHold(); + // Make sure a hold was started + if (HoldStartTime == null) + return; + + Tail.UpdateResult(); + endHold(); // If the key has been released too early, the user should not receive full score for the release if (!Tail.IsHit) - hasBroken = true; + HasBroken = true; - return true; + releaseTime = Time.Current; } - /// - /// The head note of a hold. - /// - private class DrawableHeadNote : DrawableNote + private void endHold() { - private readonly DrawableHoldNote holdNote; - - public DrawableHeadNote(DrawableHoldNote holdNote) - : base(holdNote.HitObject.Head) - { - this.holdNote = holdNote; - } - - public override bool OnPressed(ManiaAction action) - { - if (!base.OnPressed(action)) - return false; - - // If the key has been released too early, the user should not receive full score for the release - if (Result.Type == HitResult.Miss) - holdNote.hasBroken = true; - - // The head note also handles early hits before the body, but we want accurate early hits to count as the body being held - // The body doesn't handle these early early hits, so we have to explicitly set the holding state here - holdNote.BeginHold(); - - return true; - } - } - - /// - /// The tail note of a hold. - /// - private class DrawableTailNote : DrawableNote - { - /// - /// Lenience of release hit windows. This is to make cases where the hold note release - /// is timed alongside presses of other hit objects less awkward. - /// Todo: This shouldn't exist for non-LegacyBeatmapDecoder beatmaps - /// - private const double release_window_lenience = 1.5; - - private readonly DrawableHoldNote holdNote; - - public DrawableTailNote(DrawableHoldNote holdNote) - : base(holdNote.HitObject.Tail) - { - this.holdNote = holdNote; - } - - protected override void CheckForResult(bool userTriggered, double timeOffset) - { - // Factor in the release lenience - timeOffset /= release_window_lenience; - - if (!userTriggered) - { - if (!HitObject.HitWindows.CanBeHit(timeOffset)) - ApplyResult(r => r.Type = HitResult.Miss); - - return; - } - - var result = HitObject.HitWindows.ResultFor(timeOffset); - if (result == HitResult.None) - return; - - ApplyResult(r => - { - if (holdNote.hasBroken && (result == HitResult.Perfect || result == HitResult.Perfect)) - result = HitResult.Good; - - r.Type = result; - }); - } - - public override bool OnPressed(ManiaAction action) => false; // Tail doesn't handle key down - - public override bool OnReleased(ManiaAction action) - { - // Make sure that the user started holding the key during the hold note - if (!holdNote.holdStartTime.HasValue) - return false; - - if (action != Action.Value) - return false; - - UpdateResult(true); - - // Handled by the hold note, which will set holding = false - return false; - } + HoldStartTime = null; + isHitting.Value = false; } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.cs new file mode 100644 index 0000000000..75dcf0e55e --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteHead.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. + +namespace osu.Game.Rulesets.Mania.Objects.Drawables +{ + /// + /// The head of a . + /// + public class DrawableHoldNoteHead : DrawableNote + { + protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteHead; + + public DrawableHoldNoteHead(DrawableHoldNote holdNote) + : base(holdNote.HitObject.Head) + { + } + + public void UpdateResult() => base.UpdateResult(true); + + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + // This hitobject should never expire, so this is just a safe maximum. + LifetimeEnd = LifetimeStart + 30000; + } + + 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 new file mode 100644 index 0000000000..c780c0836e --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTail.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Diagnostics; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Objects.Drawables +{ + /// + /// The tail of a . + /// + public class DrawableHoldNoteTail : DrawableNote + { + /// + /// Lenience of release hit windows. This is to make cases where the hold note release + /// is timed alongside presses of other hit objects less awkward. + /// Todo: This shouldn't exist for non-LegacyBeatmapDecoder beatmaps + /// + private const double release_window_lenience = 1.5; + + protected override ManiaSkinComponents Component => ManiaSkinComponents.HoldNoteTail; + + private readonly DrawableHoldNote holdNote; + + public DrawableHoldNoteTail(DrawableHoldNote holdNote) + : base(holdNote.HitObject.Tail) + { + this.holdNote = holdNote; + } + + public void UpdateResult() => base.UpdateResult(true); + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + Debug.Assert(HitObject.HitWindows != null); + + // Factor in the release lenience + timeOffset /= release_window_lenience; + + if (!userTriggered) + { + if (!HitObject.HitWindows.CanBeHit(timeOffset)) + ApplyResult(r => r.Type = r.Judgement.MinResult); + + return; + } + + var result = HitObject.HitWindows.ResultFor(timeOffset); + if (result == HitResult.None) + return; + + 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.HasBroken)) + result = HitResult.Meh; + + r.Type = result; + }); + } + + 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/DrawableHoldNoteTick.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs index 9b0322a6cd..f265419aa0 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNoteTick.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Objects.Drawables { @@ -17,6 +16,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// public class DrawableHoldNoteTick : DrawableManiaHitObject { + public override bool DisplayResult => false; + /// /// References the time at which the user started holding the hold note. /// @@ -73,9 +74,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables var startTime = HoldStartTime?.Invoke(); if (startTime == null || startTime > HitObject.StartTime) - ApplyResult(r => r.Type = HitResult.Miss); + ApplyResult(r => r.Type = r.Judgement.MinResult); else - ApplyResult(r => r.Type = HitResult.Perfect); + ApplyResult(r => r.Type = r.Judgement.MaxResult); } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index db6b53e76d..1550faee50 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -1,22 +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; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Objects.Drawables { public abstract class DrawableManiaHitObject : DrawableHitObject { - /// - /// Whether this should always remain alive. - /// - internal bool AlwaysAlive; - /// /// The which causes this to be hit. /// @@ -24,6 +21,26 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables protected readonly IBindable Direction = new Bindable(); + [Resolved(canBeNull: true)] + private ManiaPlayfield playfield { get; set; } + + protected override float SamplePlaybackPosition + { + get + { + if (playfield == null) + return base.SamplePlaybackPosition; + + return (float)HitObject.Column / playfield.TotalColumns; + } + } + + /// + /// Whether this can be hit, given a time value. + /// If non-null, judgements will be ignored whilst the function returns false. + /// + public Func CheckHittable; + protected DrawableManiaHitObject(ManiaHitObject hitObject) : base(hitObject) { @@ -39,12 +56,86 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables Direction.BindValueChanged(OnDirectionChanged, true); } - protected override bool ShouldBeAlive => AlwaysAlive || base.ShouldBeAlive; + private double computedLifetimeStart; + + public override double LifetimeStart + { + get => base.LifetimeStart; + set + { + computedLifetimeStart = value; + + if (!AlwaysAlive) + base.LifetimeStart = value; + } + } + + private double computedLifetimeEnd; + + public override double LifetimeEnd + { + get => base.LifetimeEnd; + set + { + computedLifetimeEnd = value; + + if (!AlwaysAlive) + base.LifetimeEnd = value; + } + } + + private bool alwaysAlive; + + /// + /// Whether this should always remain alive. + /// + internal bool AlwaysAlive + { + get => alwaysAlive; + set + { + if (alwaysAlive == value) + return; + + alwaysAlive = value; + + if (value) + { + // Set the base lifetimes directly, to avoid mangling the computed lifetimes + base.LifetimeStart = double.MinValue; + base.LifetimeEnd = double.MaxValue; + } + else + { + LifetimeStart = computedLifetimeStart; + LifetimeEnd = computedLifetimeEnd; + } + } + } protected virtual void OnDirectionChanged(ValueChangedEvent e) { Anchor = Origin = e.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; } + + protected override void UpdateHitStateTransforms(ArmedState state) + { + switch (state) + { + case ArmedState.Miss: + this.FadeOut(150, Easing.In); + break; + + case ArmedState.Hit: + this.FadeOut(); + break; + } + } + + /// + /// Causes this to get missed, disregarding all conditions in implementations of . + /// + public void MissForcefully() => ApplyResult(r => r.Type = r.Judgement.MinResult); } public abstract class DrawableManiaHitObject : DrawableManiaHitObject @@ -57,22 +148,5 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables { HitObject = hitObject; } - - protected override bool UseTransformStateManagement => false; - - protected override void UpdateState(ArmedState state) - { - // TODO: update to use new state management. - switch (state) - { - case ArmedState.Miss: - this.FadeOut(150, Easing.In).Expire(); - break; - - case ArmedState.Hit: - this.FadeOut(150, Easing.OutQuint).Expire(); - break; - } - } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs index dccff7f6ac..b3402d13e4 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableNote.cs @@ -1,14 +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 System.Diagnostics; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Effects; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.Objects.Drawables { @@ -17,7 +17,9 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables /// public class DrawableNote : DrawableManiaHitObject, IKeyBindingHandler { - private readonly NotePiece headPiece; + protected virtual ManiaSkinComponents Component => ManiaSkinComponents.Note; + + private readonly Drawable headPiece; public DrawableNote(Note hitObject) : base(hitObject) @@ -25,22 +27,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - CornerRadius = 5; - Masking = true; - - AddInternal(headPiece = new NotePiece()); - - AccentColour.BindValueChanged(colour => + AddInternal(headPiece = new SkinnableDrawable(new ManiaSkinComponent(Component, hitObject.Column), _ => new DefaultNotePiece()) { - headPiece.AccentColour = colour.NewValue; - - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = colour.NewValue.Lighten(1f).Opacity(0.6f), - Radius = 10, - }; - }, true); + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }); } protected override void OnDirectionChanged(ValueChangedEvent e) @@ -52,10 +43,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables protected override void CheckForResult(bool userTriggered, double timeOffset) { + Debug.Assert(HitObject.HitWindows != null); + if (!userTriggered) { if (!HitObject.HitWindows.CanBeHit(timeOffset)) - ApplyResult(r => r.Type = HitResult.Miss); + ApplyResult(r => r.Type = r.Judgement.MinResult); return; } @@ -71,9 +64,14 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables if (action != Action.Value) return false; + if (CheckHittable?.Invoke(this, Time.Current) == false) + return false; + return UpdateResult(true); } - public virtual bool OnReleased(ManiaAction action) => false; + public virtual void OnReleased(ManiaAction action) + { + } } } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/BodyPiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/BodyPiece.cs deleted file mode 100644 index 8102718edf..0000000000 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/BodyPiece.cs +++ /dev/null @@ -1,160 +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.Caching; -using osuTK.Graphics; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; - -namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces -{ - /// - /// Represents length-wise portion of a hold note. - /// - public class BodyPiece : Container, IHasAccentColour - { - private readonly Container subtractionLayer; - - protected readonly Drawable Background; - protected readonly BufferedContainer Foreground; - private readonly BufferedContainer subtractionContainer; - - public BodyPiece() - { - Blending = BlendingMode.Additive; - - Children = new[] - { - Background = new Box { RelativeSizeAxes = Axes.Both }, - Foreground = new BufferedContainer - { - Blending = BlendingMode.Additive, - RelativeSizeAxes = Axes.Both, - CacheDrawnFrameBuffer = true, - Children = new Drawable[] - { - new Box { RelativeSizeAxes = Axes.Both }, - subtractionContainer = new BufferedContainer - { - RelativeSizeAxes = Axes.Both, - // This is needed because we're blending with another object - BackgroundColour = Color4.White.Opacity(0), - CacheDrawnFrameBuffer = true, - // The 'hole' is achieved by subtracting the result of this container with the parent - Blending = new BlendingParameters { AlphaEquation = BlendingEquation.ReverseSubtract }, - Child = subtractionLayer = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - // Height computed in Update - Width = 1, - Masking = true, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - } - } - } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - updateAccentColour(); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - updateAccentColour(); - } - } - - public bool Hitting - { - get => hitting; - set - { - hitting = value; - updateAccentColour(); - } - } - - private Cached subtractionCache = new Cached(); - - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.DrawSize) > 0) - subtractionCache.Invalidate(); - - return base.Invalidate(invalidation, source, shallPropagate); - } - - protected override void Update() - { - base.Update(); - - if (!subtractionCache.IsValid) - { - subtractionLayer.Width = 5; - subtractionLayer.Height = Math.Max(0, DrawHeight - DrawWidth); - subtractionLayer.EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.White, - Type = EdgeEffectType.Glow, - Radius = DrawWidth - }; - - Foreground.ForceRedraw(); - subtractionContainer.ForceRedraw(); - - subtractionCache.Validate(); - } - } - - private bool hitting; - - private void updateAccentColour() - { - if (!IsLoaded) - return; - - Foreground.Colour = AccentColour.Opacity(0.5f); - Background.Colour = AccentColour.Opacity(0.7f); - - const float animation_length = 50; - - Foreground.ClearTransforms(false, nameof(Foreground.Colour)); - - if (hitting) - { - // wait for the next sync point - double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2); - using (Foreground.BeginDelayedSequence(synchronisedOffset)) - Foreground.FadeColour(AccentColour.Lighten(0.2f), animation_length).Then().FadeColour(Foreground.Colour, animation_length).Loop(); - } - - subtractionCache.Invalidate(); - } - } -} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultBodyPiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultBodyPiece.cs new file mode 100644 index 0000000000..9999983af5 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultBodyPiece.cs @@ -0,0 +1,168 @@ +// 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.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.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Layout; +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces +{ + /// + /// Represents length-wise portion of a hold note. + /// + public class DefaultBodyPiece : CompositeDrawable, IHoldNoteBody + { + protected readonly Bindable AccentColour = new Bindable(); + protected readonly IBindable IsHitting = new Bindable(); + + protected Drawable Background { get; private set; } + private Container foregroundContainer; + + public DefaultBodyPiece() + { + Blending = BlendingParameters.Additive; + } + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] DrawableHitObject drawableObject) + { + InternalChildren = new[] + { + Background = new Box { RelativeSizeAxes = Axes.Both }, + foregroundContainer = new Container { RelativeSizeAxes = Axes.Both } + }; + + if (drawableObject != null) + { + var holdNote = (DrawableHoldNote)drawableObject; + + AccentColour.BindTo(drawableObject.AccentColour); + IsHitting.BindTo(holdNote.IsHitting); + } + + AccentColour.BindValueChanged(onAccentChanged, true); + + Recycle(); + } + + public void Recycle() => foregroundContainer.Child = CreateForeground(); + + protected virtual Drawable CreateForeground() => new ForegroundPiece + { + AccentColour = { BindTarget = AccentColour }, + IsHitting = { BindTarget = IsHitting } + }; + + private void onAccentChanged(ValueChangedEvent accent) => Background.Colour = accent.NewValue.Opacity(0.7f); + + private class ForegroundPiece : CompositeDrawable + { + public readonly Bindable AccentColour = new Bindable(); + public readonly IBindable IsHitting = new Bindable(); + + private readonly LayoutValue subtractionCache = new LayoutValue(Invalidation.DrawSize); + + private BufferedContainer foregroundBuffer; + private BufferedContainer subtractionBuffer; + private Container subtractionLayer; + + public ForegroundPiece() + { + RelativeSizeAxes = Axes.Both; + + AddLayout(subtractionCache); + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = foregroundBuffer = new BufferedContainer + { + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + CacheDrawnFrameBuffer = true, + Children = new Drawable[] + { + new Box { RelativeSizeAxes = Axes.Both }, + subtractionBuffer = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + // This is needed because we're blending with another object + BackgroundColour = Color4.White.Opacity(0), + CacheDrawnFrameBuffer = true, + // The 'hole' is achieved by subtracting the result of this container with the parent + Blending = new BlendingParameters { AlphaEquation = BlendingEquation.ReverseSubtract }, + Child = subtractionLayer = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + // Height computed in Update + Width = 1, + Masking = true, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + } + } + }; + + AccentColour.BindValueChanged(onAccentChanged, true); + IsHitting.BindValueChanged(_ => onAccentChanged(new ValueChangedEvent(AccentColour.Value, AccentColour.Value)), true); + } + + private void onAccentChanged(ValueChangedEvent accent) + { + foregroundBuffer.Colour = accent.NewValue.Opacity(0.5f); + + const float animation_length = 50; + + foregroundBuffer.ClearTransforms(false, nameof(foregroundBuffer.Colour)); + + if (IsHitting.Value) + { + // wait for the next sync point + double synchronisedOffset = animation_length * 2 - Time.Current % (animation_length * 2); + using (foregroundBuffer.BeginDelayedSequence(synchronisedOffset)) + foregroundBuffer.FadeColour(accent.NewValue.Lighten(0.2f), animation_length).Then().FadeColour(foregroundBuffer.Colour, animation_length).Loop(); + } + + subtractionCache.Invalidate(); + } + + protected override void Update() + { + base.Update(); + + if (!subtractionCache.IsValid) + { + subtractionLayer.Width = 5; + subtractionLayer.Height = Math.Max(0, DrawHeight - DrawWidth); + subtractionLayer.EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.White, + Type = EdgeEffectType.Glow, + Radius = DrawWidth + }; + + foregroundBuffer.ForceRedraw(); + subtractionBuffer.ForceRedraw(); + + subtractionCache.Validate(); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultNotePiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultNotePiece.cs new file mode 100644 index 0000000000..29f5217fd8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/DefaultNotePiece.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 JetBrains.Annotations; +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.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces +{ + /// + /// Represents the static hit markers of notes. + /// + internal class DefaultNotePiece : CompositeDrawable + { + public const float NOTE_HEIGHT = 12; + + private readonly IBindable direction = new Bindable(); + private readonly IBindable accentColour = new Bindable(); + + private readonly Box colouredBox; + + public DefaultNotePiece() + { + RelativeSizeAxes = Axes.X; + Height = NOTE_HEIGHT; + + CornerRadius = 5; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both + }, + colouredBox = new Box + { + RelativeSizeAxes = Axes.X, + Height = NOTE_HEIGHT / 2, + Alpha = 0.1f + } + }; + } + + [BackgroundDependencyLoader(true)] + private void load([NotNull] IScrollingInfo scrollingInfo, [CanBeNull] DrawableHitObject drawableObject) + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + if (drawableObject != null) + { + accentColour.BindTo(drawableObject.AccentColour); + accentColour.BindValueChanged(onAccentChanged, true); + } + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + colouredBox.Anchor = colouredBox.Origin = direction.NewValue == ScrollingDirection.Up + ? Anchor.TopCentre + : Anchor.BottomCentre; + } + + private void onAccentChanged(ValueChangedEvent accent) + { + colouredBox.Colour = accent.NewValue.Lighten(0.9f); + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = accent.NewValue.Lighten(1f).Opacity(0.2f), + Radius = 10, + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/GlowPiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/GlowPiece.cs deleted file mode 100644 index 1d25a0c966..0000000000 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/GlowPiece.cs +++ /dev/null @@ -1,68 +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.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces -{ - public class GlowPiece : CompositeDrawable, IHasAccentColour - { - private const float glow_alpha = 0.7f; - private const float glow_radius = 5; - - public GlowPiece() - { - RelativeSizeAxes = Axes.Both; - Masking = true; - - InternalChild = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - updateGlow(); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - updateGlow(); - } - } - - private void updateGlow() - { - if (!IsLoaded) - return; - - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = AccentColour.Opacity(glow_alpha), - Radius = glow_radius, - Hollow = true - }; - } - } -} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/IHoldNoteBody.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/IHoldNoteBody.cs new file mode 100644 index 0000000000..ac3792c01d --- /dev/null +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/IHoldNoteBody.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. + +namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces +{ + /// + /// Interface for mania hold note bodies. + /// + public interface IHoldNoteBody + { + /// + /// Recycles the contents of this to free used resources. + /// + void Recycle(); + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/LaneGlowPiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/LaneGlowPiece.cs deleted file mode 100644 index 9e0307c5c2..0000000000 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/LaneGlowPiece.cs +++ /dev/null @@ -1,85 +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 osuTK.Graphics; -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.Graphics; - -namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces -{ - public class LaneGlowPiece : CompositeDrawable, IHasAccentColour - { - private const float total_height = 100; - private const float glow_height = 50; - private const float glow_alpha = 0.4f; - private const float edge_alpha = 0.3f; - - public LaneGlowPiece() - { - BypassAutoSizeAxes = Axes.Both; - RelativeSizeAxes = Axes.X; - Height = total_height; - - InternalChildren = new[] - { - new Container - { - Name = "Left edge", - RelativeSizeAxes = Axes.Y, - Width = 1, - Children = createGradient(edge_alpha) - }, - new Container - { - Name = "Right edge", - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - Width = 1, - Children = createGradient(edge_alpha) - }, - new Container - { - Name = "Glow", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = glow_height, - Children = createGradient(glow_alpha) - } - }; - } - - private Drawable[] createGradient(float alpha) => new Drawable[] - { - new Box - { - Name = "Top", - RelativeSizeAxes = Axes.Both, - Height = 0.5f, - Blending = BlendingMode.Additive, - Colour = ColourInfo.GradientVertical(Color4.Transparent, Color4.White.Opacity(alpha)) - }, - new Box - { - Name = "Bottom", - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - Height = 0.5f, - Blending = BlendingMode.Additive, - Colour = ColourInfo.GradientVertical(Color4.White.Opacity(alpha), Color4.Transparent) - } - }; - - public Color4 AccentColour - { - get => Colour; - set => Colour = value; - } - } -} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/NotePiece.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/NotePiece.cs deleted file mode 100644 index bb33693783..0000000000 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/Pieces/NotePiece.cs +++ /dev/null @@ -1,74 +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 osuTK.Graphics; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Rulesets.UI.Scrolling; - -namespace osu.Game.Rulesets.Mania.Objects.Drawables.Pieces -{ - /// - /// Represents the static hit markers of notes. - /// - internal class NotePiece : Container, IHasAccentColour - { - public const float NOTE_HEIGHT = 10; - private const float head_colour_height = 6; - - private readonly IBindable direction = new Bindable(); - - private readonly Box colouredBox; - - public NotePiece() - { - RelativeSizeAxes = Axes.X; - Height = NOTE_HEIGHT; - - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both - }, - colouredBox = new Box - { - RelativeSizeAxes = Axes.X, - Height = head_colour_height, - Alpha = 0.2f - } - }; - } - - [BackgroundDependencyLoader] - private void load(IScrollingInfo scrollingInfo) - { - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(dir => - { - colouredBox.Anchor = colouredBox.Origin = dir.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; - }, true); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - colouredBox.Colour = AccentColour.Lighten(0.9f); - } - } - } -} diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs index 5e9f46d9c7..6cc7ff92d3 100644 --- a/osu.Game.Rulesets.Mania/Objects/HoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/HoldNote.cs @@ -1,20 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Threading; +using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Mania.Judgements; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Objects { /// /// Represents a hit object which requires pressing, holding, and releasing a key. /// - public class HoldNote : ManiaHitObject, IHasEndTime + public class HoldNote : ManiaHitObject, IHasDuration { - public double EndTime => StartTime + Duration; + public double EndTime + { + get => StartTime + Duration; + set => Duration = value - StartTime; + } private double duration; @@ -24,7 +31,9 @@ namespace osu.Game.Rulesets.Mania.Objects set { duration = value; - Tail.StartTime = EndTime; + + if (Tail != null) + Tail.StartTime = EndTime; } } @@ -34,8 +43,12 @@ namespace osu.Game.Rulesets.Mania.Objects set { base.StartTime = value; - Head.StartTime = value; - Tail.StartTime = EndTime; + + if (Head != null) + Head.StartTime = value; + + if (Tail != null) + Tail.StartTime = EndTime; } } @@ -45,20 +58,26 @@ namespace osu.Game.Rulesets.Mania.Objects set { base.Column = value; - Head.Column = value; - Tail.Column = value; + + if (Head != null) + Head.Column = value; + + if (Tail != null) + Tail.Column = value; } } + public List> NodeSamples { get; set; } + /// /// The head note of the hold. /// - public readonly Note Head = new Note(); + public Note Head { get; private set; } /// /// The tail note of the hold. /// - public readonly TailNote Tail = new TailNote(); + public TailNote Tail { get; private set; } /// /// The time between ticks of this hold. @@ -73,23 +92,36 @@ namespace osu.Game.Rulesets.Mania.Objects tickSpacing = timingPoint.BeatLength / difficulty.SliderTickRate; } - protected override void CreateNestedHitObjects() + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - base.CreateNestedHitObjects(); + base.CreateNestedHitObjects(cancellationToken); - createTicks(); + createTicks(cancellationToken); - AddNested(Head); - AddNested(Tail); + AddNested(Head = new Note + { + StartTime = StartTime, + Column = Column, + Samples = GetNodeSamples(0), + }); + + AddNested(Tail = new TailNote + { + StartTime = EndTime, + Column = Column, + Samples = GetNodeSamples((NodeSamples?.Count - 1) ?? 1), + }); } - private void createTicks() + private void createTicks(CancellationToken cancellationToken) { if (tickSpacing == 0) return; for (double t = StartTime + tickSpacing; t <= EndTime - tickSpacing; t += tickSpacing) { + cancellationToken.ThrowIfCancellationRequested(); + AddNested(new HoldNoteTick { StartTime = t, @@ -98,6 +130,11 @@ namespace osu.Game.Rulesets.Mania.Objects } } - public override Judgement CreateJudgement() => new HoldNoteJudgement(); + public override Judgement CreateJudgement() => new IgnoreJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public IList GetNodeSamples(int nodeIndex) => + nodeIndex < NodeSamples?.Count ? NodeSamples[nodeIndex] : Samples; } } diff --git a/osu.Game.Rulesets.Mania/Objects/HoldNoteTick.cs b/osu.Game.Rulesets.Mania/Objects/HoldNoteTick.cs index c133ee73b1..ac6697a6dc 100644 --- a/osu.Game.Rulesets.Mania/Objects/HoldNoteTick.cs +++ b/osu.Game.Rulesets.Mania/Objects/HoldNoteTick.cs @@ -3,6 +3,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Judgements; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Objects { @@ -12,5 +13,7 @@ namespace osu.Game.Rulesets.Mania.Objects public class HoldNoteTick : ManiaHitObject { public override Judgement CreateJudgement() => new HoldNoteTickJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs index 70720a926b..27bf50493d 100644 --- a/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/ManiaHitObject.cs @@ -3,11 +3,14 @@ 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; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Objects { - public abstract class ManiaHitObject : HitObject, IHasColumn + public abstract class ManiaHitObject : HitObject, IHasColumn, IHasXPosition { public readonly Bindable ColumnBindable = new Bindable(); @@ -18,5 +21,11 @@ namespace osu.Game.Rulesets.Mania.Objects } protected override HitWindows CreateHitWindows() => new ManiaHitWindows(); + + #region LegacyBeatmapEncoder + + float IHasXPosition.X => Column; + + #endregion } } diff --git a/osu.Game.Rulesets.Mania/Objects/ManiaHitWindows.cs b/osu.Game.Rulesets.Mania/Objects/ManiaHitWindows.cs deleted file mode 100644 index 5f2ceab48b..0000000000 --- a/osu.Game.Rulesets.Mania/Objects/ManiaHitWindows.cs +++ /dev/null @@ -1,35 +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 osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Mania.Objects -{ - public class ManiaHitWindows : HitWindows - { - private static readonly IReadOnlyDictionary base_ranges = new Dictionary - { - { HitResult.Perfect, (44.8, 38.8, 27.8) }, - { HitResult.Great, (128, 98, 68) }, - { HitResult.Good, (194, 164, 134) }, - { HitResult.Ok, (254, 224, 194) }, - { HitResult.Meh, (302, 272, 242) }, - { HitResult.Miss, (376, 346, 316) }, - }; - - public override bool IsHitResultAllowed(HitResult result) => true; - - public override void SetDifficulty(double difficulty) - { - Perfect = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Perfect]); - Great = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Great]); - Good = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Good]); - Ok = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Ok]); - Meh = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Meh]); - Miss = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Miss]); - } - } -} diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs b/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs index e5669816fa..3ebbe5af8e 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaAutoGenerator.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Replays; using osu.Game.Rulesets.Mania.Beatmaps; -using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Replays; namespace osu.Game.Rulesets.Mania.Replays @@ -46,8 +46,8 @@ namespace osu.Game.Rulesets.Mania.Replays public override Replay Generate() { - // Todo: Realistically this shouldn't be needed, but the first frame is skipped with the way replays are currently handled - Replay.Frames.Add(new ManiaReplayFrame(-100000, 0)); + if (Beatmap.HitObjects.Count == 0) + return Replay; var pointGroups = generateActionPoints().GroupBy(a => a.Time).OrderBy(g => g.First().Time); @@ -69,6 +69,10 @@ 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())); } @@ -77,13 +81,37 @@ namespace osu.Game.Rulesets.Mania.Replays private IEnumerable generateActionPoints() { - foreach (var obj in Beatmap.HitObjects) + for (int i = 0; i < Beatmap.HitObjects.Count; i++) { - yield return new HitPoint { Time = obj.StartTime, Column = obj.Column }; - yield return new ReleasePoint { Time = ((obj as IHasEndTime)?.EndTime ?? obj.StartTime) + RELEASE_DELAY, Column = obj.Column }; + 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; + + yield return new HitPoint { Time = currentObject.StartTime, Column = currentObject.Column }; + + yield return new ReleasePoint { Time = endTime + calculatedDelay, Column = currentObject.Column }; } } + protected override HitObject GetNextObject(int currentIndex) + { + int desiredColumn = Beatmap.HitObjects[currentIndex].Column; + + for (int i = currentIndex + 1; i < Beatmap.HitObjects.Count; i++) + { + if (Beatmap.HitObjects[i].Column == desiredColumn) + return Beatmap.HitObjects[i]; + } + + return null; + } + private interface IActionPoint { double Time { get; set; } diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs b/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs index 899718b77e..aa0c148caf 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaFramedReplayInputHandler.cs @@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Mania.Replays protected override bool IsImportant(ManiaReplayFrame frame) => frame.Actions.Any(); - public override List GetPendingInputs() => new List { new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() } }; + public override void CollectPendingInputs(List inputs) + { + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); + } } } diff --git a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs index f7277d3669..dbab54d1d0 100644 --- a/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.cs +++ b/osu.Game.Rulesets.Mania/Replays/ManiaReplayFrame.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 osu.Game.Beatmaps; using osu.Game.Replays.Legacy; @@ -24,15 +25,9 @@ namespace osu.Game.Rulesets.Mania.Replays Actions.AddRange(actions); } - public void ConvertFrom(LegacyReplayFrame legacyFrame, IBeatmap beatmap) + public void FromLegacy(LegacyReplayFrame legacyFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) { - // We don't need to fully convert, just create the converter - var converter = new ManiaBeatmapConverter(beatmap); - - // NB: Via co-op mod, osu-stable can have two stages with floor(col/2) and ceil(col/2) columns. This will need special handling - // elsewhere in the game if we do choose to support the old co-op mod anyway. For now, assume that there is only one stage. - - var stage = new StageDefinition { Columns = converter.TargetColumns }; + var maniaBeatmap = (ManiaBeatmap)beatmap; var normalAction = ManiaAction.Key1; var specialAction = ManiaAction.Special1; @@ -42,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Replays while (activeColumns > 0) { - var isSpecial = stage.IsSpecialColumn(counter); + bool isSpecial = isColumnAtIndexSpecial(maniaBeatmap, counter); if ((activeColumns & 1) > 0) Actions.Add(isSpecial ? specialAction : normalAction); @@ -56,5 +51,94 @@ namespace osu.Game.Rulesets.Mania.Replays activeColumns >>= 1; } } + + public LegacyReplayFrame ToLegacy(IBeatmap beatmap) + { + var maniaBeatmap = (ManiaBeatmap)beatmap; + + int keys = 0; + + foreach (var action in Actions) + { + switch (action) + { + case ManiaAction.Special1: + keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 0); + break; + + case ManiaAction.Special2: + keys |= 1 << getSpecialColumnIndex(maniaBeatmap, 1); + break; + + default: + // the index in lazer, which doesn't include special keys. + int nonSpecialKeyIndex = action - ManiaAction.Key1; + + // the index inclusive of special keys. + int overallIndex = 0; + + // iterate to find the index including special keys. + for (; overallIndex < maniaBeatmap.TotalColumns; overallIndex++) + { + // skip over special columns. + if (isColumnAtIndexSpecial(maniaBeatmap, overallIndex)) + continue; + // found a non-special column to use. + if (nonSpecialKeyIndex == 0) + break; + // found a non-special column but not ours. + nonSpecialKeyIndex--; + } + + keys |= 1 << overallIndex; + break; + } + } + + return new LegacyReplayFrame(Time, keys, null, ReplayButtonState.None); + } + + /// + /// Find the overall index (across all stages) for a specified special key. + /// + /// The beatmap. + /// The special key offset (0 is S1). + /// The overall index for the special column. + private int getSpecialColumnIndex(ManiaBeatmap maniaBeatmap, int specialOffset) + { + for (int i = 0; i < maniaBeatmap.TotalColumns; i++) + { + if (isColumnAtIndexSpecial(maniaBeatmap, i)) + { + if (specialOffset == 0) + return i; + + specialOffset--; + } + } + + throw new ArgumentException("Special key index is too high.", nameof(specialOffset)); + } + + /// + /// Check whether the column at an overall index (across all stages) is a special column. + /// + /// The beatmap. + /// The overall index to check. + private bool isColumnAtIndexSpecial(ManiaBeatmap beatmap, int index) + { + foreach (var stage in beatmap.Stages) + { + if (index >= stage.Columns) + { + index -= stage.Columns; + continue; + } + + return stage.IsSpecialColumn(index); + } + + throw new ArgumentException("Column index is too high.", nameof(index)); + } } } diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json new file mode 100644 index 0000000000..6f1d45ad8c --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json @@ -0,0 +1,33 @@ +{ + "Mappings": [{ + "StartTime": 1000.0, + "Objects": [{ + "StartTime": 1000.0, + "EndTime": 2750.0, + "Column": 1, + "NodeSamples": [ + ["Gameplay/normal-hitnormal"], + ["Gameplay/soft-hitnormal"], + ["Gameplay/drum-hitnormal"] + ], + "Samples": ["Gameplay/-hitnormal"] + }, { + "StartTime": 1875.0, + "EndTime": 2750.0, + "Column": 0, + "NodeSamples": [ + ["Gameplay/soft-hitnormal"], + ["Gameplay/drum-hitnormal"] + ], + "Samples": ["Gameplay/-hitnormal"] + }] + }, { + "StartTime": 3750.0, + "Objects": [{ + "StartTime": 3750.0, + "EndTime": 3750.0, + "Column": 3, + "Samples": ["Gameplay/normal-hitnormal"] + }] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples.osu b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples.osu new file mode 100644 index 0000000000..fea1de6614 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples.osu @@ -0,0 +1,16 @@ +osu file format v14 + +[Difficulty] +HPDrainRate:5 +CircleSize:5 +OverallDifficulty:5 +ApproachRate:5 +SliderMultiplier:1.4 +SliderTickRate:1 + +[TimingPoints] +0,500,4,1,0,100,1,0 + +[HitObjects] +88,99,1000,6,0,L|306:259,2,245,0|0|0,1:0|2:0|3:0,0:0:0:0: +259,118,3750,1,0,1:0:0:0: diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json new file mode 100644 index 0000000000..fd0c0cad60 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples-expected-conversion.json @@ -0,0 +1,27 @@ +{ + "Mappings": [{ + "StartTime": 500.0, + "Objects": [{ + "StartTime": 500.0, + "EndTime": 1500.0, + "Column": 0, + "NodeSamples": [ + ["Gameplay/normal-hitnormal"], + [] + ], + "Samples": ["Gameplay/normal-hitnormal"] + }] + }, { + "StartTime": 2000.0, + "Objects": [{ + "StartTime": 2000.0, + "EndTime": 3000.0, + "Column": 2, + "NodeSamples": [ + ["Gameplay/drum-hitnormal"], + [] + ], + "Samples": ["Gameplay/drum-hitnormal"] + }] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples.osu b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples.osu new file mode 100644 index 0000000000..7c75b45e5f --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples.osu @@ -0,0 +1,19 @@ +osu file format v14 + +[General] +Mode: 3 + +[Difficulty] +HPDrainRate:5 +CircleSize:5 +OverallDifficulty:5 +ApproachRate:5 +SliderMultiplier:1.4 +SliderTickRate:1 + +[TimingPoints] +0,500,4,1,0,100,1,0 + +[HitObjects] +51,192,500,128,0,1500:1:0:0:0: +256,192,2000,128,0,3000:3:0:0:0: diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples-expected-conversion.json new file mode 100644 index 0000000000..e07bd3c47c --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples-expected-conversion.json @@ -0,0 +1,21 @@ +{ + "Mappings": [{ + "StartTime": 8470.0, + "Objects": [{ + "StartTime": 8470.0, + "EndTime": 8470.0, + "Column": 0, + "Samples": ["Gameplay/normal-hitnormal", "Gameplay/normal-hitclap"] + }, { + "StartTime": 8626.470587768974, + "EndTime": 8626.470587768974, + "Column": 1, + "Samples": ["Gameplay/normal-hitnormal"] + }, { + "StartTime": 8782.941175537948, + "EndTime": 8782.941175537948, + "Column": 2, + "Samples": ["Gameplay/normal-hitnormal", "Gameplay/normal-hitclap"] + }] + }] +} diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples.osu b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples.osu new file mode 100644 index 0000000000..08e90ce807 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/slider-convert-samples.osu @@ -0,0 +1,15 @@ +osu file format v14 + +[Difficulty] +HPDrainRate:6 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:9.5 +SliderMultiplier:2.00000000596047 +SliderTickRate:1 + +[TimingPoints] +0,312.941176470588,4,1,0,100,1,0 + +[HitObjects] +82,216,8470,6,0,P|52:161|99:113,2,100,8|0|8,1:0|1:0|1:0,0:0:0:0: diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/zero-length-slider-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/zero-length-slider-expected-conversion.json new file mode 100644 index 0000000000..229760cd1c --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/zero-length-slider-expected-conversion.json @@ -0,0 +1,14 @@ +{ + "Mappings": [{ + "RandomW": 3083084786, + "RandomX": 273326509, + "RandomY": 273553282, + "RandomZ": 2659838971, + "StartTime": 4836, + "Objects": [{ + "StartTime": 4836, + "EndTime": 4836, + "Column": 0 + }] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/zero-length-slider.osu b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/zero-length-slider.osu new file mode 100644 index 0000000000..9b8ac1f9db --- /dev/null +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/zero-length-slider.osu @@ -0,0 +1,20 @@ +osu file format v14 + +[General] +StackLeniency: 0.7 +Mode: 0 + +[Difficulty] +HPDrainRate:1 +CircleSize:4 +OverallDifficulty:1 +ApproachRate:9 +SliderMultiplier:2.5 +SliderTickRate:0.5 + +[TimingPoints] +34,431.654676258993,4,1,0,50,1,0 +4782,-66.6666666666667,4,1,0,20,0,0 + +[HitObjects] +15,199,4836,22,0,L,1,46.8750017881394 \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.cs new file mode 100644 index 0000000000..289f8a00ef --- /dev/null +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaHitWindows.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.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Scoring +{ + public class ManiaHitWindows : HitWindows + { + public override bool IsHitResultAllowed(HitResult result) + { + switch (result) + { + case HitResult.Perfect: + case HitResult.Great: + case HitResult.Good: + case HitResult.Ok: + case HitResult.Meh: + case HitResult.Miss: + return true; + } + + return false; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index 5caf08fb1e..71cc0bdf1f 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -1,90 +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 osu.Game.Beatmaps; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mania.Scoring { - internal class ManiaScoreProcessor : ScoreProcessor + internal class ManiaScoreProcessor : ScoreProcessor { - /// - /// The hit HP multiplier at OD = 0. - /// - private const double hp_multiplier_min = 0.75; + protected override double DefaultAccuracyPortion => 0.95; - /// - /// The hit HP multiplier at OD = 0. - /// - private const double hp_multiplier_mid = 0.85; - - /// - /// The hit HP multiplier at OD = 0. - /// - private const double hp_multiplier_max = 1; - - /// - /// The MISS HP multiplier at OD = 0. - /// - private const double hp_multiplier_miss_min = 0.5; - - /// - /// The MISS HP multiplier at OD = 5. - /// - private const double hp_multiplier_miss_mid = 0.75; - - /// - /// The MISS HP multiplier at OD = 10. - /// - private const double hp_multiplier_miss_max = 1; - - /// - /// The MISS HP multiplier. This is multiplied to the miss hp increase. - /// - private double hpMissMultiplier = 1; - - /// - /// The HIT HP multiplier. This is multiplied to hit hp increases. - /// - private double hpMultiplier = 1; - - public ManiaScoreProcessor(DrawableRuleset drawableRuleset) - : base(drawableRuleset) - { - } - - protected override void ApplyBeatmap(Beatmap beatmap) - { - base.ApplyBeatmap(beatmap); - - BeatmapDifficulty difficulty = beatmap.BeatmapInfo.BaseDifficulty; - hpMultiplier = BeatmapDifficulty.DifficultyRange(difficulty.DrainRate, hp_multiplier_min, hp_multiplier_mid, hp_multiplier_max); - hpMissMultiplier = BeatmapDifficulty.DifficultyRange(difficulty.DrainRate, hp_multiplier_miss_min, hp_multiplier_miss_mid, hp_multiplier_miss_max); - } - - protected override void SimulateAutoplay(Beatmap beatmap) - { - while (true) - { - base.SimulateAutoplay(beatmap); - - if (!HasFailed) - break; - - hpMultiplier *= 1.01; - hpMissMultiplier *= 0.98; - - Reset(false); - } - } - - protected override double HealthAdjustmentFactorFor(JudgementResult result) - => result.Type == HitResult.Miss ? hpMissMultiplier : hpMultiplier; - - public override HitWindows CreateHitWindows() => new ManiaHitWindows(); + protected override double DefaultComboPortion => 0.05; } } diff --git a/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.cs new file mode 100644 index 0000000000..2069329d9a --- /dev/null +++ b/osu.Game.Rulesets.Mania/SingleStageVariantGenerator.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 System.Collections.Generic; +using osu.Framework.Input.Bindings; + +namespace osu.Game.Rulesets.Mania +{ + public class SingleStageVariantGenerator + { + private readonly int variant; + private readonly InputKey[] leftKeys; + private readonly InputKey[] rightKeys; + + public SingleStageVariantGenerator(int variant) + { + this.variant = variant; + + // 10K is special because it expands towards the centre of the keyboard (V/N), rather than towards the edges of the keyboard. + if (variant == 10) + { + leftKeys = new[] { InputKey.A, InputKey.S, InputKey.D, InputKey.F, InputKey.V }; + rightKeys = new[] { InputKey.N, InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + } + else + { + leftKeys = new[] { InputKey.A, InputKey.S, InputKey.D, InputKey.F }; + rightKeys = new[] { InputKey.J, InputKey.K, InputKey.L, InputKey.Semicolon }; + } + } + + public IEnumerable GenerateMappings() => new VariantMappingGenerator + { + LeftKeys = leftKeys, + RightKeys = rightKeys, + SpecialKey = InputKey.Space, + SpecialAction = ManiaAction.Special1, + NormalActionStart = ManiaAction.Key1, + }.GenerateKeyBindingsFor(variant, out _); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/HitTargetInsetContainer.cs b/osu.Game.Rulesets.Mania/Skinning/HitTargetInsetContainer.cs new file mode 100644 index 0000000000..c8b05ed2f8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/HitTargetInsetContainer.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class HitTargetInsetContainer : Container + { + private readonly IBindable direction = new Bindable(); + + protected override Container Content => content; + private readonly Container content; + + private float hitPosition; + + public HitTargetInsetContainer() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = content = new Container { RelativeSizeAxes = Axes.Both }; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + hitPosition = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.HitPosition)?.Value ?? Stage.HIT_TARGET_POSITION; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + content.Padding = direction.NewValue == ScrollingDirection.Up + ? new MarginPadding { Top = hitPosition } + : new MarginPadding { Bottom = hitPosition }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs new file mode 100644 index 0000000000..c0f0fcb4af --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyBodyPiece.cs @@ -0,0 +1,168 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyBodyPiece : LegacyManiaColumnElement + { + private readonly IBindable direction = new Bindable(); + private readonly IBindable isHitting = new Bindable(); + + [CanBeNull] + private Drawable bodySprite; + + [CanBeNull] + private Drawable lightContainer; + + [CanBeNull] + private Drawable light; + + public LegacyBodyPiece() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo, DrawableHitObject drawableObject) + { + string imageName = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.HoldNoteBodyImage)?.Value + ?? $"mania-note{FallbackColumnIndex}L"; + + string lightImage = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.HoldNoteLightImage)?.Value + ?? "lightingL"; + + float lightScale = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.HoldNoteLightScale)?.Value + ?? 1; + + // Create a temporary animation to retrieve the number of frames, in an effort to calculate the intended frame length. + // This animation is discarded and re-queried with the appropriate frame length afterwards. + var tmp = skin.GetAnimation(lightImage, true, false); + double frameLength = 0; + if (tmp is IFramedAnimation tmpAnimation && tmpAnimation.FrameCount > 0) + frameLength = Math.Max(1000 / 60.0, 170.0 / tmpAnimation.FrameCount); + + light = skin.GetAnimation(lightImage, true, true, frameLength: frameLength).With(d => + { + if (d == null) + return; + + d.Origin = Anchor.Centre; + d.Blending = BlendingParameters.Additive; + d.Scale = new Vector2(lightScale); + }); + + if (light != null) + { + lightContainer = new HitTargetInsetContainer + { + Alpha = 0, + Child = light + }; + } + + bodySprite = skin.GetAnimation(imageName, WrapMode.ClampToEdge, WrapMode.ClampToEdge, true, true).With(d => + { + if (d == null) + return; + + if (d is TextureAnimation animation) + animation.IsPlaying = false; + + d.Anchor = Anchor.TopCentre; + d.RelativeSizeAxes = Axes.Both; + d.Size = Vector2.One; + d.FillMode = FillMode.Stretch; + // Todo: Wrap + }); + + if (bodySprite != null) + InternalChild = bodySprite; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + var holdNote = (DrawableHoldNote)drawableObject; + isHitting.BindTo(holdNote.IsHitting); + isHitting.BindValueChanged(onIsHittingChanged, true); + } + + private void onIsHittingChanged(ValueChangedEvent isHitting) + { + if (bodySprite is TextureAnimation bodyAnimation) + { + bodyAnimation.GotoFrame(0); + bodyAnimation.IsPlaying = isHitting.NewValue; + } + + if (lightContainer == null) + return; + + if (isHitting.NewValue) + { + // Clear the fade out and, more importantly, the removal. + lightContainer.ClearTransforms(); + + // Only add the container if the removal has taken place. + if (lightContainer.Parent == null) + Column.TopLevelContainer.Add(lightContainer); + + // The light must be seeked only after being loaded, otherwise a nullref occurs (https://github.com/ppy/osu-framework/issues/3847). + if (light is TextureAnimation lightAnimation) + lightAnimation.GotoFrame(0); + + lightContainer.FadeIn(80); + } + else + { + lightContainer.FadeOut(120) + .OnComplete(d => Column.TopLevelContainer.Remove(d)); + } + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + if (bodySprite != null) + { + bodySprite.Origin = Anchor.BottomCentre; + bodySprite.Scale = new Vector2(1, -1); + } + + if (light != null) + light.Anchor = Anchor.TopCentre; + } + else + { + if (bodySprite != null) + { + bodySprite.Origin = Anchor.TopCentre; + bodySprite.Scale = Vector2.One; + } + + if (light != null) + light.Anchor = Anchor.BottomCentre; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + lightContainer?.Expire(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyColumnBackground.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyColumnBackground.cs new file mode 100644 index 0000000000..3bf51b3073 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyColumnBackground.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyColumnBackground : LegacyManiaColumnElement, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + + private Container lightContainer; + private Sprite light; + + public LegacyColumnBackground() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + string lightImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.LightImage)?.Value + ?? "mania-stage-light"; + + float lightPosition = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.LightPosition)?.Value + ?? 0; + + Color4 lightColour = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.ColumnLightColour)?.Value + ?? Color4.White; + + InternalChildren = new[] + { + lightContainer = new Container + { + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = lightPosition }, + Child = light = new Sprite + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = LegacyColourCompatibility.DisallowZeroAlpha(lightColour), + Texture = skin.GetTexture(lightImage), + RelativeSizeAxes = Axes.X, + Width = 1, + Alpha = 0 + } + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + lightContainer.Anchor = Anchor.TopCentre; + lightContainer.Scale = new Vector2(1, -1); + } + else + { + lightContainer.Anchor = Anchor.BottomCentre; + lightContainer.Scale = Vector2.One; + } + } + + public bool OnPressed(ManiaAction action) + { + if (action == Column.Action.Value) + { + light.FadeIn(); + light.ScaleTo(Vector2.One); + } + + return false; + } + + public void OnReleased(ManiaAction action) + { + // Todo: Should be 400 * 100 / CurrentBPM + const double animation_length = 250; + + if (action == Column.Action.Value) + { + light.FadeTo(0, animation_length); + light.ScaleTo(new Vector2(1, 0), animation_length); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyHitExplosion.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyHitExplosion.cs new file mode 100644 index 0000000000..7c5d41efcf --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyHitExplosion.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 System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Judgements; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyHitExplosion : LegacyManiaColumnElement, IHitExplosion + { + private readonly IBindable direction = new Bindable(); + + private Drawable explosion; + + public LegacyHitExplosion() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + string imageName = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.ExplosionImage)?.Value + ?? "lightingN"; + + float explosionScale = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.ExplosionScale)?.Value + ?? 1; + + // Create a temporary animation to retrieve the number of frames, in an effort to calculate the intended frame length. + // This animation is discarded and re-queried with the appropriate frame length afterwards. + var tmp = skin.GetAnimation(imageName, true, false); + double frameLength = 0; + if (tmp is IFramedAnimation tmpAnimation && tmpAnimation.FrameCount > 0) + frameLength = Math.Max(1000 / 60.0, 170.0 / tmpAnimation.FrameCount); + + explosion = skin.GetAnimation(imageName, true, false, frameLength: frameLength).With(d => + { + if (d == null) + return; + + d.Origin = Anchor.Centre; + d.Blending = BlendingParameters.Additive; + d.Scale = new Vector2(explosionScale); + }); + + if (explosion != null) + InternalChild = explosion; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (explosion != null) + explosion.Anchor = direction.NewValue == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; + } + + public void Animate(JudgementResult result) + { + if (result.Judgement is HoldNoteTickJudgement) + return; + + (explosion as IFramedAnimation)?.GotoFrame(0); + + explosion?.FadeInFromZero(80) + .Then().FadeOut(120); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyHitTarget.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyHitTarget.cs new file mode 100644 index 0000000000..6eced571d2 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyHitTarget.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 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.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyHitTarget : CompositeDrawable + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + string targetImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.HitTargetImage)?.Value + ?? "mania-stage-hint"; + + bool showJudgementLine = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ShowJudgementLine)?.Value + ?? true; + + Color4 lineColour = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.JudgementLineColour)?.Value + ?? Color4.White; + + InternalChild = directionContainer = new Container + { + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Sprite + { + Texture = skin.GetTexture(targetImage), + Scale = new Vector2(1, 0.9f * 1.6025f), + RelativeSizeAxes = Axes.X, + Width = 1 + }, + new Box + { + Anchor = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + Height = 1, + Colour = LegacyColourCompatibility.DisallowZeroAlpha(lineColour), + Alpha = showJudgementLine ? 0.9f : 0 + } + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + directionContainer.Anchor = Anchor.TopLeft; + directionContainer.Scale = new Vector2(1, -1); + } + else + { + directionContainer.Anchor = Anchor.BottomLeft; + directionContainer.Scale = Vector2.One; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteHeadPiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteHeadPiece.cs new file mode 100644 index 0000000000..c5aa062d0f --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteHeadPiece.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.Graphics.Textures; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyHoldNoteHeadPiece : LegacyNotePiece + { + protected override Texture GetTexture(ISkinSource skin) + { + // TODO: Should fallback to the head from default legacy skin instead of note. + return GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage) + ?? GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteTailPiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteTailPiece.cs new file mode 100644 index 0000000000..2e8259f10a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyHoldNoteTailPiece.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.Graphics.Textures; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyHoldNoteTailPiece : LegacyNotePiece + { + protected override void OnDirectionChanged(ValueChangedEvent direction) + { + // Invert the direction + base.OnDirectionChanged(direction.NewValue == ScrollingDirection.Up + ? new ValueChangedEvent(ScrollingDirection.Down, ScrollingDirection.Down) + : new ValueChangedEvent(ScrollingDirection.Up, ScrollingDirection.Up)); + } + + protected override Texture GetTexture(ISkinSource skin) + { + // TODO: Should fallback to the head from default legacy skin instead of note. + return GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteTailImage) + ?? GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage) + ?? GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyKeyArea.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyKeyArea.cs new file mode 100644 index 0000000000..b269ea25d4 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyKeyArea.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyKeyArea : LegacyManiaColumnElement, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer; + private Sprite upSprite; + private Sprite downSprite; + + [Resolved] + private Column column { get; set; } + + public LegacyKeyArea() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + string upImage = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.KeyImage)?.Value + ?? $"mania-key{FallbackColumnIndex}"; + + string downImage = GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.KeyImageDown)?.Value + ?? $"mania-key{FallbackColumnIndex}D"; + + InternalChild = directionContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + upSprite = new Sprite + { + Origin = Anchor.BottomCentre, + Texture = skin.GetTexture(upImage), + RelativeSizeAxes = Axes.X, + Width = 1 + }, + downSprite = new Sprite + { + Origin = Anchor.BottomCentre, + Texture = skin.GetTexture(downImage), + RelativeSizeAxes = Axes.X, + Width = 1, + Alpha = 0 + } + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + + if (GetColumnSkinConfig(skin, LegacyManiaSkinConfigurationLookups.KeysUnderNotes)?.Value ?? false) + Column.UnderlayElements.Add(CreateProxy()); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + directionContainer.Anchor = directionContainer.Origin = Anchor.TopCentre; + upSprite.Anchor = downSprite.Anchor = Anchor.TopCentre; + upSprite.Scale = downSprite.Scale = new Vector2(1, -1); + } + else + { + directionContainer.Anchor = directionContainer.Origin = Anchor.BottomCentre; + upSprite.Anchor = downSprite.Anchor = Anchor.BottomCentre; + upSprite.Scale = downSprite.Scale = Vector2.One; + } + } + + public bool OnPressed(ManiaAction action) + { + if (action == column.Action.Value) + { + upSprite.FadeTo(0); + downSprite.FadeTo(1); + } + + return false; + } + + public void OnReleased(ManiaAction action) + { + if (action == column.Action.Value) + { + upSprite.FadeTo(1); + downSprite.FadeTo(0); + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyManiaColumnElement.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyManiaColumnElement.cs new file mode 100644 index 0000000000..3c0c632c14 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyManiaColumnElement.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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + /// + /// A which is placed somewhere within a . + /// + public class LegacyManiaColumnElement : CompositeDrawable + { + [Resolved] + protected Column Column { get; private set; } + + /// + /// The column type identifier to use for texture lookups, in the case of no user-provided configuration. + /// + protected string FallbackColumnIndex { get; private set; } + + [BackgroundDependencyLoader] + private void load() + { + switch (Column.ColumnType) + { + case ColumnType.Special: + FallbackColumnIndex = "S"; + break; + + case ColumnType.Odd: + FallbackColumnIndex = "1"; + break; + + case ColumnType.Even: + FallbackColumnIndex = "2"; + break; + } + } + + protected IBindable GetColumnSkinConfig(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) + => skin.GetManiaSkinConfig(lookup, Column.Index); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyNotePiece.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyNotePiece.cs new file mode 100644 index 0000000000..283b04373b --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyNotePiece.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.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyNotePiece : LegacyManiaColumnElement + { + private readonly IBindable direction = new Bindable(); + + private Container directionContainer; + private Sprite noteSprite; + + private float? minimumColumnWidth; + + public LegacyNotePiece() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + minimumColumnWidth = skin.GetConfig(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.MinimumColumnWidth))?.Value; + + InternalChild = directionContainer = new Container + { + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = noteSprite = new Sprite { Texture = GetTexture(skin) } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(OnDirectionChanged, true); + } + + protected override void Update() + { + base.Update(); + + if (noteSprite.Texture != null) + { + // The height is scaled to the minimum column width, if provided. + float minimumWidth = minimumColumnWidth ?? DrawWidth; + + noteSprite.Scale = Vector2.Divide(new Vector2(DrawWidth, minimumWidth), noteSprite.Texture.DisplayWidth); + } + } + + protected virtual void OnDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + directionContainer.Anchor = Anchor.TopCentre; + directionContainer.Scale = new Vector2(1, -1); + } + else + { + directionContainer.Anchor = Anchor.BottomCentre; + directionContainer.Scale = Vector2.One; + } + } + + protected virtual Texture GetTexture(ISkinSource skin) => GetTextureFromLookup(skin, LegacyManiaSkinConfigurationLookups.NoteImage); + + protected Texture GetTextureFromLookup(ISkin skin, LegacyManiaSkinConfigurationLookups lookup) + { + string suffix = string.Empty; + + switch (lookup) + { + case LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage: + suffix = "H"; + break; + + case LegacyManiaSkinConfigurationLookups.HoldNoteTailImage: + suffix = "T"; + break; + } + + string noteImage = GetColumnSkinConfig(skin, lookup)?.Value + ?? $"mania-note{FallbackColumnIndex}{suffix}"; + + return skin.GetTexture(noteImage, WrapMode.ClampToEdge, WrapMode.ClampToEdge); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyStageBackground.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyStageBackground.cs new file mode 100644 index 0000000000..b0bab8e760 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyStageBackground.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyStageBackground : CompositeDrawable + { + private readonly StageDefinition stageDefinition; + + private Drawable leftSprite; + private Drawable rightSprite; + private ColumnFlow columnBackgrounds; + + public LegacyStageBackground(StageDefinition stageDefinition) + { + this.stageDefinition = stageDefinition; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + string leftImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.LeftStageImage)?.Value + ?? "mania-stage-left"; + + string rightImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.RightStageImage)?.Value + ?? "mania-stage-right"; + + InternalChildren = new[] + { + leftSprite = new Sprite + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopRight, + X = 0.05f, + Texture = skin.GetTexture(leftImage), + }, + rightSprite = new Sprite + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopLeft, + X = -0.05f, + Texture = skin.GetTexture(rightImage) + }, + columnBackgrounds = new ColumnFlow(stageDefinition) + { + RelativeSizeAxes = Axes.Y + }, + new HitTargetInsetContainer + { + Child = new LegacyHitTarget { RelativeSizeAxes = Axes.Both } + } + }; + + for (int i = 0; i < stageDefinition.Columns; i++) + columnBackgrounds.SetContentForColumn(i, new ColumnBackground(i, i == stageDefinition.Columns - 1)); + } + + protected override void Update() + { + base.Update(); + + if (leftSprite?.Height > 0) + leftSprite.Scale = new Vector2(1, DrawHeight / leftSprite.Height); + + if (rightSprite?.Height > 0) + rightSprite.Scale = new Vector2(1, DrawHeight / rightSprite.Height); + } + + private class ColumnBackground : CompositeDrawable + { + private readonly int columnIndex; + private readonly bool isLastColumn; + + public ColumnBackground(int columnIndex, bool isLastColumn) + { + this.columnIndex = columnIndex; + this.isLastColumn = isLastColumn; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + float leftLineWidth = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.LeftLineWidth, columnIndex)?.Value ?? 1; + float rightLineWidth = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.RightLineWidth, columnIndex)?.Value ?? 1; + + bool hasLeftLine = leftLineWidth > 0; + bool hasRightLine = rightLineWidth > 0 && skin.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value >= 2.4m + || isLastColumn; + + Color4 lineColour = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ColumnLineColour, columnIndex)?.Value ?? Color4.White; + Color4 backgroundColour = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour, columnIndex)?.Value ?? Color4.Black; + + InternalChildren = new Drawable[] + { + LegacyColourCompatibility.ApplyWithDoubledAlpha(new Box + { + RelativeSizeAxes = Axes.Both + }, backgroundColour), + new HitTargetInsetContainer + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + new Container + { + RelativeSizeAxes = Axes.Y, + Width = leftLineWidth, + Scale = new Vector2(0.740f, 1), + Alpha = hasLeftLine ? 1 : 0, + Child = LegacyColourCompatibility.ApplyWithDoubledAlpha(new Box + { + RelativeSizeAxes = Axes.Both + }, lineColour) + }, + new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + Width = rightLineWidth, + Scale = new Vector2(0.740f, 1), + Alpha = hasRightLine ? 1 : 0, + Child = LegacyColourCompatibility.ApplyWithDoubledAlpha(new Box + { + RelativeSizeAxes = Axes.Both + }, lineColour) + }, + } + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/LegacyStageForeground.cs b/osu.Game.Rulesets.Mania/Skinning/LegacyStageForeground.cs new file mode 100644 index 0000000000..4609fcc849 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/LegacyStageForeground.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.Framework.Graphics.Containers; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class LegacyStageForeground : CompositeDrawable + { + private readonly IBindable direction = new Bindable(); + + private Drawable sprite; + + public LegacyStageForeground() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, IScrollingInfo scrollingInfo) + { + string bottomImage = skin.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.BottomStageImage)?.Value + ?? "mania-stage-bottom"; + + sprite = skin.GetAnimation(bottomImage, true, true)?.With(d => + { + if (d == null) + return; + + d.Scale = new Vector2(1.6f); + }); + + if (sprite != null) + InternalChild = sprite; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (sprite == null) + return; + + if (direction.NewValue == ScrollingDirection.Up) + sprite.Anchor = sprite.Origin = Anchor.TopCentre; + else + sprite.Anchor = sprite.Origin = Anchor.BottomCentre; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaLegacySkinTransformer.cs new file mode 100644 index 0000000000..3724269f4d --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaLegacySkinTransformer.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; +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Skinning; +using System.Collections.Generic; +using System.Diagnostics; +using osu.Framework.Audio.Sample; +using osu.Game.Audio; +using osu.Game.Rulesets.Objects.Legacy; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class ManiaLegacySkinTransformer : LegacySkinTransformer + { + private readonly ManiaBeatmap beatmap; + + /// + /// Mapping of to their corresponding + /// value. + /// + private static readonly IReadOnlyDictionary hitresult_mapping + = new Dictionary + { + { HitResult.Perfect, LegacyManiaSkinConfigurationLookups.Hit300g }, + { HitResult.Great, LegacyManiaSkinConfigurationLookups.Hit300 }, + { HitResult.Good, LegacyManiaSkinConfigurationLookups.Hit200 }, + { HitResult.Ok, LegacyManiaSkinConfigurationLookups.Hit100 }, + { HitResult.Meh, LegacyManiaSkinConfigurationLookups.Hit50 }, + { HitResult.Miss, LegacyManiaSkinConfigurationLookups.Hit0 } + }; + + /// + /// Mapping of to their corresponding + /// default filenames. + /// + private static readonly IReadOnlyDictionary default_hitresult_skin_filenames + = new Dictionary + { + { HitResult.Perfect, "mania-hit300g" }, + { HitResult.Great, "mania-hit300" }, + { HitResult.Good, "mania-hit200" }, + { HitResult.Ok, "mania-hit100" }, + { HitResult.Meh, "mania-hit50" }, + { HitResult.Miss, "mania-hit0" } + }; + + private Lazy isLegacySkin; + + /// + /// Whether texture for the keys exists. + /// Used to determine if the mania ruleset is skinned. + /// + private Lazy hasKeyTexture; + + public ManiaLegacySkinTransformer(ISkinSource source, IBeatmap beatmap) + : base(source) + { + this.beatmap = (ManiaBeatmap)beatmap; + + Source.SourceChanged += sourceChanged; + sourceChanged(); + } + + private void sourceChanged() + { + isLegacySkin = new Lazy(() => Source.GetConfig(LegacySkinConfiguration.LegacySetting.Version) != null); + hasKeyTexture = new Lazy(() => Source.GetAnimation( + this.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.KeyImage, 0)?.Value + ?? "mania-key1", true, true) != null); + } + + public override Drawable GetDrawableComponent(ISkinComponent component) + { + switch (component) + { + case GameplaySkinComponent resultComponent: + return getResult(resultComponent.Component); + + case ManiaSkinComponent maniaComponent: + if (!isLegacySkin.Value || !hasKeyTexture.Value) + return null; + + switch (maniaComponent.Component) + { + case ManiaSkinComponents.ColumnBackground: + return new LegacyColumnBackground(); + + case ManiaSkinComponents.HitTarget: + // Legacy skins sandwich the hit target between the column background and the column light. + // To preserve this ordering, it's created manually inside LegacyStageBackground. + return Drawable.Empty(); + + case ManiaSkinComponents.KeyArea: + return new LegacyKeyArea(); + + case ManiaSkinComponents.Note: + return new LegacyNotePiece(); + + case ManiaSkinComponents.HoldNoteHead: + return new LegacyHoldNoteHeadPiece(); + + case ManiaSkinComponents.HoldNoteTail: + return new LegacyHoldNoteTailPiece(); + + case ManiaSkinComponents.HoldNoteBody: + return new LegacyBodyPiece(); + + case ManiaSkinComponents.HitExplosion: + return new LegacyHitExplosion(); + + case ManiaSkinComponents.StageBackground: + Debug.Assert(maniaComponent.StageDefinition != null); + return new LegacyStageBackground(maniaComponent.StageDefinition.Value); + + case ManiaSkinComponents.StageForeground: + return new LegacyStageForeground(); + } + + break; + } + + return null; + } + + private Drawable getResult(HitResult result) + { + if (!hitresult_mapping.ContainsKey(result)) + return null; + + string filename = this.GetManiaSkinConfig(hitresult_mapping[result])?.Value + ?? default_hitresult_skin_filenames[result]; + + return this.GetAnimation(filename, true, true); + } + + public override SampleChannel GetSample(ISampleInfo sampleInfo) + { + // layered hit sounds never play in mania + if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered) + return new SampleChannelVirtual(); + + return Source.GetSample(sampleInfo); + } + + public override IBindable GetConfig(TLookup lookup) + { + if (lookup is ManiaSkinConfigurationLookup maniaLookup) + return Source.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.TargetColumn)); + + return Source.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.cs new file mode 100644 index 0000000000..2e17a6bef1 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigExtensions.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.Framework.Bindables; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public static class ManiaSkinConfigExtensions + { + /// + /// Retrieve a per-column-count skin configuration. + /// + /// The skin from which configuration is retrieved. + /// The value to retrieve. + /// If not null, denotes the index of the column to which the entry applies. + public static IBindable GetManiaSkinConfig(this ISkin skin, LegacyManiaSkinConfigurationLookups lookup, int? index = null) + => skin.GetConfig( + new ManiaSkinConfigurationLookup(lookup, index)); + } +} diff --git a/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.cs new file mode 100644 index 0000000000..f07a5518b7 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Skinning/ManiaSkinConfigurationLookup.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.Rulesets.Mania.UI; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.Skinning +{ + public class ManiaSkinConfigurationLookup + { + /// + /// The configuration lookup value. + /// + public readonly LegacyManiaSkinConfigurationLookups Lookup; + + /// + /// The intended index for the configuration. + /// May be null if the configuration does not apply to a . + /// + public readonly int? TargetColumn; + + /// + /// Creates a new . + /// + /// The lookup value. + /// The intended index for the configuration. May be null if the configuration does not apply to a . + public ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups lookup, int? targetColumn = null) + { + Lookup = lookup; + TargetColumn = targetColumn; + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index 91dd236ab1..c28a1c13d8 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -1,4 +1,4 @@ -// 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.Linq; @@ -9,18 +9,23 @@ using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics.Pooling; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.UI.Components; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; using osuTK; +using osu.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Objects.Drawables; namespace osu.Game.Rulesets.Mania.UI { + [Cached] public class Column : ScrollingPlayfield, IKeyBindingHandler, IHasAccentColour { - private const float column_width = 45; - private const float special_column_width = 70; + public const float COLUMN_WIDTH = 80; + public const float SPECIAL_COLUMN_WIDTH = 70; /// /// The index of this column as part of the whole playfield. @@ -29,106 +34,49 @@ namespace osu.Game.Rulesets.Mania.UI public readonly Bindable Action = new Bindable(); - private readonly ColumnBackground background; - private readonly ColumnKeyArea keyArea; - private readonly ColumnHitObjectArea hitObjectArea; - + public readonly ColumnHitObjectArea HitObjectArea; internal readonly Container TopLevelContainer; - private readonly Container explosionContainer; + private readonly DrawablePool hitExplosionPool; + private readonly OrderedHitPolicy hitPolicy; + + public Container UnderlayElements => HitObjectArea.UnderlayElements; public Column(int index) { Index = index; RelativeSizeAxes = Axes.Y; - Width = column_width; + Width = COLUMN_WIDTH; - Masking = true; - CornerRadius = 5; - - background = new ColumnBackground { RelativeSizeAxes = Axes.Both }; - - Container hitTargetContainer; + Drawable background = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.ColumnBackground, Index), _ => new DefaultColumnBackground()) + { + RelativeSizeAxes = Axes.Both + }; InternalChildren = new[] { + hitExplosionPool = new DrawablePool(5), // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements background.CreateProxy(), - hitTargetContainer = new Container + HitObjectArea = new ColumnHitObjectArea(Index, HitObjectContainer) { RelativeSizeAxes = Axes.Both }, + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.KeyArea, Index), _ => new DefaultKeyArea()) { - Name = "Hit target + hit objects", - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - hitObjectArea = new ColumnHitObjectArea(HitObjectContainer) - { - RelativeSizeAxes = Axes.Both, - }, - explosionContainer = new Container - { - Name = "Hit explosions", - RelativeSizeAxes = Axes.Both - } - } - }, - keyArea = new ColumnKeyArea - { - RelativeSizeAxes = Axes.X, - Height = ManiaStage.HIT_TARGET_POSITION, + RelativeSizeAxes = Axes.Both }, background, TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both } }; - TopLevelContainer.Add(explosionContainer.CreateProxy()); + hitPolicy = new OrderedHitPolicy(HitObjectContainer); - Direction.BindValueChanged(dir => - { - hitTargetContainer.Padding = new MarginPadding - { - Top = dir.NewValue == ScrollingDirection.Up ? ManiaStage.HIT_TARGET_POSITION : 0, - Bottom = dir.NewValue == ScrollingDirection.Down ? ManiaStage.HIT_TARGET_POSITION : 0, - }; - - keyArea.Anchor = keyArea.Origin = dir.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; - }, true); + TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); } - public override Axes RelativeSizeAxes => Axes.Y; + public ColumnType ColumnType { get; set; } - private bool isSpecial; + public bool IsSpecial => ColumnType == ColumnType.Special; - public bool IsSpecial - { - get => isSpecial; - set - { - if (isSpecial == value) - return; - - isSpecial = value; - - Width = isSpecial ? special_column_width : column_width; - } - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - background.AccentColour = value; - keyArea.AccentColour = value; - hitObjectArea.AccentColour = value; - } - } + public Color4 AccentColour { get; set; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -146,6 +94,9 @@ namespace osu.Game.Rulesets.Mania.UI hitObject.AccentColour.Value = AccentColour; hitObject.OnNewResult += OnNewResult; + DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)hitObject; + maniaObject.CheckHittable = hitPolicy.IsHittable; + HitObjectContainer.Add(hitObject); } @@ -160,13 +111,13 @@ namespace osu.Game.Rulesets.Mania.UI internal void OnNewResult(DrawableHitObject judgedObject, JudgementResult result) { - if (!result.IsHit || !judgedObject.DisplayResult || !DisplayJudgements.Value) + if (result.IsHit) + hitPolicy.HandleHit(judgedObject); + + if (!result.IsHit || !DisplayJudgements.Value) return; - explosionContainer.Add(new HitExplosion(judgedObject) - { - Anchor = Direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre - }); + HitObjectArea.Explosions.Add(hitExplosionPool.Get(e => e.Apply(result))); } public bool OnPressed(ManiaAction action) @@ -185,10 +136,12 @@ namespace osu.Game.Rulesets.Mania.UI return true; } - public bool OnReleased(ManiaAction action) => false; + public void OnReleased(ManiaAction action) + { + } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) // This probably shouldn't exist as is, but the columns in the stage are separated by a 1px border - => DrawRectangle.Inflate(new Vector2(ManiaStage.COLUMN_SPACING / 2, 0)).Contains(ToLocalSpace(screenSpacePos)); + => DrawRectangle.Inflate(new Vector2(Stage.COLUMN_SPACING / 2, 0)).Contains(ToLocalSpace(screenSpacePos)); } } diff --git a/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs new file mode 100644 index 0000000000..aef82d4c08 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/ColumnFlow.cs @@ -0,0 +1,105 @@ +// 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.Game.Rulesets.Mania.Beatmaps; +using osu.Game.Rulesets.Mania.Skinning; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.UI +{ + /// + /// A which flows its contents according to the s in a . + /// Content can be added to individual columns via . + /// + /// The type of content in each column. + public class ColumnFlow : CompositeDrawable + where TContent : Drawable + { + /// + /// All contents added to this . + /// + public IReadOnlyList Content => columns.Children.Select(c => c.Count == 0 ? null : (TContent)c.Child).ToList(); + + private readonly FillFlowContainer columns; + private readonly StageDefinition stageDefinition; + + public ColumnFlow(StageDefinition stageDefinition) + { + this.stageDefinition = stageDefinition; + + AutoSizeAxes = Axes.X; + + InternalChild = columns = new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + }; + + for (int i = 0; i < stageDefinition.Columns; i++) + columns.Add(new Container { RelativeSizeAxes = Axes.Y }); + } + + private ISkinSource currentSkin; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + currentSkin = skin; + + skin.SourceChanged += onSkinChanged; + onSkinChanged(); + } + + private void onSkinChanged() + { + for (int i = 0; i < stageDefinition.Columns; i++) + { + if (i > 0) + { + float spacing = currentSkin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnSpacing, i - 1)) + ?.Value ?? Stage.COLUMN_SPACING; + + columns[i].Margin = new MarginPadding { Left = spacing }; + } + + float? width = currentSkin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.ColumnWidth, i)) + ?.Value; + + if (width == null) + // only used by default skin (legacy skins get defaults set in LegacyManiaSkinConfiguration) + columns[i].Width = stageDefinition.IsSpecialColumn(i) ? Column.SPECIAL_COLUMN_WIDTH : Column.COLUMN_WIDTH; + else + columns[i].Width = width.Value; + } + } + + /// + /// Sets the content of one of the columns of this . + /// + /// The index of the column to set the content of. + /// The content. + public void SetContentForColumn(int column, TContent content) => columns[column].Child = content; + + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (currentSkin != null) + currentSkin.SourceChanged -= onSkinChanged; + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs index b4e29ae9f9..75cc351310 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnBackground.cs @@ -35,14 +35,13 @@ namespace osu.Game.Rulesets.Mania.UI.Components { Name = "Background", RelativeSizeAxes = Axes.Both, - Alpha = 0.3f }, backgroundOverlay = new Box { Name = "Background Gradient Overlay", RelativeSizeAxes = Axes.Both, Height = 0.5f, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Alpha = 0 } }; @@ -82,7 +81,7 @@ namespace osu.Game.Rulesets.Mania.UI.Components if (!IsLoaded) return; - background.Colour = AccentColour; + background.Colour = AccentColour.Darken(5); var brightPoint = AccentColour.Opacity(0.6f); var dimPoint = AccentColour.Opacity(0); @@ -99,11 +98,10 @@ namespace osu.Game.Rulesets.Mania.UI.Components return false; } - public bool OnReleased(ManiaAction action) + public void OnReleased(ManiaAction action) { if (action == this.action.Value) backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); - return false; } } } diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs index a0d713067d..b365ae45a9 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/ColumnHitObjectArea.cs @@ -1,97 +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 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.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; -using osuTK.Graphics; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Mania.UI.Components { - public class ColumnHitObjectArea : CompositeDrawable, IHasAccentColour + public class ColumnHitObjectArea : HitObjectArea { - private const float hit_target_height = 10; - private const float hit_target_bar_height = 2; + public readonly Container Explosions; - private readonly IBindable direction = new Bindable(); + public readonly Container UnderlayElements; - private readonly Container hitTargetLine; - private readonly Drawable hitTargetBar; + private readonly Drawable hitTarget; - public ColumnHitObjectArea(HitObjectContainer hitObjectContainer) + public ColumnHitObjectArea(int columnIndex, HitObjectContainer hitObjectContainer) + : base(hitObjectContainer) { - InternalChildren = new[] + AddRangeInternal(new[] { - hitTargetBar = new Box + UnderlayElements = new Container + { + RelativeSizeAxes = Axes.Both, + Depth = 2, + }, + hitTarget = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitTarget, columnIndex), _ => new DefaultHitTarget()) { RelativeSizeAxes = Axes.X, - Height = hit_target_height, - Colour = Color4.Black + Depth = 1 }, - hitTargetLine = new Container + Explosions = new Container { - RelativeSizeAxes = Axes.X, - Height = hit_target_bar_height, - Masking = true, - Child = new Box { RelativeSizeAxes = Axes.Both } - }, - hitObjectContainer - }; + RelativeSizeAxes = Axes.Both, + Depth = -1, + } + }); } - [BackgroundDependencyLoader] - private void load(IScrollingInfo scrollingInfo) + protected override void UpdateHitPosition() { - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(dir => - { - Anchor anchor = dir.NewValue == ScrollingDirection.Up ? Anchor.TopLeft : Anchor.BottomLeft; + base.UpdateHitPosition(); - hitTargetBar.Anchor = hitTargetBar.Origin = anchor; - hitTargetLine.Anchor = hitTargetLine.Origin = anchor; - }, true); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - updateColours(); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - updateColours(); - } - } - - private void updateColours() - { - if (!IsLoaded) - return; - - hitTargetLine.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = accentColour.Opacity(0.5f), - }; + if (Direction.Value == ScrollingDirection.Up) + hitTarget.Anchor = hitTarget.Origin = Anchor.TopLeft; + else + hitTarget.Anchor = hitTarget.Origin = Anchor.BottomLeft; } } } diff --git a/osu.Game.Rulesets.Mania/UI/Components/ColumnKeyArea.cs b/osu.Game.Rulesets.Mania/UI/Components/ColumnKeyArea.cs deleted file mode 100644 index 85880222d7..0000000000 --- a/osu.Game.Rulesets.Mania/UI/Components/ColumnKeyArea.cs +++ /dev/null @@ -1,125 +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.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Bindings; -using osu.Game.Graphics; -using osu.Game.Rulesets.UI.Scrolling; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Rulesets.Mania.UI.Components -{ - public class ColumnKeyArea : CompositeDrawable, IKeyBindingHandler, IHasAccentColour - { - private const float key_icon_size = 10; - private const float key_icon_corner_radius = 3; - - private readonly IBindable action = new Bindable(); - private readonly IBindable direction = new Bindable(); - - private Container keyIcon; - - [BackgroundDependencyLoader] - private void load(IBindable action, IScrollingInfo scrollingInfo) - { - this.action.BindTo(action); - - Drawable gradient; - - InternalChildren = new[] - { - gradient = new Box - { - Name = "Key gradient", - RelativeSizeAxes = Axes.Both, - Alpha = 0.5f - }, - keyIcon = new Container - { - Name = "Key icon", - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(key_icon_size), - Masking = true, - CornerRadius = key_icon_corner_radius, - BorderThickness = 2, - BorderColour = Color4.White, // Not true - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - } - } - }; - - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(dir => - { - gradient.Colour = ColourInfo.GradientVertical( - dir.NewValue == ScrollingDirection.Up ? Color4.Black : Color4.Black.Opacity(0), - dir.NewValue == ScrollingDirection.Up ? Color4.Black.Opacity(0) : Color4.Black); - }, true); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - updateColours(); - } - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - updateColours(); - } - } - - private void updateColours() - { - if (!IsLoaded) - return; - - keyIcon.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 5, - Colour = accentColour.Opacity(0.5f), - }; - } - - public bool OnPressed(ManiaAction action) - { - if (action == this.action.Value) - keyIcon.ScaleTo(1.4f, 50, Easing.OutQuint).Then().ScaleTo(1.3f, 250, Easing.OutQuint); - return false; - } - - public bool OnReleased(ManiaAction action) - { - if (action == this.action.Value) - keyIcon.ScaleTo(1f, 125, Easing.OutQuint); - return false; - } - } -} diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs new file mode 100644 index 0000000000..4b4bc157d5 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultColumnBackground.cs @@ -0,0 +1,90 @@ +// 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.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI.Components +{ + public class DefaultColumnBackground : CompositeDrawable, IKeyBindingHandler + { + private readonly IBindable direction = new Bindable(); + + private Color4 brightColour; + private Color4 dimColour; + + private Box background; + private Box backgroundOverlay; + + [Resolved] + private Column column { get; set; } + + public DefaultColumnBackground() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + InternalChildren = new[] + { + background = new Box + { + Name = "Background", + RelativeSizeAxes = Axes.Both, + }, + backgroundOverlay = new Box + { + Name = "Background Gradient Overlay", + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Blending = BlendingParameters.Additive, + Alpha = 0 + } + }; + + background.Colour = column.AccentColour.Darken(5); + brightColour = column.AccentColour.Opacity(0.6f); + dimColour = column.AccentColour.Opacity(0); + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + backgroundOverlay.Anchor = backgroundOverlay.Origin = Anchor.TopLeft; + backgroundOverlay.Colour = ColourInfo.GradientVertical(brightColour, dimColour); + } + else + { + backgroundOverlay.Anchor = backgroundOverlay.Origin = Anchor.BottomLeft; + backgroundOverlay.Colour = ColourInfo.GradientVertical(dimColour, brightColour); + } + } + + public bool OnPressed(ManiaAction action) + { + if (action == column.Action.Value) + backgroundOverlay.FadeTo(1, 50, Easing.OutQuint).Then().FadeTo(0.5f, 250, Easing.OutQuint); + return false; + } + + public void OnReleased(ManiaAction action) + { + if (action == column.Action.Value) + backgroundOverlay.FadeTo(0, 250, Easing.OutQuint); + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.cs new file mode 100644 index 0000000000..e0b099ab9b --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultHitTarget.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 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.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI.Components +{ + public class DefaultHitTarget : CompositeDrawable + { + private const float hit_target_bar_height = 2; + + private readonly IBindable direction = new Bindable(); + + private Container hitTargetLine; + private Drawable hitTargetBar; + + [Resolved] + private Column column { get; set; } + + public DefaultHitTarget() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + InternalChildren = new[] + { + hitTargetBar = new Box + { + RelativeSizeAxes = Axes.X, + Height = DefaultNotePiece.NOTE_HEIGHT, + Alpha = 0.6f, + Colour = Color4.Black + }, + hitTargetLine = new Container + { + RelativeSizeAxes = Axes.X, + Height = hit_target_bar_height, + Masking = true, + Child = new Box { RelativeSizeAxes = Axes.Both } + }, + }; + + hitTargetLine.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = column.AccentColour.Opacity(0.5f), + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + hitTargetBar.Anchor = hitTargetBar.Origin = Anchor.TopLeft; + hitTargetLine.Anchor = hitTargetLine.Origin = Anchor.TopLeft; + } + else + { + hitTargetBar.Anchor = hitTargetBar.Origin = Anchor.BottomLeft; + hitTargetLine.Anchor = hitTargetLine.Origin = Anchor.BottomLeft; + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs new file mode 100644 index 0000000000..47cb9bd45a --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultKeyArea.cs @@ -0,0 +1,117 @@ +// 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.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI.Components +{ + public class DefaultKeyArea : CompositeDrawable, IKeyBindingHandler + { + private const float key_icon_size = 10; + private const float key_icon_corner_radius = 3; + + private readonly IBindable direction = new Bindable(); + + private Container directionContainer; + private Container keyIcon; + private Drawable gradient; + + [Resolved] + private Column column { get; set; } + + public DefaultKeyArea() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + InternalChild = directionContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = Stage.HIT_TARGET_POSITION, + Children = new[] + { + gradient = new Box + { + Name = "Key gradient", + RelativeSizeAxes = Axes.Both, + Alpha = 0.5f + }, + keyIcon = new Container + { + Name = "Key icon", + Size = new Vector2(key_icon_size), + Origin = Anchor.Centre, + Masking = true, + CornerRadius = key_icon_corner_radius, + BorderThickness = 2, + BorderColour = Color4.White, // Not true + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + } + } + }; + + keyIcon.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 5, + Colour = column.AccentColour.Opacity(0.5f), + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + keyIcon.Anchor = Anchor.BottomCentre; + keyIcon.Y = -20; + directionContainer.Anchor = directionContainer.Origin = Anchor.TopLeft; + gradient.Colour = ColourInfo.GradientVertical(Color4.Black, Color4.Black.Opacity(0)); + } + else + { + keyIcon.Anchor = Anchor.TopCentre; + keyIcon.Y = 20; + directionContainer.Anchor = directionContainer.Origin = Anchor.BottomLeft; + gradient.Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0), Color4.Black); + } + } + + public bool OnPressed(ManiaAction action) + { + if (action == column.Action.Value) + keyIcon.ScaleTo(1.4f, 50, Easing.OutQuint).Then().ScaleTo(1.3f, 250, Easing.OutQuint); + return false; + } + + public void OnReleased(ManiaAction action) + { + if (action == column.Action.Value) + keyIcon.ScaleTo(1f, 125, Easing.OutQuint); + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Components/DefaultStageBackground.cs b/osu.Game.Rulesets.Mania/UI/Components/DefaultStageBackground.cs new file mode 100644 index 0000000000..f5b542d085 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/Components/DefaultStageBackground.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.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI.Components +{ + public class DefaultStageBackground : CompositeDrawable + { + public DefaultStageBackground() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Box + { + Name = "Background", + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black + }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs new file mode 100644 index 0000000000..8f7880dafa --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.Skinning; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.UI.Components +{ + public class HitObjectArea : SkinReloadableDrawable + { + protected readonly IBindable Direction = new Bindable(); + public readonly HitObjectContainer HitObjectContainer; + + public HitObjectArea(HitObjectContainer hitObjectContainer) + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Child = HitObjectContainer = hitObjectContainer + }; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + Direction.BindTo(scrollingInfo.Direction); + Direction.BindValueChanged(onDirectionChanged, true); + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + UpdateHitPosition(); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + UpdateHitPosition(); + } + + protected virtual void UpdateHitPosition() + { + float hitPosition = CurrentSkin.GetConfig( + new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.HitPosition))?.Value + ?? Stage.HIT_TARGET_POSITION; + + Padding = Direction.Value == ScrollingDirection.Up + ? new MarginPadding { Top = hitPosition } + : new MarginPadding { Bottom = hitPosition }; + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.cs new file mode 100644 index 0000000000..225269cf48 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/DefaultHitExplosion.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 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.Effects; +using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mania.Judgements; +using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI +{ + public class DefaultHitExplosion : CompositeDrawable, IHitExplosion + { + private const float default_large_faint_size = 0.8f; + + public override bool RemoveWhenNotAlive => true; + + [Resolved] + private Column column { get; set; } + + private readonly IBindable direction = new Bindable(); + + private CircularContainer largeFaint; + private CircularContainer mainGlow1; + + public DefaultHitExplosion() + { + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.X; + Height = DefaultNotePiece.NOTE_HEIGHT; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + const float angle_variangle = 15; // should be less than 45 + const float roundness = 80; + const float initial_height = 10; + + var colour = Interpolation.ValueAt(0.4f, column.AccentColour, Color4.White, 0, 1); + + InternalChildren = new Drawable[] + { + largeFaint = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + // we want our size to be very small so the glow dominates it. + Size = new Vector2(default_large_faint_size), + Blending = BlendingParameters.Additive, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.1f, column.AccentColour, Color4.White, 0, 1).Opacity(0.3f), + Roundness = 160, + Radius = 200, + }, + }, + mainGlow1 = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Blending = BlendingParameters.Additive, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Interpolation.ValueAt(0.6f, column.AccentColour, Color4.White, 0, 1), + Roundness = 20, + Radius = 50, + }, + }, + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Size = new Vector2(0.01f, initial_height), + Blending = BlendingParameters.Additive, + Rotation = RNG.NextSingle(-angle_variangle, angle_variangle), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour, + Roundness = roundness, + Radius = 40, + }, + }, + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + Size = new Vector2(0.01f, initial_height), + Blending = BlendingParameters.Additive, + Rotation = RNG.NextSingle(-angle_variangle, angle_variangle), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = colour, + Roundness = roundness, + Radius = 40, + }, + } + }; + + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + if (direction.NewValue == ScrollingDirection.Up) + { + Anchor = Anchor.TopCentre; + Y = DefaultNotePiece.NOTE_HEIGHT / 2; + } + else + { + Anchor = Anchor.BottomCentre; + Y = -DefaultNotePiece.NOTE_HEIGHT / 2; + } + } + + public void Animate(JudgementResult result) + { + // scale roughly in-line with visual appearance of notes + Vector2 scale = new Vector2(1, 0.6f); + + if (result.Judgement is HoldNoteTickJudgement) + scale *= 0.5f; + + this.ScaleTo(scale); + + largeFaint + .ResizeTo(default_large_faint_size) + .Then() + .ResizeTo(default_large_faint_size * new Vector2(5, 1), PoolableHitExplosion.DURATION, Easing.OutQuint) + .FadeOut(PoolableHitExplosion.DURATION * 2); + + mainGlow1 + .ScaleTo(1) + .Then() + .ScaleTo(1.4f, PoolableHitExplosion.DURATION, Easing.OutQuint); + + this.FadeOutFromOne(PoolableHitExplosion.DURATION, Easing.Out); + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs index 8797f014df..d99f6cb8d3 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaJudgement.cs @@ -15,6 +15,10 @@ namespace osu.Game.Rulesets.Mania.UI { } + public DrawableManiaJudgement() + { + } + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index c8aeda8fe4..7f5b9a6ee0 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -4,10 +4,11 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; using osu.Framework.Input; -using osu.Framework.MathUtils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Input.Handlers; @@ -17,85 +18,99 @@ 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.Mania.Scoring; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; -using osuTK; namespace osu.Game.Rulesets.Mania.UI { public class DrawableManiaRuleset : DrawableScrollingRuleset { + /// + /// The minimum time range. This occurs at a of 40. + /// + public const double MIN_TIME_RANGE = 340; + + /// + /// The maximum time range. This occurs at a of 1. + /// + public const double MAX_TIME_RANGE = 13720; + protected new ManiaPlayfield Playfield => (ManiaPlayfield)base.Playfield; public new ManiaBeatmap Beatmap => (ManiaBeatmap)base.Beatmap; public IEnumerable BarLines; + protected override bool RelativeScaleBeatLengths => true; + protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config; private readonly Bindable configDirection = new Bindable(); + private readonly Bindable configTimeRange = new BindableDouble(); - public DrawableManiaRuleset(Ruleset ruleset, WorkingBeatmap beatmap, IReadOnlyList mods) + // Stores the current speed adjustment active in gameplay. + private readonly Track speedAdjustmentTrack = new TrackVirtual(0); + + public DrawableManiaRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) { - // Generate the bar lines - double lastObjectTime = (Objects.LastOrDefault() as IHasEndTime)?.EndTime ?? Objects.LastOrDefault()?.StartTime ?? double.MaxValue; - - var timingPoints = Beatmap.ControlPointInfo.TimingPoints; - var barLines = new List(); - - for (int i = 0; i < timingPoints.Count; i++) - { - TimingControlPoint point = timingPoints[i]; - - // Stop on the beat before the next timing point, or if there is no next timing point stop slightly past the last object - double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time - point.BeatLength : lastObjectTime + point.BeatLength * (int)point.TimeSignature; - - int index = 0; - - for (double t = timingPoints[i].Time; Precision.DefinitelyBigger(endTime, t); t += point.BeatLength, index++) - { - barLines.Add(new BarLine - { - StartTime = t, - ControlPoint = point, - BeatIndex = index - }); - } - } - - BarLines = barLines; + BarLines = new BarLineGenerator(Beatmap).BarLines; } [BackgroundDependencyLoader] private void load() { + foreach (var mod in Mods.OfType()) + mod.ApplyToTrack(speedAdjustmentTrack); + + bool isForCurrentRuleset = Beatmap.BeatmapInfo.Ruleset.Equals(Ruleset.RulesetInfo); + + foreach (var p in ControlPoints) + { + // Mania doesn't care about global velocity + p.Velocity = 1; + p.BaseBeatLength *= Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier; + + // For non-mania beatmap, speed changes should only happen through timing points + if (!isForCurrentRuleset) + p.DifficultyPoint = new DifficultyControlPoint(); + } + BarLines.ForEach(Playfield.Add); Config.BindWith(ManiaRulesetSetting.ScrollDirection, configDirection); configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true); - Config.BindWith(ManiaRulesetSetting.ScrollTime, TimeRange); + Config.BindWith(ManiaRulesetSetting.ScrollTime, configTimeRange); } - /// - /// Retrieves the column that intersects a screen-space position. - /// - /// The screen-space position. - /// The column which intersects with . - public Column GetColumnByPosition(Vector2 screenSpacePosition) => Playfield.GetColumnByPosition(screenSpacePosition); + protected override void AdjustScrollSpeed(int amount) + { + this.TransformTo(nameof(relativeTimeRange), relativeTimeRange + amount, 200, Easing.OutQuint); + } + + private double relativeTimeRange + { + get => MAX_TIME_RANGE / configTimeRange.Value; + set => configTimeRange.Value = MAX_TIME_RANGE / value; + } + + protected override void Update() + { + base.Update(); + + updateTimeRange(); + } + + private void updateTimeRange() => TimeRange.Value = configTimeRange.Value * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value; public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new ManiaPlayfieldAdjustmentContainer(); protected override Playfield CreatePlayfield() => new ManiaPlayfield(Beatmap.Stages); - public override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor(this); - public override int Variant => (int)(Beatmap.Stages.Count == 1 ? PlayfieldType.Single : PlayfieldType.Dual) + Beatmap.TotalColumns; protected override PassThroughInputManager CreateInputManager() => new ManiaInputManager(Ruleset.RulesetInfo, Variant); @@ -116,5 +131,7 @@ namespace osu.Game.Rulesets.Mania.UI } protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new ManiaFramedReplayInputHandler(replay); + + protected override ReplayRecorder CreateReplayRecorder(Replay replay) => new ManiaReplayRecorder(replay); } } diff --git a/osu.Game.Rulesets.Mania/UI/HitExplosion.cs b/osu.Game.Rulesets.Mania/UI/HitExplosion.cs deleted file mode 100644 index 48470add8b..0000000000 --- a/osu.Game.Rulesets.Mania/UI/HitExplosion.cs +++ /dev/null @@ -1,69 +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 osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.MathUtils; -using osu.Game.Rulesets.Mania.Objects.Drawables; -using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces; -using osu.Game.Rulesets.Objects.Drawables; -using osuTK; - -namespace osu.Game.Rulesets.Mania.UI -{ - internal class HitExplosion : CompositeDrawable - { - public override bool RemoveWhenNotAlive => true; - - private readonly CircularContainer circle; - - public HitExplosion(DrawableHitObject judgedObject) - { - bool isTick = judgedObject is DrawableHoldNoteTick; - - Origin = Anchor.Centre; - - RelativeSizeAxes = Axes.X; - Y = NotePiece.NOTE_HEIGHT / 2; - Height = NotePiece.NOTE_HEIGHT; - - // scale roughly in-line with visual appearance of notes - Scale = new Vector2(isTick ? 0.4f : 0.8f); - - InternalChild = circle = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true, - // we want our size to be very small so the glow dominates it. - Size = new Vector2(0.1f), - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = Interpolation.ValueAt(0.1f, judgedObject.AccentColour.Value, Color4.White, 0, 1), - Radius = 100, - }, - Child = new Box - { - Alpha = 0, - RelativeSizeAxes = Axes.Both, - AlwaysPresent = true - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - circle.ResizeTo(circle.Size * new Vector2(4, 20), 1000, Easing.OutQuint); - this.FadeIn(16).Then().FadeOut(500, Easing.OutQuint); - - Expire(true); - } - } -} diff --git a/osu.Game.Rulesets.Mania/UI/IHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/IHitExplosion.cs new file mode 100644 index 0000000000..3252dcc276 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/IHitExplosion.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.Judgements; + +namespace osu.Game.Rulesets.Mania.UI +{ + /// + /// Common interface for all hit explosion bodies. + /// + public interface IHitExplosion + { + /// + /// Begins animating this . + /// + /// The type of that caused this explosion. + void Animate(JudgementResult result); + } +} diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs index 5ab07416a6..271e432e8d 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfield.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics.Containers; using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -14,9 +15,12 @@ using osuTK; namespace osu.Game.Rulesets.Mania.UI { + [Cached] public class ManiaPlayfield : ScrollingPlayfield { - private readonly List stages = new List(); + public IReadOnlyList Stages => stages; + + private readonly List stages = new List(); public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => stages.Any(s => s.ReceivePositionalInputAt(screenSpacePos)); @@ -41,7 +45,7 @@ namespace osu.Game.Rulesets.Mania.UI for (int i = 0; i < stageDefinitions.Count; i++) { - var newStage = new ManiaStage(firstColumnIndex, stageDefinitions[i], ref normalColumnAction, ref specialColumnAction); + var newStage = new Stage(firstColumnIndex, stageDefinitions[i], ref normalColumnAction, ref specialColumnAction); playfieldGrid.Content[0][i] = newStage; @@ -71,7 +75,7 @@ namespace osu.Game.Rulesets.Mania.UI { foreach (var column in stage.Columns) { - if (column.ReceivePositionalInputAt(screenSpacePosition)) + if (column.ReceivePositionalInputAt(new Vector2(screenSpacePosition.X, column.ScreenSpaceDrawQuad.Centre.Y))) { found = column; break; @@ -85,18 +89,43 @@ namespace osu.Game.Rulesets.Mania.UI return found; } + /// + /// Retrieves a by index. + /// + /// The index of the column. + /// The corresponding to the given index. + /// If is less than 0 or greater than . + public Column GetColumn(int index) + { + if (index < 0 || index > TotalColumns - 1) + throw new ArgumentOutOfRangeException(nameof(index)); + + foreach (var stage in stages) + { + if (index >= stage.Columns.Count) + { + index -= stage.Columns.Count; + continue; + } + + return stage.Columns[index]; + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + /// /// Retrieves the total amount of columns across all stages in this playfield. /// public int TotalColumns => stages.Sum(s => s.Columns.Count); - private ManiaStage getStageByColumn(int column) + private Stage getStageByColumn(int column) { int sum = 0; foreach (var stage in stages) { - sum = sum + stage.Columns.Count; + sum += stage.Columns.Count; if (sum > column) return stage; } diff --git a/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs index d893a3fdde..30e0aafb7d 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Mania/UI/ManiaPlayfieldAdjustmentContainer.cs @@ -3,7 +3,6 @@ using osu.Framework.Graphics; using osu.Game.Rulesets.UI; -using osuTK; namespace osu.Game.Rulesets.Mania.UI { @@ -13,8 +12,6 @@ namespace osu.Game.Rulesets.Mania.UI { Anchor = Anchor.Centre; Origin = Anchor.Centre; - - Size = new Vector2(1, 0.8f); } } } diff --git a/osu.Game.Rulesets.Mania/UI/ManiaReplayRecorder.cs b/osu.Game.Rulesets.Mania/UI/ManiaReplayRecorder.cs new file mode 100644 index 0000000000..18275000a2 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/ManiaReplayRecorder.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.Collections.Generic; +using osu.Game.Replays; +using osu.Game.Rulesets.Mania.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Mania.UI +{ + public class ManiaReplayRecorder : ReplayRecorder + { + public ManiaReplayRecorder(Replay replay) + : base(replay) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) + => new ManiaReplayFrame(Time.Current, actions.ToArray()); + } +} diff --git a/osu.Game.Rulesets.Mania/UI/OrderedHitPolicy.cs b/osu.Game.Rulesets.Mania/UI/OrderedHitPolicy.cs new file mode 100644 index 0000000000..0f9cd48dd8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/OrderedHitPolicy.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; +using System.Collections.Generic; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Mania.UI +{ + /// + /// Ensures that only the most recent is hittable, affectionately known as "note lock". + /// + public class OrderedHitPolicy + { + private readonly HitObjectContainer hitObjectContainer; + + public OrderedHitPolicy(HitObjectContainer hitObjectContainer) + { + this.hitObjectContainer = hitObjectContainer; + } + + /// + /// Determines whether a can be hit at a point in time. + /// + /// + /// Only the most recent can be hit, a previous hitobject's window cannot extend past the next one. + /// + /// The to check. + /// The time to check. + /// Whether can be hit at the given . + public bool IsHittable(DrawableHitObject hitObject, double time) + { + var nextObject = hitObjectContainer.AliveObjects.GetNext(hitObject); + return nextObject == null || time < nextObject.HitObject.StartTime; + } + + /// + /// Handles a being hit to potentially miss all earlier s. + /// + /// The that was hit. + public void HandleHit(DrawableHitObject hitObject) + { + if (!IsHittable(hitObject, hitObject.HitObject.StartTime + hitObject.Result.TimeOffset)) + throw new InvalidOperationException($"A {hitObject} was hit before it became hittable!"); + + foreach (var obj in enumerateHitObjectsUpTo(hitObject.HitObject.StartTime)) + { + if (obj.Judged) + continue; + + ((DrawableManiaHitObject)obj).MissForcefully(); + } + } + + private IEnumerable enumerateHitObjectsUpTo(double targetTime) + { + foreach (var obj in hitObjectContainer.AliveObjects) + { + if (obj.HitObject.GetEndTime() >= targetTime) + yield break; + + yield return obj; + + foreach (var nestedObj in obj.NestedHitObjects) + { + if (nestedObj.HitObject.GetEndTime() >= targetTime) + break; + + yield return nestedObj; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs b/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.cs new file mode 100644 index 0000000000..15d216e8c5 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/PlayfieldCoveringWrapper.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.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.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Mania.UI +{ + /// + /// A that has its contents partially hidden by an adjustable "cover". This is intended to be used in a playfield. + /// + public class PlayfieldCoveringWrapper : CompositeDrawable + { + /// + /// The complete cover, including gradient and fill. + /// + private readonly Drawable cover; + + /// + /// The gradient portion of the cover. + /// + private readonly Box gradient; + + /// + /// The fully-opaque portion of the cover. + /// + private readonly Box filled; + + private readonly IBindable scrollDirection = new Bindable(); + + public PlayfieldCoveringWrapper(Drawable content) + { + InternalChild = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + content, + cover = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Blending = new BlendingParameters + { + // Don't change the destination colour. + RGBEquation = BlendingEquation.Add, + Source = BlendingType.Zero, + Destination = BlendingType.One, + // Subtract the cover's alpha from the destination (points with alpha 1 should make the destination completely transparent). + AlphaEquation = BlendingEquation.Add, + SourceAlpha = BlendingType.Zero, + DestinationAlpha = BlendingType.OneMinusSrcAlpha + }, + Children = new Drawable[] + { + gradient = new Box + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Height = 0.25f, + Colour = ColourInfo.GradientVertical( + Color4.White.Opacity(0f), + Color4.White.Opacity(1f) + ) + }, + filled = new Box + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Height = 0 + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(IScrollingInfo scrollingInfo) + { + scrollDirection.BindTo(scrollingInfo.Direction); + scrollDirection.BindValueChanged(onScrollDirectionChanged, true); + } + + private void onScrollDirectionChanged(ValueChangedEvent direction) + => cover.Rotation = direction.NewValue == ScrollingDirection.Up ? 0 : 180f; + + /// + /// The relative area that should be completely covered. This does not include the fade. + /// + public float Coverage + { + set + { + filled.Height = value; + gradient.Y = -value; + } + } + + /// + /// The direction in which the cover expands. + /// + public CoverExpandDirection Direction + { + set => cover.Scale = value == CoverExpandDirection.AlongScroll ? Vector2.One : new Vector2(1, -1); + } + } + + public enum CoverExpandDirection + { + /// + /// The cover expands along the scrolling direction. + /// + AlongScroll, + + /// + /// The cover expands against the scrolling direction. + /// + AgainstScroll + } +} diff --git a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs new file mode 100644 index 0000000000..64b7d7d550 --- /dev/null +++ b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Judgements; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mania.UI +{ + public class PoolableHitExplosion : PoolableDrawable + { + public const double DURATION = 200; + + public JudgementResult Result { get; private set; } + + [Resolved] + private Column column { get; set; } + + private SkinnableDrawable skinnableExplosion; + + public PoolableHitExplosion() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = skinnableExplosion = new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.HitExplosion, column.Index), _ => new DefaultHitExplosion()) + { + RelativeSizeAxes = Axes.Both + }; + } + + public void Apply(JudgementResult result) + { + Result = result; + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + (skinnableExplosion?.Drawable as IHitExplosion)?.Animate(Result); + + this.Delay(DURATION).Then().Expire(); + } + } +} diff --git a/osu.Game.Rulesets.Mania/UI/ManiaStage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs similarity index 51% rename from osu.Game.Rulesets.Mania/UI/ManiaStage.cs rename to osu.Game.Rulesets.Mania/UI/Stage.cs index a28de7ea58..e7a2de266d 100644 --- a/osu.Game.Rulesets.Mania/UI/ManiaStage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -1,20 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Pooling; using osu.Game.Rulesets.Judgements; 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.Drawables; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -23,30 +23,32 @@ namespace osu.Game.Rulesets.Mania.UI /// /// A collection of s. /// - public class ManiaStage : ScrollingPlayfield + public class Stage : ScrollingPlayfield { public const float COLUMN_SPACING = 1; - public const float HIT_TARGET_POSITION = 50; + public const float HIT_TARGET_POSITION = 110; - public IReadOnlyList Columns => columnFlow.Children; - private readonly FillFlowContainer columnFlow; + public IReadOnlyList Columns => columnFlow.Content; + private readonly ColumnFlow columnFlow; - private readonly Container barLineContainer; - - public Container Judgements => judgements; private readonly JudgementContainer judgements; + private readonly DrawablePool judgementPool; - private readonly Container topLevelContainer; + private readonly Drawable barLineContainer; - private List normalColumnColours = new List(); - private Color4 specialColumnColour; + private readonly Dictionary columnColours = new Dictionary + { + { ColumnType.Even, new Color4(6, 84, 0, 255) }, + { ColumnType.Odd, new Color4(94, 0, 57, 255) }, + { ColumnType.Special, new Color4(0, 48, 63, 255) } + }; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Columns.Any(c => c.ReceivePositionalInputAt(screenSpacePos)); private readonly int firstColumnIndex; - public ManiaStage(int firstColumnIndex, StageDefinition definition, ref ManiaAction normalColumnStartAction, ref ManiaAction specialColumnStartAction) + public Stage(int firstColumnIndex, StageDefinition definition, ref ManiaAction normalColumnStartAction, ref ManiaAction specialColumnStartAction) { this.firstColumnIndex = firstColumnIndex; @@ -57,8 +59,11 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Y; AutoSizeAxes = Axes.X; + Container topLevelContainer; + InternalChildren = new Drawable[] { + judgementPool = new DrawablePool(2), new Container { Anchor = Anchor.TopCentre, @@ -67,31 +72,13 @@ namespace osu.Game.Rulesets.Mania.UI AutoSizeAxes = Axes.X, Children = new Drawable[] { - new Container + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageBackground, stageDefinition: definition), _ => new DefaultStageBackground()) + { + RelativeSizeAxes = Axes.Both + }, + columnFlow = new ColumnFlow(definition) { - Name = "Columns mask", RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Masking = true, - CornerRadius = 5, - Children = new Drawable[] - { - new Box - { - Name = "Background", - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black - }, - columnFlow = new FillFlowContainer - { - Name = "Columns", - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Direction = FillDirection.Horizontal, - Padding = new MarginPadding { Left = COLUMN_SPACING, Right = COLUMN_SPACING }, - Spacing = new Vector2(COLUMN_SPACING, 0) - }, - } }, new Container { @@ -102,22 +89,24 @@ namespace osu.Game.Rulesets.Mania.UI Width = 1366, // Bar lines should only be masked on the vertical axis BypassAutoSizeAxes = Axes.Both, Masking = true, - Child = barLineContainer = new Container + Child = barLineContainer = new HitObjectArea(HitObjectContainer) { Name = "Bar lines", Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.Y, - Child = HitObjectContainer } }, + new SkinnableDrawable(new ManiaSkinComponent(ManiaSkinComponents.StageForeground, stageDefinition: definition), _ => null) + { + RelativeSizeAxes = Axes.Both + }, judgements = new JudgementContainer { Anchor = Anchor.TopCentre, Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.Both, Y = HIT_TARGET_POSITION + 150, - BypassAutoSizeAxes = Axes.Both }, topLevelContainer = new Container { RelativeSizeAxes = Axes.Both } } @@ -126,31 +115,21 @@ namespace osu.Game.Rulesets.Mania.UI for (int i = 0; i < definition.Columns; i++) { - var isSpecial = definition.IsSpecialColumn(i); + var columnType = definition.GetTypeOfColumn(i); + var column = new Column(firstColumnIndex + i) { - IsSpecial = isSpecial, - Action = { Value = isSpecial ? specialColumnStartAction++ : normalColumnStartAction++ } + RelativeSizeAxes = Axes.Both, + Width = 1, + ColumnType = columnType, + AccentColour = columnColours[columnType], + Action = { Value = columnType == ColumnType.Special ? specialColumnStartAction++ : normalColumnStartAction++ } }; - AddColumn(column); + topLevelContainer.Add(column.TopLevelContainer.CreateProxy()); + columnFlow.SetContentForColumn(i, column); + AddNested(column); } - - Direction.BindValueChanged(dir => - { - barLineContainer.Padding = new MarginPadding - { - Top = dir.NewValue == ScrollingDirection.Up ? HIT_TARGET_POSITION : 0, - Bottom = dir.NewValue == ScrollingDirection.Down ? HIT_TARGET_POSITION : 0, - }; - }, true); - } - - public void AddColumn(Column c) - { - topLevelContainer.Add(c.TopLevelContainer.CreateProxy()); - columnFlow.Add(c); - AddNested(c); } public override void Add(DrawableHitObject h) @@ -188,44 +167,14 @@ namespace osu.Game.Rulesets.Mania.UI if (!judgedObject.DisplayResult || !DisplayJudgements.Value) return; - judgements.Clear(); - judgements.Add(new DrawableManiaJudgement(result, judgedObject) + judgements.Clear(false); + judgements.Add(judgementPool.Get(j => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }); - } + j.Apply(result, judgedObject); - [BackgroundDependencyLoader] - private void load() - { - normalColumnColours = new List - { - new Color4(94, 0, 57, 255), - new Color4(6, 84, 0, 255) - }; - - specialColumnColour = new Color4(0, 48, 63, 255); - - // Set the special column + colour + key - foreach (var column in Columns) - { - if (!column.IsSpecial) - continue; - - column.AccentColour = specialColumnColour; - } - - var nonSpecialColumns = Columns.Where(c => !c.IsSpecial).ToList(); - - // We'll set the colours of the non-special columns in a separate loop, because the non-special - // column colours are mirrored across their centre and special styles mess with this - for (int i = 0; i < Math.Ceiling(nonSpecialColumns.Count / 2f); i++) - { - Color4 colour = normalColumnColours[i % normalColumnColours.Count]; - nonSpecialColumns[i].AccentColour = colour; - nonSpecialColumns[nonSpecialColumns.Count - 1 - i].AccentColour = colour; - } + j.Anchor = Anchor.Centre; + j.Origin = Anchor.Centre; + })); } protected override void Update() diff --git a/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs b/osu.Game.Rulesets.Mania/VariantMappingGenerator.cs new file mode 100644 index 0000000000..878d1088a6 --- /dev/null +++ b/osu.Game.Rulesets.Mania/VariantMappingGenerator.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 System.Collections.Generic; +using osu.Framework.Input.Bindings; + +namespace osu.Game.Rulesets.Mania +{ + public class VariantMappingGenerator + { + /// + /// All the s available to the left hand. + /// + public InputKey[] LeftKeys; + + /// + /// All the s available to the right hand. + /// + public InputKey[] RightKeys; + + /// + /// The for the special key. + /// + public InputKey SpecialKey; + + /// + /// The at which the normal columns should begin. + /// + public ManiaAction NormalActionStart; + + /// + /// The for the special column. + /// + public ManiaAction SpecialAction; + + /// + /// Generates a list of s for a specific number of columns. + /// + /// The number of columns that need to be bound. + /// The next to use for normal columns. + /// The keybindings. + public IEnumerable GenerateKeyBindingsFor(int columns, out ManiaAction nextNormalAction) + { + ManiaAction currentNormalAction = NormalActionStart; + + var bindings = new List(); + + for (int i = LeftKeys.Length - columns / 2; i < LeftKeys.Length; i++) + bindings.Add(new KeyBinding(LeftKeys[i], currentNormalAction++)); + + if (columns % 2 == 1) + bindings.Add(new KeyBinding(SpecialKey, SpecialAction)); + + for (int i = 0; i < columns / 2; i++) + bindings.Add(new KeyBinding(RightKeys[i], currentNormalAction++)); + + nextNormalAction = currentNormalAction; + return bindings; + } + } +} diff --git a/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj b/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj index a086da0565..07ef1022ae 100644 --- a/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj +++ b/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj @@ -1,9 +1,7 @@  - - netstandard2.0 + netstandard2.1 Library - AnyCPU true smash the keys. to the beat. diff --git a/osu.Game.Rulesets.Osu.Tests.Android/Properties/AndroidManifest.xml b/osu.Game.Rulesets.Osu.Tests.Android/Properties/AndroidManifest.xml index aad907b241..3ce17ccc27 100644 --- a/osu.Game.Rulesets.Osu.Tests.Android/Properties/AndroidManifest.xml +++ b/osu.Game.Rulesets.Osu.Tests.Android/Properties/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/Application.cs b/osu.Game.Rulesets.Osu.Tests.iOS/Application.cs index 3718264a42..b36d0b5728 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Osu.Tests.iOS/Application.cs @@ -5,7 +5,7 @@ using UIKit; namespace osu.Game.Rulesets.Osu.Tests.iOS { - public class Application + public static class Application { public static void Main(string[] args) { diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj b/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj index 9930a166e3..545abcec6c 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj +++ b/osu.Game.Rulesets.Osu.Tests.iOS/osu.Game.Rulesets.Osu.Tests.iOS.csproj @@ -1,6 +1,5 @@ - + - Debug iPhoneSimulator @@ -13,14 +12,6 @@ - - libbass.a - PreserveNewest - - - libbass_fx.a - PreserveNewest - Linker.xml @@ -41,5 +32,4 @@ - \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/.vscode/launch.json b/osu.Game.Rulesets.Osu.Tests/.vscode/launch.json index ed03e99b9b..94568e3852 100644 --- a/osu.Game.Rulesets.Osu.Tests/.vscode/launch.json +++ b/osu.Game.Rulesets.Osu.Tests/.vscode/launch.json @@ -7,7 +7,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/bin/Debug/netcoreapp2.2/osu.Game.Rulesets.Osu.Tests.dll" + "${workspaceRoot}/bin/Debug/netcoreapp3.1/osu.Game.Rulesets.Osu.Tests.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build (Debug)", @@ -20,7 +20,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/bin/Release/netcoreapp2.2/osu.Game.Rulesets.Osu.Tests.dll" + "${workspaceRoot}/bin/Release/netcoreapp3.1/osu.Game.Rulesets.Osu.Tests.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build (Release)", diff --git a/osu.Game.Rulesets.Osu.Tests/.vscode/tasks.json b/osu.Game.Rulesets.Osu.Tests/.vscode/tasks.json index ed2a015e11..14ffbfb4ae 100644 --- a/osu.Game.Rulesets.Osu.Tests/.vscode/tasks.json +++ b/osu.Game.Rulesets.Osu.Tests/.vscode/tasks.json @@ -11,9 +11,9 @@ "build", "--no-restore", "osu.Game.Rulesets.Osu.Tests.csproj", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" @@ -26,10 +26,10 @@ "build", "--no-restore", "osu.Game.Rulesets.Osu.Tests.csproj", - "/p:Configuration=Release", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs similarity index 94% rename from osu.Game.Rulesets.Osu.Tests/TestSceneHitCirclePlacementBlueprint.cs rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs index 4c6abc45f7..7bccec6c97 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCirclePlacementBlueprint.cs @@ -9,7 +9,7 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Tests.Visual; -namespace osu.Game.Rulesets.Osu.Tests +namespace osu.Game.Rulesets.Osu.Tests.Editor { public class TestSceneHitCirclePlacementBlueprint : PlacementBlueprintTestScene { diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.cs new file mode 100644 index 0000000000..66cd405195 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneHitCircleSelectionBlueprint.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.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; +using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneHitCircleSelectionBlueprint : SelectionBlueprintTestScene + { + private HitCircle hitCircle; + private DrawableHitCircle drawableObject; + private TestBlueprint blueprint; + + [SetUp] + public void Setup() => Schedule(() => + { + Clear(); + + hitCircle = new HitCircle { Position = new Vector2(256, 192) }; + hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); + + Add(drawableObject = new DrawableHitCircle(hitCircle)); + AddBlueprint(blueprint = new TestBlueprint(drawableObject)); + }); + + [Test] + public void TestInitialState() + { + AddAssert("blueprint positioned over hitobject", () => blueprint.CirclePiece.Position == hitCircle.Position); + } + + [Test] + public void TestMoveHitObject() + { + AddStep("move hitobject", () => hitCircle.Position = new Vector2(300, 225)); + AddAssert("blueprint positioned over hitobject", () => blueprint.CirclePiece.Position == hitCircle.Position); + } + + [Test] + public void TestMoveAfterApplyingDefaults() + { + AddStep("apply defaults", () => hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 })); + AddStep("move hitobject", () => hitCircle.Position = new Vector2(300, 225)); + AddAssert("blueprint positioned over hitobject", () => blueprint.CirclePiece.Position == hitCircle.Position); + } + + [Test] + public void TestStackedHitObject() + { + AddStep("set stacking", () => hitCircle.StackHeight = 5); + AddAssert("blueprint positioned over hitobject", () => blueprint.CirclePiece.Position == hitCircle.StackedPosition); + } + + private class TestBlueprint : HitCircleSelectionBlueprint + { + public new HitCirclePiece CirclePiece => base.CirclePiece; + + public TestBlueprint(DrawableHitCircle drawableCircle) + : base(drawableCircle) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs new file mode 100644 index 0000000000..1ca94df26b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs @@ -0,0 +1,90 @@ +// 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.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + [TestFixture] + public class TestSceneObjectObjectSnap : 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()); + } + + [TestCase(true)] + [TestCase(false)] + public void TestHitCircleSnapsToOtherHitCircle(bool distanceSnapEnabled) + { + AddStep("move mouse to centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre)); + + if (!distanceSnapEnabled) + AddStep("disable distance snap", () => InputManager.Key(Key.Q)); + + AddStep("enter placement mode", () => InputManager.Key(Key.Number2)); + + AddStep("place first object", () => InputManager.Click(MouseButton.Left)); + + AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.02f, 0))); + + AddStep("place second object", () => InputManager.Click(MouseButton.Left)); + + AddAssert("both objects at same location", () => + { + var objects = EditorBeatmap.HitObjects; + + var first = (OsuHitObject)objects.First(); + var second = (OsuHitObject)objects.Last(); + + return first.Position == second.Position; + }); + } + + [Test] + public void TestHitCircleSnapsToSliderEnd() + { + AddStep("move mouse to centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre)); + + AddStep("disable distance snap", () => InputManager.Key(Key.Q)); + + AddStep("enter slider placement mode", () => InputManager.Key(Key.Number3)); + + AddStep("start slider placement", () => InputManager.Click(MouseButton.Left)); + + AddStep("move to place end", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.185f, 0))); + + AddStep("end slider placement", () => InputManager.Click(MouseButton.Right)); + + AddStep("enter circle placement mode", () => InputManager.Key(Key.Number2)); + + AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.20f, 0))); + + AddStep("place second object", () => InputManager.Click(MouseButton.Left)); + + AddAssert("circle is at slider's end", () => + { + var objects = EditorBeatmap.HitObjects; + + var first = (Slider)objects.First(); + var second = (OsuHitObject)objects.Last(); + + return Precision.AlmostEquals(first.EndPosition, second.Position); + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs new file mode 100644 index 0000000000..1232369a0b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs @@ -0,0 +1,190 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestSceneOsuDistanceSnapGrid : OsuManualInputManagerTestScene + { + private const double beat_length = 100; + private static readonly Vector2 grid_position = new Vector2(512, 384); + + [Cached(typeof(EditorBeatmap))] + private readonly EditorBeatmap editorBeatmap; + + [Cached] + private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); + + [Cached(typeof(IPositionSnapProvider))] + private readonly SnapProvider snapProvider = new SnapProvider(); + + private TestOsuDistanceSnapGrid grid; + + public TestSceneOsuDistanceSnapGrid() + { + editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + } + + [SetUp] + public void Setup() => Schedule(() => + { + editorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 1; + editorBeatmap.ControlPointInfo.Clear(); + editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length }); + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray + }, + grid = new TestOsuDistanceSnapGrid(new HitCircle { Position = grid_position }), + new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position } + }; + }); + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(6)] + [TestCase(8)] + [TestCase(12)] + [TestCase(16)] + public void TestBeatDivisor(int divisor) + { + AddStep($"set beat divisor = {divisor}", () => beatDivisor.Value = divisor); + } + + [Test] + public void TestCursorInCentre() + { + AddStep("move mouse to centre", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position))); + assertSnappedDistance((float)beat_length); + } + + [Test] + public void TestCursorBeforeMovementPoint() + { + AddStep("move mouse to just before movement point", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2((float)beat_length, 0) * 1.49f))); + assertSnappedDistance((float)beat_length); + } + + [Test] + public void TestCursorAfterMovementPoint() + { + AddStep("move mouse to just after movement point", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2((float)beat_length, 0) * 1.51f))); + assertSnappedDistance((float)beat_length * 2); + } + + [Test] + public void TestLimitedDistance() + { + AddStep("create limited grid", () => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray + }, + grid = new TestOsuDistanceSnapGrid(new HitCircle { Position = grid_position }, new HitCircle { StartTime = 200 }), + new SnappingCursorContainer { GetSnapPosition = v => grid.GetSnappedPosition(grid.ToLocalSpace(v)).position } + }; + }); + + AddStep("move mouse outside grid", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2((float)beat_length, 0) * 3f))); + assertSnappedDistance((float)beat_length * 2); + } + + private void assertSnappedDistance(float expectedDistance) => AddAssert($"snap distance = {expectedDistance}", () => + { + Vector2 snappedPosition = grid.GetSnappedPosition(grid.ToLocalSpace(InputManager.CurrentState.Mouse.Position)).position; + + return Precision.AlmostEquals(expectedDistance, Vector2.Distance(snappedPosition, grid_position)); + }); + + private class SnappingCursorContainer : CompositeDrawable + { + public Func GetSnapPosition; + + private readonly Drawable cursor; + + public SnappingCursorContainer() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = cursor = new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(50), + Colour = Color4.Red + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updatePosition(GetContainingInputManager().CurrentState.Mouse.Position); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + base.OnMouseMove(e); + + updatePosition(e.ScreenSpaceMousePosition); + return true; + } + + private void updatePosition(Vector2 screenSpacePosition) + { + cursor.Position = GetSnapPosition.Invoke(screenSpacePosition); + } + } + + private class TestOsuDistanceSnapGrid : OsuDistanceSnapGrid + { + public new float DistanceSpacing => base.DistanceSpacing; + + public TestOsuDistanceSnapGrid(OsuHitObject hitObject, OsuHitObject nextHitObject = null) + : base(hitObject, nextHitObject) + { + } + } + + private class SnapProvider : IPositionSnapProvider + { + public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); + + public float GetBeatSnapDistanceAt(double referenceTime) => (float)beat_length; + + public float DurationToDistance(double referenceTime, double duration) => (float)duration; + + public double DistanceToDuration(double referenceTime, float distance) => distance; + + public double GetSnappedDurationFromDistance(double referenceTime, float distance) => 0; + + public float GetSnappedDistanceFromDistance(double referenceTime, float distance) => 0; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneEditor.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditor.cs similarity index 57% rename from osu.Game.Rulesets.Osu.Tests/TestSceneEditor.cs rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditor.cs index 4aca34bf64..e1ca3ddd61 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneEditor.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditor.cs @@ -4,14 +4,11 @@ using NUnit.Framework; using osu.Game.Tests.Visual; -namespace osu.Game.Rulesets.Osu.Tests +namespace osu.Game.Rulesets.Osu.Tests.Editor { [TestFixture] - public class TestSceneEditor : EditorTestScene + public class TestSceneOsuEditor : EditorTestScene { - public TestSceneEditor() - : base(new OsuRuleset()) - { - } + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs new file mode 100644 index 0000000000..738a21b17e --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.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 System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public class TestScenePathControlPointVisualiser : OsuTestScene + { + private Slider slider; + private PathControlPointVisualiser visualiser; + + [SetUp] + public void Setup() => Schedule(() => + { + slider = new Slider(); + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + }); + + [Test] + public void TestAddOverlappingControlPoints() + { + createVisualiser(true); + + addControlPointStep(new Vector2(200)); + addControlPointStep(new Vector2(300)); + addControlPointStep(new Vector2(300)); + addControlPointStep(new Vector2(500, 300)); + + AddAssert("last connection displayed", () => + { + var lastConnection = visualiser.Connections.Last(c => c.ControlPoint.Position.Value == new Vector2(300)); + return lastConnection.DrawWidth > 50; + }); + } + + 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))); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs new file mode 100644 index 0000000000..67a2e5a47c --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs @@ -0,0 +1,302 @@ +// 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.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; +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 TestSceneSliderPlacementBlueprint : PlacementBlueprintTestScene + { + [SetUp] + public void Setup() => Schedule(() => + { + HitObjectContainer.Clear(); + ResetPlacement(); + }); + + [Test] + public void TestBeginPlacementWithoutFinishing() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + assertPlaced(false); + } + + [Test] + public void TestPlaceWithoutMovingMouse() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertLength(0); + assertControlPointType(0, PathType.Linear); + } + + [Test] + public void TestPlaceWithMouseMovement() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 200)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertLength(200); + assertControlPointCount(2); + assertControlPointType(0, PathType.Linear); + } + + [Test] + public void TestPlaceNormalControlPoint() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointType(0, PathType.PerfectCurve); + } + + [Test] + public void TestPlaceTwoNormalControlPoints() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(4); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointPosition(2, new Vector2(100, 100)); + assertControlPointType(0, PathType.Bezier); + } + + [Test] + public void TestPlaceSegmentControlPoint() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointType(0, PathType.Linear); + assertControlPointType(1, PathType.Linear); + } + + [Test] + public void TestMoveToPerfectCurveThenPlaceLinear() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(2); + assertControlPointType(0, PathType.Linear); + assertLength(100); + } + + [Test] + public void TestMoveToBezierThenPlacePerfectCurve() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 300)); + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointType(0, PathType.PerfectCurve); + } + + [Test] + public void TestMoveToFourthOrderBezierThenPlaceThirdOrderBezier() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400)); + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(4); + assertControlPointType(0, PathType.Bezier); + } + + [Test] + public void TestPlaceLinearSegmentThenPlaceLinearSegment() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(3); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointPosition(2, new Vector2(100)); + assertControlPointType(0, PathType.Linear); + assertControlPointType(1, PathType.Linear); + } + + [Test] + public void TestPlaceLinearSegmentThenPlacePerfectCurveSegment() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(4); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointPosition(2, new Vector2(100)); + assertControlPointType(0, PathType.Linear); + assertControlPointType(1, PathType.PerfectCurve); + } + + [Test] + public void TestPlacePerfectCurveSegmentThenPlacePerfectCurveSegment() + { + addMovementStep(new Vector2(200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 200)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(300, 300)); + addClickStep(MouseButton.Left); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400, 300)); + addClickStep(MouseButton.Left); + + addMovementStep(new Vector2(400)); + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertControlPointCount(5); + assertControlPointPosition(1, new Vector2(100, 0)); + assertControlPointPosition(2, new Vector2(100)); + assertControlPointPosition(3, new Vector2(200, 100)); + assertControlPointPosition(4, new Vector2(200)); + assertControlPointType(0, PathType.PerfectCurve); + assertControlPointType(2, PathType.PerfectCurve); + } + + [Test] + public void TestBeginPlacementWithoutReleasingMouse() + { + addMovementStep(new Vector2(200)); + AddStep("press left button", () => InputManager.PressButton(MouseButton.Left)); + + addMovementStep(new Vector2(400, 200)); + AddStep("release left button", () => InputManager.ReleaseButton(MouseButton.Left)); + + addClickStep(MouseButton.Right); + + assertPlaced(true); + assertLength(200); + assertControlPointCount(2); + assertControlPointType(0, PathType.Linear); + } + + private void addMovementStep(Vector2 position) => AddStep($"move mouse to {position}", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(position))); + + private void addClickStep(MouseButton button) + { + AddStep($"click {button}", () => InputManager.Click(button)); + } + + private void assertPlaced(bool expected) => AddAssert($"slider {(expected ? "placed" : "not placed")}", () => (getSlider() != null) == expected); + + private void assertLength(double expected) => AddAssert($"slider length is {expected}", () => Precision.AlmostEquals(expected, getSlider().Distance, 1)); + + private void assertControlPointCount(int expected) => AddAssert($"has {expected} control points", () => getSlider().Path.ControlPoints.Count == expected); + + private void assertControlPointType(int index, PathType type) => AddAssert($"control point {index} is {type}", () => getSlider().Path.ControlPoints[index].Type.Value == type); + + private void assertControlPointPosition(int index, Vector2 position) => + AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, getSlider().Path.ControlPoints[index].Position.Value, 1)); + + private Slider getSlider() => HitObjectContainer.Count > 0 ? ((DrawableSlider)HitObjectContainer[0]).HitObject : null; + + protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSlider((Slider)hitObject); + protected override PlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs new file mode 100644 index 0000000000..f6e1be693b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSelectionBlueprint.cs @@ -0,0 +1,220 @@ +// 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 TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene + { + private Slider slider; + private DrawableSlider drawableObject; + private TestSliderBlueprint blueprint; + + [SetUp] + public void Setup() => Schedule(() => + { + Clear(); + + slider = new Slider + { + Position = new Vector2(256, 192), + Path = new SliderPath(PathType.Bezier, new[] + { + Vector2.Zero, + new Vector2(150, 150), + new Vector2(300, 0) + }) + }; + + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); + + Add(drawableObject = new DrawableSlider(slider)); + AddBlueprint(blueprint = new TestSliderBlueprint(drawableObject)); + }); + + [Test] + public void TestInitialState() + { + checkPositions(); + } + + [Test] + public void TestMoveHitObject() + { + moveHitObject(); + checkPositions(); + } + + [Test] + public void TestMoveAfterApplyingDefaults() + { + AddStep("apply defaults", () => slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 })); + moveHitObject(); + checkPositions(); + } + + [Test] + public void TestStackedHitObject() + { + AddStep("set stacking", () => slider.StackHeight = 5); + checkPositions(); + } + + [Test] + public void TestSingleControlPointSelection() + { + moveMouseToControlPoint(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + checkControlPointSelected(0, true); + checkControlPointSelected(1, false); + } + + [Test] + public void TestSingleControlPointDeselectionViaOtherControlPoint() + { + moveMouseToControlPoint(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + moveMouseToControlPoint(1); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + checkControlPointSelected(0, false); + checkControlPointSelected(1, true); + } + + [Test] + public void TestSingleControlPointDeselectionViaClickOutside() + { + moveMouseToControlPoint(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddStep("move mouse outside control point", () => InputManager.MoveMouseTo(drawableObject)); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + checkControlPointSelected(0, false); + checkControlPointSelected(1, false); + } + + [Test] + public void TestMultipleControlPointSelection() + { + moveMouseToControlPoint(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + moveMouseToControlPoint(1); + AddStep("ctrl + click", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + checkControlPointSelected(0, true); + checkControlPointSelected(1, true); + } + + [Test] + public void TestMultipleControlPointDeselectionViaOtherControlPoint() + { + moveMouseToControlPoint(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + moveMouseToControlPoint(1); + AddStep("ctrl + click", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + moveMouseToControlPoint(2); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + checkControlPointSelected(0, false); + checkControlPointSelected(1, false); + } + + [Test] + public void TestMultipleControlPointDeselectionViaClickOutside() + { + moveMouseToControlPoint(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + moveMouseToControlPoint(1); + AddStep("ctrl + click", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddStep("move mouse outside control point", () => InputManager.MoveMouseTo(drawableObject)); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + checkControlPointSelected(0, false); + checkControlPointSelected(1, false); + } + + private void moveHitObject() + { + AddStep("move hitobject", () => + { + slider.Position = new Vector2(300, 225); + }); + } + + private void checkPositions() + { + AddAssert("body positioned correctly", () => blueprint.BodyPiece.Position == slider.StackedPosition); + + AddAssert("head positioned correctly", + () => Precision.AlmostEquals(blueprint.HeadBlueprint.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.HeadCircle.ScreenSpaceDrawQuad.Centre)); + + AddAssert("tail positioned correctly", + () => Precision.AlmostEquals(blueprint.TailBlueprint.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre)); + } + + 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 checkControlPointSelected(int index, bool selected) + => AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser.Pieces[index].IsSelected.Value == selected); + + 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 PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser; + + public TestSliderBlueprint(DrawableSlider slider) + : base(slider) + { + } + + protected override SliderCircleSelectionBlueprint CreateCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) => new TestSliderCircleBlueprint(slider, position); + } + + private class TestSliderCircleBlueprint : SliderCircleSelectionBlueprint + { + public new HitCirclePiece CirclePiece => base.CirclePiece; + + public TestSliderCircleBlueprint(DrawableSlider slider, SliderPosition position) + : base(slider, position) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs similarity index 94% rename from osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerPlacementBlueprint.cs rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs index d74d072857..fa6c660b01 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerPlacementBlueprint.cs @@ -9,7 +9,7 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Tests.Visual; -namespace osu.Game.Rulesets.Osu.Tests +namespace osu.Game.Rulesets.Osu.Tests.Editor { public class TestSceneSpinnerPlacementBlueprint : PlacementBlueprintTestScene { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs similarity index 67% rename from osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSelectionBlueprint.cs rename to osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs index c5cea76b14..4248f68a60 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSpinnerSelectionBlueprint.cs @@ -1,32 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners; -using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Tests.Visual; using osuTK; -namespace osu.Game.Rulesets.Osu.Tests +namespace osu.Game.Rulesets.Osu.Tests.Editor { public class TestSceneSpinnerSelectionBlueprint : SelectionBlueprintTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SpinnerSelectionBlueprint), - typeof(SpinnerPiece) - }; - - private readonly DrawableSpinner drawableSpinner; - public TestSceneSpinnerSelectionBlueprint() { var spinner = new Spinner @@ -35,16 +23,19 @@ namespace osu.Game.Rulesets.Osu.Tests StartTime = -1000, EndTime = 2000 }; + spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); + DrawableSpinner drawableSpinner; + Add(new Container { RelativeSizeAxes = Axes.Both, Size = new Vector2(0.5f), Child = drawableSpinner = new DrawableSpinner(spinner) }); - } - protected override SelectionBlueprint CreateBlueprint() => new SpinnerSelectionBlueprint(drawableSpinner) { Size = new Vector2(0.5f) }; + AddBlueprint(new SpinnerSelectionBlueprint(drawableSpinner) { Size = new Vector2(0.5f) }); + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs b/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.cs new file mode 100644 index 0000000000..7697f46160 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/OsuModTestScene.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.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class OsuModTestScene : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.cs new file mode 100644 index 0000000000..49c1fe8540 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDifficultyAdjust.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.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModDifficultyAdjust : OsuModTestScene + { + [Test] + public void TestNoAdjustment() => CreateModTest(new ModTestData + { + Mod = new OsuModDifficultyAdjust(), + Autoplay = true, + PassCondition = checkSomeHit + }); + + [Test] + public void TestCircleSize1() => CreateModTest(new ModTestData + { + Mod = new OsuModDifficultyAdjust { CircleSize = { Value = 1 } }, + Autoplay = true, + PassCondition = () => checkSomeHit() && checkObjectsScale(0.78f) + }); + + [Test] + public void TestCircleSize10() => CreateModTest(new ModTestData + { + Mod = new OsuModDifficultyAdjust { CircleSize = { Value = 10 } }, + Autoplay = true, + PassCondition = () => checkSomeHit() && checkObjectsScale(0.15f) + }); + + [Test] + public void TestApproachRate1() => CreateModTest(new ModTestData + { + Mod = new OsuModDifficultyAdjust { ApproachRate = { Value = 1 } }, + Autoplay = true, + PassCondition = () => checkSomeHit() && checkObjectsPreempt(1680) + }); + + [Test] + public void TestApproachRate10() => CreateModTest(new ModTestData + { + Mod = new OsuModDifficultyAdjust { ApproachRate = { Value = 10 } }, + Autoplay = true, + PassCondition = () => checkSomeHit() && checkObjectsPreempt(450) + }); + + private bool checkObjectsPreempt(double target) + { + var objects = Player.ChildrenOfType(); + if (!objects.Any()) + return false; + + return objects.All(o => o.HitObject.TimePreempt == target); + } + + private bool checkObjectsScale(float target) + { + var objects = Player.ChildrenOfType(); + if (!objects.Any()) + return false; + + return objects.All(o => Precision.AlmostEquals(o.ChildrenOfType().First().Children.OfType().Single().Scale.X, target)); + } + + private bool checkSomeHit() + { + return Player.ScoreProcessor.JudgedHits >= 2; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs new file mode 100644 index 0000000000..335ef31019 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.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 NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModDoubleTime : OsuModTestScene + { + [TestCase(0.5)] + [TestCase(1.01)] + [TestCase(1.5)] + [TestCase(2)] + [TestCase(5)] + public void TestSpeedChangeCustomisation(double rate) + { + var mod = new OsuModDoubleTime { SpeedChange = { Value = rate } }; + + CreateModTest(new ModTestData + { + Mod = mod, + PassCondition = () => Player.ScoreProcessor.JudgedHits >= 2 && + Precision.AlmostEquals(Player.GameplayClockContainer.GameplayClock.Rate, mod.SpeedChange.Value) + }); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs new file mode 100644 index 0000000000..40f1c4a52f --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs @@ -0,0 +1,100 @@ +// 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.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModHidden : OsuModTestScene + { + [Test] + public void TestDefaultBeatmapTest() => CreateModTest(new ModTestData + { + Mod = new OsuModHidden(), + Autoplay = true, + PassCondition = checkSomeHit + }); + + [Test] + public void FirstCircleAfterTwoSpinners() => CreateModTest(new ModTestData + { + Mod = new OsuModHidden(), + Autoplay = true, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Spinner + { + Position = new Vector2(256, 192), + EndTime = 1000, + }, + new Spinner + { + Position = new Vector2(256, 192), + StartTime = 1200, + EndTime = 2200, + }, + new HitCircle + { + Position = new Vector2(300, 192), + StartTime = 3200, + }, + new HitCircle + { + Position = new Vector2(384, 192), + StartTime = 4200, + } + } + }, + PassCondition = checkSomeHit + }); + + [Test] + public void FirstSliderAfterTwoSpinners() => CreateModTest(new ModTestData + { + Mod = new OsuModHidden(), + Autoplay = true, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Spinner + { + Position = new Vector2(256, 192), + EndTime = 1000, + }, + new Spinner + { + Position = new Vector2(256, 192), + StartTime = 1200, + EndTime = 2200, + }, + new Slider + { + StartTime = 3200, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(100, 0), }) + }, + new Slider + { + StartTime = 5200, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(100, 0), }) + } + } + }, + PassCondition = checkSomeHit + }); + + private bool checkSomeHit() + { + return Player.ScoreProcessor.JudgedHits >= 4; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.cs new file mode 100644 index 0000000000..985baa8cf5 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModPerfect.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 NUnit.Framework; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModPerfect : ModPerfectTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + public TestSceneOsuModPerfect() + : base(new OsuModPerfect()) + { + } + + [TestCase(false)] + [TestCase(true)] + public void TestHitCircle(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new HitCircle { StartTime = 1000 }), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestSlider(bool shouldMiss) + { + var slider = new Slider + { + StartTime = 1000, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(100, 0), }) + }; + + CreateHitObjectTest(new HitObjectTestData(slider), shouldMiss); + } + + [TestCase(false)] + [TestCase(true)] + public void TestSpinner(bool shouldMiss) + { + var spinner = new Spinner + { + StartTime = 1000, + EndTime = 3000, + Position = new Vector2(256, 192) + }; + + CreateHitObjectTest(new HitObjectTestData(spinner), shouldMiss); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.cs new file mode 100644 index 0000000000..d8064d36ea --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModSpunOut.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 System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModSpunOut : OsuModTestScene + { + protected override bool AllowFail => true; + + [Test] + public void TestSpinnerAutoCompleted() => CreateModTest(new ModTestData + { + Mod = new OsuModSpunOut(), + Autoplay = false, + Beatmap = singleSpinnerBeatmap, + PassCondition = () => Player.ChildrenOfType().Single().Progress >= 1 + }); + + [TestCase(null)] + [TestCase(typeof(OsuModDoubleTime))] + [TestCase(typeof(OsuModHalfTime))] + public void TestSpinRateUnaffectedByMods(Type additionalModType) + { + var mods = new List { new OsuModSpunOut() }; + if (additionalModType != null) + mods.Add((Mod)Activator.CreateInstance(additionalModType)); + + CreateModTest(new ModTestData + { + Mods = mods, + Autoplay = false, + Beatmap = singleSpinnerBeatmap, + PassCondition = () => Precision.AlmostEquals(Player.ChildrenOfType().Single().SpinsPerMinute, 286, 1) + }); + } + + private Beatmap singleSpinnerBeatmap => new Beatmap + { + HitObjects = new List + { + new Spinner + { + Position = new Vector2(256, 192), + StartTime = 500, + Duration = 2000 + } + } + }; + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs index f98d63e6c7..7d32895083 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs @@ -4,16 +4,15 @@ using System; using System.Collections.Generic; using NUnit.Framework; -using osu.Framework.MathUtils; +using osu.Framework.Utils; 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.Tests.Beatmaps; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] + [Timeout(10000)] public class OsuBeatmapConversionTest : BeatmapConversionTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; @@ -21,10 +20,10 @@ namespace osu.Game.Rulesets.Osu.Tests [TestCase("basic")] [TestCase("colinear-perfect-curve")] [TestCase("slider-ticks")] - public new void Test(string name) - { - base.Test(name); - } + [TestCase("repeat-slider")] + [TestCase("uneven-repeat-slider")] + [TestCase("old-stacking")] + public void Test(string name) => base.Test(name); protected override IEnumerable CreateConvertValue(HitObject hitObject) { @@ -32,22 +31,22 @@ namespace osu.Game.Rulesets.Osu.Tests { case Slider slider: foreach (var nested in slider.NestedHitObjects) - yield return createConvertValue(nested); + yield return createConvertValue((OsuHitObject)nested); break; default: - yield return createConvertValue(hitObject); + yield return createConvertValue((OsuHitObject)hitObject); break; } - ConvertValue createConvertValue(HitObject obj) => new ConvertValue + static ConvertValue createConvertValue(OsuHitObject obj) => new ConvertValue { StartTime = obj.StartTime, - EndTime = (obj as IHasEndTime)?.EndTime ?? obj.StartTime, - X = (obj as IHasPosition)?.X ?? OsuPlayfield.BASE_SIZE.X / 2, - Y = (obj as IHasPosition)?.Y ?? OsuPlayfield.BASE_SIZE.Y / 2, + EndTime = obj.GetEndTime(), + X = obj.StackedPosition.X, + Y = obj.StackedPosition.Y }; } diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index 693faee3b7..85a41137d4 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; - [TestCase(6.931145117263422, "diffcalc-test")] + [TestCase(6.9311451172608853d, "diffcalc-test")] [TestCase(1.0736587013228804d, "zero-length-sliders")] public void Test(double expected, string name) => base.Test(expected, name); diff --git a/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.cs new file mode 100644 index 0000000000..495f2738b5 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/OsuLegacyModConversionTest.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 NUnit.Framework; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class OsuLegacyModConversionTest : LegacyModConversionTest + { + [TestCase(LegacyMods.Easy, new[] { typeof(OsuModEasy) })] + [TestCase(LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(OsuModHardRock), typeof(OsuModDoubleTime) })] + [TestCase(LegacyMods.DoubleTime, new[] { typeof(OsuModDoubleTime) })] + [TestCase(LegacyMods.Nightcore, new[] { typeof(OsuModNightcore) })] + [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(OsuModNightcore) })] + [TestCase(LegacyMods.Flashlight | LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(OsuModFlashlight), typeof(OsuModFlashlight) })] + [TestCase(LegacyMods.Perfect, new[] { typeof(OsuModPerfect) })] + [TestCase(LegacyMods.SuddenDeath, new[] { typeof(OsuModSuddenDeath) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(OsuModPerfect) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath | LegacyMods.DoubleTime, new[] { typeof(OsuModDoubleTime), typeof(OsuModPerfect) })] + [TestCase(LegacyMods.SpunOut | LegacyMods.Easy, new[] { typeof(OsuModSpunOut), typeof(OsuModEasy) })] + public new void Test(LegacyMods legacyMods, Type[] expectedMods) => base.Test(legacyMods, expectedMods); + + protected override Ruleset CreateRuleset() => new OsuRuleset(); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs b/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.cs new file mode 100644 index 0000000000..cad98185ce --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/OsuSkinnableTestScene.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.Containers; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public abstract class OsuSkinnableTestScene : SkinnableTestScene + { + private Container content; + + protected override Container Content + { + get + { + if (content == null) + base.Content.Add(content = new OsuInputManager(new RulesetInfo { ID = 0 })); + + return content; + } + } + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/approachcircle@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/approachcircle@2x.png new file mode 100644 index 0000000000..72ef665478 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/approachcircle@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit0@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit0@2x.png new file mode 100644 index 0000000000..a91072eb5b Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit0@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit100@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit100@2x.png new file mode 100644 index 0000000000..5eb202c021 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit100@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit300@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit300@2x.png new file mode 100644 index 0000000000..878c11cd67 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit300@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit50@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit50@2x.png new file mode 100644 index 0000000000..f64feded0c Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hit50@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hitcircle@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hitcircle@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hitcircle@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hitcircleoverlay@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hitcircleoverlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/hitcircleoverlay@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderendcircle@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderendcircle@2x.png new file mode 100644 index 0000000000..c6c3771593 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderendcircle@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderendcircleoverlay@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderendcircleoverlay@2x.png new file mode 100644 index 0000000000..232560a1d4 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderendcircleoverlay@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircle@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircle@2x.png new file mode 100644 index 0000000000..4d630443cd Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircle@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircleoverlay@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircleoverlay@2x.png new file mode 100644 index 0000000000..a824784942 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/sliderstartcircleoverlay@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/spinner-background@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/spinner-background@2x.png new file mode 100644 index 0000000000..4f50f638c5 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/spinner-background@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/spinner-circle@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/spinner-circle@2x.png new file mode 100644 index 0000000000..daf28e09cb Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/spinner-circle@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/spinner-metre@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/spinner-metre@2x.png new file mode 100644 index 0000000000..6ef1068420 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/metrics-skin/spinner-metre@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/approachcircle.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/approachcircle.png new file mode 100644 index 0000000000..ff8b02ce80 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/approachcircle.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-0.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-0.png new file mode 100644 index 0000000000..2af0569bcb Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-0.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-1.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-1.png new file mode 100644 index 0000000000..e8b674d62a Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-1.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-2.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-2.png new file mode 100644 index 0000000000..dbf7bc73bc Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-2.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-3.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-3.png new file mode 100644 index 0000000000..43990c46e7 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-3.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-4.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-4.png new file mode 100644 index 0000000000..4564f6d8bf Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-4.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-5.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-5.png new file mode 100644 index 0000000000..dcef35eb59 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-5.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-6.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-6.png new file mode 100644 index 0000000000..bfc0a01be5 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-6.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-7.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-7.png new file mode 100644 index 0000000000..b9079ad5d5 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-7.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-8.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-8.png new file mode 100644 index 0000000000..3a3d61b947 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-8.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-9.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-9.png new file mode 100644 index 0000000000..3e703cd1cf Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/default-9.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit0.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit0.png new file mode 100644 index 0000000000..3c3ebbfd0b Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit0.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit100.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit100.png new file mode 100644 index 0000000000..9ecc302910 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit100.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit300.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit300.png new file mode 100644 index 0000000000..24945f7d92 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit300.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit50.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit50.png new file mode 100644 index 0000000000..d3f7eec5ee Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hit50.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircle.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircle.png new file mode 100644 index 0000000000..a5a3545abf Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircle.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircleoverlay.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircleoverlay.png new file mode 100644 index 0000000000..b4062b8c62 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/hitcircleoverlay.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 new file mode 100644 index 0000000000..5369de24e9 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini @@ -0,0 +1,2 @@ +[General] +Version: 1.0 \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-approachcircle.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-approachcircle.png new file mode 100644 index 0000000000..3811e5050f Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-approachcircle.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-background.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-background.png new file mode 100644 index 0000000000..d84eab2f15 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-background.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-circle.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-circle.png new file mode 100644 index 0000000000..4dd4a6d319 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-circle.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-clear.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-clear.png new file mode 100644 index 0000000000..c66f1c9309 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-clear.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-metre.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-metre.png new file mode 100644 index 0000000000..33902186d9 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-metre.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-osu.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-osu.png new file mode 100644 index 0000000000..6882a232e0 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-osu.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-spin.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-spin.png new file mode 100644 index 0000000000..98a9991c2f Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinner-spin.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerbonus.wav b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerbonus.wav new file mode 100644 index 0000000000..5e583e77aa Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerbonus.wav differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerspin.wav b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerspin.wav new file mode 100644 index 0000000000..bba19381f1 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/spinnerspin.wav differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/approachcircle@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/approachcircle@2x.png new file mode 100755 index 0000000000..0a6ec6535c Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/approachcircle@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit0-0@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit0-0@2x.png new file mode 100644 index 0000000000..37e7e9143f Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit0-0@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit0-1@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit0-1@2x.png new file mode 100644 index 0000000000..b75c71927c Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit0-1@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit100-0@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit100-0@2x.png new file mode 100644 index 0000000000..7932667408 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit100-0@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit100-1@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit100-1@2x.png new file mode 100644 index 0000000000..0b0ae85972 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit100-1@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit300-0@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit300-0@2x.png new file mode 100644 index 0000000000..441c3ec21f Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit300-0@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit300-1@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit300-1@2x.png new file mode 100644 index 0000000000..910d8e2231 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit300-1@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit50-0@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit50-0@2x.png new file mode 100644 index 0000000000..6f92db28d3 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit50-0@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit50-1@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit50-1@2x.png new file mode 100644 index 0000000000..b28503e9f2 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hit50-1@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircle@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircle@2x.png new file mode 100755 index 0000000000..919d8f405c Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircle@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay@2x.png new file mode 100755 index 0000000000..a9b2d95d88 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/hitcircleoverlay@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0.png new file mode 100644 index 0000000000..316d52c685 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0@2x.png b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0@2x.png new file mode 100644 index 0000000000..e6f6b3c239 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/special-skin/sliderb0@2x.png differ diff --git a/osu.Game.Rulesets.Osu.Tests/StackingTest.cs b/osu.Game.Rulesets.Osu.Tests/StackingTest.cs index e8b99e86f9..871afdb09d 100644 --- a/osu.Game.Rulesets.Osu.Tests/StackingTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/StackingTest.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text; using NUnit.Framework; using osu.Game.Beatmaps; +using osu.Game.IO; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Tests.Beatmaps; @@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Tests public void TestStacking() { using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(beatmap_data))) - using (var reader = new StreamReader(stream)) + using (var reader = new LineBufferedReader(stream)) { var beatmap = Decoder.GetDecoder(reader).Decode(reader); var converted = new TestWorkingBeatmap(beatmap).GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty()); diff --git a/osu.Game.Rulesets.Osu.Tests/TestPlayfieldBorder.cs b/osu.Game.Rulesets.Osu.Tests/TestPlayfieldBorder.cs new file mode 100644 index 0000000000..23d9d265be --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestPlayfieldBorder.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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.UI; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestPlayfieldBorder : OsuTestScene + { + public TestPlayfieldBorder() + { + Bindable playfieldBorderStyle = new Bindable(); + + AddStep("add drawables", () => + { + Child = new Container + { + Size = new Vector2(400, 300), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new PlayfieldBorder + { + PlayfieldBorderStyle = { BindTarget = playfieldBorderStyle } + } + } + }; + }); + + AddStep("Set none", () => playfieldBorderStyle.Value = PlayfieldBorderStyle.None); + AddStep("Set corners", () => playfieldBorderStyle.Value = PlayfieldBorderStyle.Corners); + AddStep("Set full", () => playfieldBorderStyle.Value = PlayfieldBorderStyle.Full); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.cs new file mode 100644 index 0000000000..10d9d7ffde --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneAccuracyHeatmap.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 NUnit.Framework; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Statistics; +using osu.Game.Scoring; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneAccuracyHeatmap : OsuManualInputManagerTestScene + { + private Box background; + private Drawable object1; + private Drawable object2; + private TestAccuracyHeatmap accuracyHeatmap; + private ScheduledDelegate automaticAdditionDelegate; + + [SetUp] + public void Setup() => Schedule(() => + { + automaticAdditionDelegate?.Cancel(); + automaticAdditionDelegate = null; + + Children = new[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333"), + }, + object1 = new BorderCircle + { + Position = new Vector2(256, 192), + Colour = Color4.Yellow, + }, + object2 = new BorderCircle + { + Position = new Vector2(100, 300), + }, + accuracyHeatmap = new TestAccuracyHeatmap(new ScoreInfo { Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(130) + } + }; + }); + + [Test] + public void TestManyHitPointsAutomatic() + { + AddStep("add scheduled delegate", () => + { + automaticAdditionDelegate = Scheduler.AddDelayed(() => + { + var randomPos = new Vector2( + RNG.NextSingle(object1.DrawPosition.X - object1.DrawSize.X / 2, object1.DrawPosition.X + object1.DrawSize.X / 2), + RNG.NextSingle(object1.DrawPosition.Y - object1.DrawSize.Y / 2, object1.DrawPosition.Y + object1.DrawSize.Y / 2)); + + // The background is used for ToLocalSpace() since we need to go _inside_ the DrawSizePreservingContainer (Content of TestScene). + accuracyHeatmap.AddPoint(object2.Position, object1.Position, randomPos, RNG.NextSingle(10, 500)); + InputManager.MoveMouseTo(background.ToScreenSpace(randomPos)); + }, 1, true); + }); + + AddWaitStep("wait for some hit points", 10); + } + + [Test] + public void TestManualPlacement() + { + AddStep("return user input", () => InputManager.UseParentInput = true); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + accuracyHeatmap.AddPoint(object2.Position, object1.Position, background.ToLocalSpace(e.ScreenSpaceMouseDownPosition), 50); + return true; + } + + private class TestAccuracyHeatmap : AccuracyHeatmap + { + public TestAccuracyHeatmap(ScoreInfo score) + : base(score, new TestBeatmap(new OsuRuleset().RulesetInfo)) + { + } + + public new void AddPoint(Vector2 start, Vector2 end, Vector2 hitPoint, float radius) + => base.AddPoint(start, end, hitPoint, radius); + } + + private class BorderCircle : CircularContainer + { + public BorderCircle() + { + Origin = Anchor.Centre; + Size = new Vector2(100); + + Masking = true; + BorderThickness = 2; + BorderColour = Color4.White; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(4), + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs new file mode 100644 index 0000000000..dde02e873b --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.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 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.Textures; +using osu.Framework.Testing.Input; +using osu.Game.Audio; +using osu.Game.Rulesets.Osu.Skinning; +using osu.Game.Rulesets.Osu.UI.Cursor; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneCursorTrail : OsuTestScene + { + [Test] + public void TestSmoothCursorTrail() + { + Container scalingContainer = null; + + createTest(() => scalingContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Child = new CursorTrail() + }); + + AddStep("set large scale", () => scalingContainer.Scale = new Vector2(10)); + } + + [Test] + public void TestLegacySmoothCursorTrail() + { + createTest(() => new LegacySkinContainer(false) + { + Child = new LegacyCursorTrail() + }); + } + + [Test] + public void TestLegacyDisjointCursorTrail() + { + createTest(() => new LegacySkinContainer(true) + { + Child = new LegacyCursorTrail() + }); + } + + private void createTest(Func createContent) => AddStep("create trail", () => + { + Clear(); + + Add(new Container + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Child = new MovingCursorInputManager { Child = createContent?.Invoke() } + }); + }); + + [Cached(typeof(ISkinSource))] + private class LegacySkinContainer : Container, ISkinSource + { + private readonly bool disjoint; + + public LegacySkinContainer(bool disjoint) + { + this.disjoint = disjoint; + + RelativeSizeAxes = Axes.Both; + } + + public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotImplementedException(); + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + switch (componentName) + { + case "cursortrail": + var tex = new Texture(Texture.WhitePixel.TextureGL); + + if (disjoint) + tex.ScaleAdjust = 1 / 25f; + return tex; + + case "cursormiddle": + return disjoint ? null : Texture.WhitePixel; + } + + return null; + } + + public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + + public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + + public event Action SourceChanged + { + add { } + remove { } + } + } + + private class MovingCursorInputManager : ManualInputManager + { + public MovingCursorInputManager() + { + UseParentInput = false; + } + + protected override void Update() + { + base.Update(); + + const double spin_duration = 1000; + double currentTime = Time.Current; + + double angle = (currentTime % spin_duration) / spin_duration * 2 * Math.PI; + Vector2 rPos = new Vector2((float)Math.Cos(angle), (float)Math.Sin(angle)); + + MoveMouseTo(ToScreenSpace(DrawSize / 2 + DrawSize / 3 * rPos)); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs new file mode 100644 index 0000000000..646f12f710 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneDrawableJudgement.cs @@ -0,0 +1,112 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneDrawableJudgement : OsuSkinnableTestScene + { + [Resolved] + private OsuConfigManager config { get; set; } + + private readonly List> pools; + + public TestSceneDrawableJudgement() + { + pools = new List>(); + + foreach (HitResult result in Enum.GetValues(typeof(HitResult)).OfType().Skip(1)) + showResult(result); + } + + [Test] + public void TestHitLightingDisabled() + { + AddStep("hit lighting disabled", () => config.Set(OsuSetting.HitLighting, false)); + + showResult(HitResult.Great); + + AddUntilStep("judgements shown", () => this.ChildrenOfType().Any()); + AddAssert("judgement body immediately visible", + () => this.ChildrenOfType().All(judgement => judgement.JudgementBody.Alpha == 1)); + AddAssert("hit lighting hidden", + () => this.ChildrenOfType().All(judgement => judgement.Lighting.Alpha == 0)); + } + + [Test] + public void TestHitLightingEnabled() + { + AddStep("hit lighting enabled", () => config.Set(OsuSetting.HitLighting, true)); + + showResult(HitResult.Great); + + AddUntilStep("judgements shown", () => this.ChildrenOfType().Any()); + AddAssert("judgement body not immediately visible", + () => this.ChildrenOfType().All(judgement => judgement.JudgementBody.Alpha > 0 && judgement.JudgementBody.Alpha < 1)); + AddAssert("hit lighting shown", + () => this.ChildrenOfType().All(judgement => judgement.Lighting.Alpha > 0)); + } + + private void showResult(HitResult result) + { + AddStep("Show " + result.GetDescription(), () => + { + int poolIndex = 0; + + SetContents(() => + { + DrawablePool pool; + + if (poolIndex >= pools.Count) + pools.Add(pool = new DrawablePool(1)); + else + { + pool = pools[poolIndex]; + + // We need to make sure neither the pool nor the judgement get disposed when new content is set, and they both share the same parent. + ((Container)pool.Parent).Clear(false); + } + + var container = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + pool, + pool.Get(j => j.Apply(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null)).With(j => + { + j.Anchor = Anchor.Centre; + j.Origin = Anchor.Centre; + }) + } + }; + + poolIndex++; + return container; + }); + }); + } + + private class TestDrawableOsuJudgement : DrawableOsuJudgement + { + public new SkinnableSprite Lighting => base.Lighting; + public new Container JudgementBody => base.JudgementBody; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs new file mode 100644 index 0000000000..6c077eb214 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneFollowPoints.cs @@ -0,0 +1,276 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +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.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Connections; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneFollowPoints : OsuTestScene + { + private Container hitObjectContainer; + private FollowPointRenderer followPointRenderer; + + [SetUp] + public void Setup() => Schedule(() => + { + Children = new Drawable[] + { + hitObjectContainer = new TestHitObjectContainer { RelativeSizeAxes = Axes.Both }, + followPointRenderer = new FollowPointRenderer { RelativeSizeAxes = Axes.Both } + }; + }); + + [Test] + public void TestAddObject() + { + addObjectsStep(() => new OsuHitObject[] { new HitCircle { Position = new Vector2(100, 100) } }); + + assertGroups(); + } + + [Test] + public void TestRemoveObject() + { + addObjectsStep(() => new OsuHitObject[] { new HitCircle { Position = new Vector2(100, 100) } }); + + removeObjectStep(() => getObject(0)); + + assertGroups(); + } + + [Test] + public void TestAddMultipleObjects() + { + addMultipleObjectsStep(); + + assertGroups(); + } + + [Test] + public void TestRemoveEndObject() + { + addMultipleObjectsStep(); + + removeObjectStep(() => getObject(4)); + + assertGroups(); + } + + [Test] + public void TestRemoveStartObject() + { + addMultipleObjectsStep(); + + removeObjectStep(() => getObject(0)); + + assertGroups(); + } + + [Test] + public void TestRemoveMiddleObject() + { + addMultipleObjectsStep(); + + removeObjectStep(() => getObject(2)); + + assertGroups(); + } + + [Test] + public void TestMoveObject() + { + addMultipleObjectsStep(); + + AddStep("move hitobject", () => getObject(2).HitObject.Position = new Vector2(300, 100)); + + assertGroups(); + } + + [TestCase(0, 0)] // Start -> Start + [TestCase(0, 2)] // Start -> Middle + [TestCase(0, 5)] // Start -> End + [TestCase(2, 0)] // Middle -> Start + [TestCase(1, 3)] // Middle -> Middle (forwards) + [TestCase(3, 1)] // Middle -> Middle (backwards) + [TestCase(4, 0)] // End -> Start + [TestCase(4, 2)] // End -> Middle + [TestCase(4, 4)] // End -> End + public void TestReorderObjects(int startIndex, int endIndex) + { + addMultipleObjectsStep(); + + reorderObjectStep(startIndex, endIndex); + + assertGroups(); + } + + [Test] + public void TestStackedObjects() + { + addObjectsStep(() => new OsuHitObject[] + { + new HitCircle { Position = new Vector2(300, 100) }, + new HitCircle + { + Position = new Vector2(300, 300), + StackHeight = 20 + }, + }); + + assertDirections(); + } + + private void addMultipleObjectsStep() => addObjectsStep(() => new OsuHitObject[] + { + new HitCircle { Position = new Vector2(100, 100) }, + new HitCircle { Position = new Vector2(200, 200) }, + new HitCircle { Position = new Vector2(300, 300) }, + new HitCircle { Position = new Vector2(400, 400) }, + new HitCircle { Position = new Vector2(500, 500) }, + }); + + private void addObjectsStep(Func ctorFunc) + { + AddStep("add hitobjects", () => + { + var objects = ctorFunc(); + + for (int i = 0; i < objects.Length; i++) + { + objects[i].StartTime = Time.Current + 1000 + 500 * (i + 1); + objects[i].ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + DrawableOsuHitObject drawableObject = null; + + switch (objects[i]) + { + case HitCircle circle: + drawableObject = new DrawableHitCircle(circle); + break; + + case Slider slider: + drawableObject = new DrawableSlider(slider); + break; + + case Spinner spinner: + drawableObject = new DrawableSpinner(spinner); + break; + } + + hitObjectContainer.Add(drawableObject); + followPointRenderer.AddFollowPoints(objects[i]); + } + }); + } + + private void removeObjectStep(Func getFunc) + { + AddStep("remove hitobject", () => + { + var drawableObject = getFunc.Invoke(); + + hitObjectContainer.Remove(drawableObject); + followPointRenderer.RemoveFollowPoints(drawableObject.HitObject); + }); + } + + private void reorderObjectStep(int startIndex, int endIndex) + { + AddStep($"move object {startIndex} to {endIndex}", () => + { + DrawableOsuHitObject toReorder = getObject(startIndex); + + double targetTime; + if (endIndex < hitObjectContainer.Count) + targetTime = getObject(endIndex).HitObject.StartTime - 1; + else + targetTime = getObject(hitObjectContainer.Count - 1).HitObject.StartTime + 1; + + hitObjectContainer.Remove(toReorder); + toReorder.HitObject.StartTime = targetTime; + hitObjectContainer.Add(toReorder); + }); + } + + private void assertGroups() + { + AddAssert("has correct group count", () => followPointRenderer.Connections.Count == hitObjectContainer.Count); + AddAssert("group endpoints are correct", () => + { + for (int i = 0; i < hitObjectContainer.Count; i++) + { + DrawableOsuHitObject expectedStart = getObject(i); + DrawableOsuHitObject expectedEnd = i < hitObjectContainer.Count - 1 ? getObject(i + 1) : null; + + if (getGroup(i).Start != expectedStart.HitObject) + throw new AssertionException($"Object {i} expected to be the start of group {i}."); + + if (getGroup(i).End != expectedEnd?.HitObject) + throw new AssertionException($"Object {(expectedEnd == null ? "null" : i.ToString())} expected to be the end of group {i}."); + } + + return true; + }); + } + + private void assertDirections() + { + AddAssert("group directions are correct", () => + { + for (int i = 0; i < hitObjectContainer.Count; i++) + { + DrawableOsuHitObject expectedStart = getObject(i); + DrawableOsuHitObject expectedEnd = i < hitObjectContainer.Count - 1 ? getObject(i + 1) : null; + + if (expectedEnd == null) + continue; + + var points = getGroup(i).ChildrenOfType().ToArray(); + if (points.Length == 0) + continue; + + float expectedDirection = MathF.Atan2(expectedStart.Position.Y - expectedEnd.Position.Y, expectedStart.Position.X - expectedEnd.Position.X); + float realDirection = MathF.Atan2(expectedStart.Position.Y - points[^1].Position.Y, expectedStart.Position.X - points[^1].Position.X); + + if (!Precision.AlmostEquals(expectedDirection, realDirection)) + throw new AssertionException($"Expected group {i} in direction {expectedDirection}, but was {realDirection}."); + } + + return true; + }); + } + + private DrawableOsuHitObject getObject(int index) => hitObjectContainer[index]; + + private FollowPointConnection getGroup(int index) => followPointRenderer.Connections[index]; + + private class TestHitObjectContainer : Container + { + protected override int Compare(Drawable x, Drawable y) + { + var osuX = (DrawableOsuHitObject)x; + var osuY = (DrawableOsuHitObject)y; + + int compare = osuX.HitObject.StartTime.CompareTo(osuY.HitObject.StartTime); + + if (compare == 0) + return base.Compare(x, y); + + return compare; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index 1b1cfa89c0..461779b185 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -2,33 +2,144 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Cursor; -using osu.Game.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing.Input; +using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Rulesets.Osu.UI.Cursor; -using osu.Game.Rulesets.UI; -using osu.Game.Tests.Visual; +using osu.Game.Screens.Play; +using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneGameplayCursor : OsuTestScene, IProvideCursor + public class TestSceneGameplayCursor : OsuSkinnableTestScene { - private GameplayCursorContainer cursorContainer; + [Cached] + private GameplayBeatmap gameplayBeatmap; - public override IReadOnlyList RequiredTypes => new[] { typeof(CursorTrail) }; + private ClickingCursorContainer lastContainer; - public CursorContainer Cursor => cursorContainer; + [Resolved] + private OsuConfigManager config { get; set; } - public bool ProvidingUserCursor => true; + private Drawable background; - [BackgroundDependencyLoader] - private void load() + public TestSceneGameplayCursor() { - Add(cursorContainer = new OsuCursorContainer { RelativeSizeAxes = Axes.Both }); + gameplayBeatmap = new GameplayBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + + AddStep("change background colour", () => + { + background?.Expire(); + + Add(background = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Colour = new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1) + }); + }); + + AddSliderStep("circle size", 0f, 10f, 0f, val => + { + config.Set(OsuSetting.AutoCursorSize, true); + gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = val; + Scheduler.AddOnce(recreate); + }); + + AddStep("test cursor container", recreate); + + void recreate() => SetContents(() => new OsuInputManager(new OsuRuleset().RulesetInfo) { Child = new OsuCursorContainer() }); + } + + [TestCase(1, 1)] + [TestCase(5, 1)] + [TestCase(10, 1)] + [TestCase(1, 1.5f)] + [TestCase(5, 1.5f)] + [TestCase(10, 1.5f)] + public void TestSizing(int circleSize, float userScale) + { + AddStep($"set user scale to {userScale}", () => config.Set(OsuSetting.GameplayCursorSize, userScale)); + AddStep($"adjust cs to {circleSize}", () => gameplayBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = circleSize); + AddStep("turn on autosizing", () => config.Set(OsuSetting.AutoCursorSize, true)); + + 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)); + AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == OsuCursorContainer.GetScaleForCircleSize(circleSize)); + + AddStep("turn off autosizing", () => config.Set(OsuSetting.AutoCursorSize, false)); + AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == 1); + + AddStep($"set user scale to {userScale}", () => config.Set(OsuSetting.GameplayCursorSize, userScale)); + AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == userScale); + } + + private void loadContent() + { + SetContents(() => new MovingCursorInputManager + { + Child = lastContainer = new ClickingCursorContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + } + }); + } + + private class ClickingCursorContainer : OsuCursorContainer + { + private bool pressed; + + public bool Pressed + { + set + { + if (value == pressed) + return; + + pressed = value; + if (value) + OnPressed(OsuAction.LeftButton); + else + OnReleased(OsuAction.LeftButton); + } + } + + protected override void Update() + { + base.Update(); + Pressed = ((int)(Time.Current / 1000)) % 2 == 0; + } + } + + private class MovingCursorInputManager : ManualInputManager + { + public MovingCursorInputManager() + { + UseParentInput = false; + ShowVisualCursorGuide = false; + } + + protected override void Update() + { + base.Update(); + + const double spin_duration = 5000; + double currentTime = Time.Current; + + double angle = (currentTime % spin_duration) / spin_duration * 2 * Math.PI; + Vector2 rPos = new Vector2((float)Math.Cos(angle), (float)Math.Sin(angle)); + + MoveMouseTo(ToScreenSpace(DrawSize / 2 + DrawSize / 3 * rPos)); + } } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs index d44a0cd841..596bc06c68 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs @@ -7,10 +7,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Tests.Visual; using osuTK; -using System.Collections.Generic; -using System; using osu.Game.Rulesets.Mods; using System.Linq; using NUnit.Framework; @@ -19,39 +16,30 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneHitCircle : OsuTestScene + public class TestSceneHitCircle : OsuSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DrawableHitCircle) - }; - - private readonly Container content; - protected override Container Content => content; - private int depthIndex; - public TestSceneHitCircle() + [Test] + public void TestVariousHitCircles() { - base.Content.Add(content = new OsuInputManager(new RulesetInfo { ID = 0 })); - - AddStep("Miss Big Single", () => testSingle(2)); - AddStep("Miss Medium Single", () => testSingle(5)); - AddStep("Miss Small Single", () => testSingle(7)); - AddStep("Hit Big Single", () => testSingle(2, true)); - AddStep("Hit Medium Single", () => testSingle(5, true)); - AddStep("Hit Small Single", () => testSingle(7, true)); - AddStep("Miss Big Stream", () => testStream(2)); - AddStep("Miss Medium Stream", () => testStream(5)); - AddStep("Miss Small Stream", () => testStream(7)); - AddStep("Hit Big Stream", () => testStream(2, true)); - AddStep("Hit Medium Stream", () => testStream(5, true)); - AddStep("Hit Small Stream", () => 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 void testSingle(float circleSize, bool auto = false, double timeOffset = 0, Vector2? positionOffset = null) + private Drawable testSingle(float circleSize, bool auto = false, double timeOffset = 0, Vector2? positionOffset = null) { - positionOffset = positionOffset ?? Vector2.Zero; + positionOffset ??= Vector2.Zero; var circle = new HitCircle { @@ -61,27 +49,33 @@ namespace osu.Game.Rulesets.Osu.Tests circle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize }); - var drawable = new TestDrawableHitCircle(circle, auto) - { - Anchor = Anchor.Centre, - Depth = depthIndex++ - }; + var drawable = CreateDrawableHitCircle(circle, auto); - foreach (var mod in Mods.Value.OfType()) + foreach (var mod in SelectedMods.Value.OfType()) mod.ApplyToDrawableHitObjects(new[] { drawable }); - Add(drawable); + return drawable; } - private void testStream(float circleSize, bool auto = false) + protected virtual TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto) => new TestDrawableHitCircle(circle, auto) { + Anchor = Anchor.Centre, + Depth = depthIndex++ + }; + + private Drawable testStream(float circleSize, bool auto = false) + { + var container = new Container { RelativeSizeAxes = Axes.Both }; + Vector2 pos = new Vector2(-250, 0); for (int i = 0; i <= 1000; i += 100) { - testSingle(circleSize, auto, i, pos); + container.Add(testSingle(circleSize, auto, i, pos)); pos.X += 50; } + + return container; } protected class TestDrawableHitCircle : DrawableHitCircle diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.cs new file mode 100644 index 0000000000..8b3fead366 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleApplication.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 NUnit.Framework; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneHitCircleApplication : OsuTestScene + { + [Test] + public void TestApplyNewCircle() + { + DrawableHitCircle dho = null; + + AddStep("create circle", () => Child = dho = new DrawableHitCircle(prepareObject(new HitCircle + { + Position = new Vector2(256, 192), + IndexInCurrentCombo = 0 + })) + { + Clock = new FramedClock(new StopwatchClock()) + }); + + AddStep("apply new circle", () => dho.Apply(prepareObject(new HitCircle + { + Position = new Vector2(128, 128), + ComboIndex = 1, + }))); + } + + private HitCircle prepareObject(HitCircle circle) + { + circle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + return circle; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs new file mode 100644 index 0000000000..0649989dc0 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleArea.cs @@ -0,0 +1,103 @@ +// 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.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneHitCircleArea : OsuManualInputManagerTestScene + { + private HitCircle hitCircle; + private DrawableHitCircle drawableHitCircle; + private DrawableHitCircle.HitReceptor hitAreaReceptor => drawableHitCircle.HitArea; + + [SetUp] + public void SetUp() => Schedule(() => + { + hitCircle = new HitCircle + { + Position = new Vector2(100, 100), + StartTime = Time.Current + 500 + }; + + hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + Child = new SkinProvidingContainer(new DefaultSkin()) + { + RelativeSizeAxes = Axes.Both, + Child = drawableHitCircle = new DrawableHitCircle(hitCircle) + { + Size = new Vector2(100) + } + }; + }); + + [Test] + public void TestCircleHitCentre() + { + AddStep("move mouse to centre", () => InputManager.MoveMouseTo(hitAreaReceptor.ScreenSpaceDrawQuad.Centre)); + scheduleHit(); + + AddAssert("hit registered", () => hitAreaReceptor.HitAction == OsuAction.LeftButton); + } + + [Test] + public void TestCircleHitLeftEdge() + { + AddStep("move mouse to left edge", () => + { + var drawQuad = hitAreaReceptor.ScreenSpaceDrawQuad; + var mousePosition = new Vector2(drawQuad.TopLeft.X, drawQuad.Centre.Y); + + InputManager.MoveMouseTo(mousePosition); + }); + scheduleHit(); + + AddAssert("hit registered", () => hitAreaReceptor.HitAction == OsuAction.LeftButton); + } + + [TestCase(0.95f, OsuAction.LeftButton)] + [TestCase(1.05f, null)] + public void TestHitsCloseToEdge(float relativeDistanceFromCentre, OsuAction? expectedAction) + { + AddStep("move mouse to top left circle edge", () => + { + var drawQuad = hitAreaReceptor.ScreenSpaceDrawQuad; + // sqrt(2) / 2 = sin(45deg) = cos(45deg) + // draw width halved to get radius + float correction = relativeDistanceFromCentre * (float)Math.Sqrt(2) / 2 * (drawQuad.Width / 2); + var mousePosition = new Vector2(drawQuad.Centre.X - correction, drawQuad.Centre.Y - correction); + + InputManager.MoveMouseTo(mousePosition); + }); + scheduleHit(); + + AddAssert($"hit {(expectedAction == null ? "not " : string.Empty)}registered", () => hitAreaReceptor.HitAction == expectedAction); + } + + [Test] + public void TestCircleMissBoundingBoxCorner() + { + AddStep("move mouse to top left corner of bounding box", () => InputManager.MoveMouseTo(hitAreaReceptor.ScreenSpaceDrawQuad.TopLeft)); + scheduleHit(); + + AddAssert("hit not registered", () => hitAreaReceptor.HitAction == null); + } + + private void scheduleHit() => AddStep("schedule action", () => + { + var delay = hitCircle.StartTime - hitCircle.HitWindows.WindowFor(HitResult.Great) - Time.Current; + Scheduler.AddDelayed(() => hitAreaReceptor.OnPressed(OsuAction.LeftButton), delay); + }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.cs new file mode 100644 index 0000000000..5695462859 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.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 osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneHitCircleComboChange : TestSceneHitCircle + { + private readonly Bindable comboIndex = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + Scheduler.AddDelayed(() => comboIndex.Value++, 250, true); + } + + protected override TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto) + { + circle.ComboIndexBindable.BindTo(comboIndex); + circle.IndexInCurrentComboBindable.BindTo(comboIndex); + return base.CreateDrawableHitCircle(circle, auto); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs index 55c6b22146..45125204b6 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleHidden.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using osu.Game.Rulesets.Osu.Mods; @@ -12,11 +9,10 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneHitCircleHidden : TestSceneHitCircle { - public override IReadOnlyList RequiredTypes => base.RequiredTypes.Concat(new[] { typeof(OsuModHidden) }).ToList(); - - public TestSceneHitCircleHidden() + [SetUp] + public void SetUp() => Schedule(() => { - Mods.Value = new[] { new OsuModHidden() }; - } + SelectedMods.Value = new[] { new OsuModHidden() }; + }); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs index 399cf22599..8cf29ddfbf 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleLongCombo.cs @@ -4,19 +4,13 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Tests.Visual; using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneHitCircleLongCombo : PlayerTestScene + public class TestSceneHitCircleLongCombo : TestSceneOsuPlayer { - public TestSceneHitCircleLongCombo() - : base(new OsuRuleset()) - { - } - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) { var beatmap = new Beatmap @@ -29,7 +23,10 @@ namespace osu.Game.Rulesets.Osu.Tests }; for (int i = 0; i < 512; i++) - beatmap.HitObjects.Add(new HitCircle { Position = new Vector2(256, 192), StartTime = i * 100 }); + { + if (i % 32 < 20) + beatmap.HitObjects.Add(new HitCircle { Position = new Vector2(256, 192), StartTime = i * 100 }); + } return beatmap; } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleSelectionBlueprint.cs deleted file mode 100644 index 32043bf5d7..0000000000 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleSelectionBlueprint.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.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Tests.Visual; -using osuTK; - -namespace osu.Game.Rulesets.Osu.Tests -{ - public class TestSceneHitCircleSelectionBlueprint : SelectionBlueprintTestScene - { - private readonly DrawableHitCircle drawableObject; - - public TestSceneHitCircleSelectionBlueprint() - { - var hitCircle = new HitCircle { Position = new Vector2(256, 192) }; - hitCircle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); - - Add(drawableObject = new DrawableHitCircle(hitCircle)); - } - - protected override SelectionBlueprint CreateBlueprint() => new HitCircleSelectionBlueprint(drawableObject); - } -} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs new file mode 100644 index 0000000000..3ff37c4147 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs @@ -0,0 +1,154 @@ +// 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.Audio; +using osu.Framework.IO.Stores; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Play; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneLegacyBeatmapSkin : ScreenTestScene + { + [Resolved] + private AudioManager audio { get; set; } + + [TestCase(true)] + [TestCase(false)] + public void TestBeatmapComboColours(bool customSkinColoursPresent) + { + ExposedPlayer player = null; + + AddStep("load coloured beatmap", () => player = loadBeatmap(customSkinColoursPresent, true)); + AddUntilStep("wait for player", () => player.IsLoaded); + + AddAssert("is beatmap skin colours", () => player.UsableComboColours.SequenceEqual(TestBeatmapSkin.Colours)); + } + + [Test] + public void TestBeatmapNoComboColours() + { + ExposedPlayer player = null; + + AddStep("load no-colour beatmap", () => player = loadBeatmap(false, false)); + AddUntilStep("wait for player", () => player.IsLoaded); + + AddAssert("is default user skin colours", () => player.UsableComboColours.SequenceEqual(SkinConfiguration.DefaultComboColours)); + } + + [Test] + public void TestBeatmapNoComboColoursSkinOverride() + { + ExposedPlayer player = null; + + AddStep("load custom-skin colour", () => player = loadBeatmap(true, false)); + AddUntilStep("wait for player", () => player.IsLoaded); + + AddAssert("is custom user skin colours", () => player.UsableComboColours.SequenceEqual(TestSkin.Colours)); + } + + private ExposedPlayer loadBeatmap(bool userHasCustomColours, bool beatmapHasColours) + { + ExposedPlayer player; + + Beatmap.Value = new CustomSkinWorkingBeatmap(audio, beatmapHasColours); + + LoadScreen(player = new ExposedPlayer(userHasCustomColours)); + + return player; + } + + private class ExposedPlayer : Player + { + private readonly bool userHasCustomColours; + + public ExposedPlayer(bool userHasCustomColours) + : base(false, false) + { + this.userHasCustomColours = userHasCustomColours; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(new TestSkin(userHasCustomColours)); + return dependencies; + } + + public IReadOnlyList UsableComboColours => + GameplayClockContainer.ChildrenOfType() + .First() + .GetConfig>(GlobalSkinColours.ComboColours)?.Value; + } + + private class CustomSkinWorkingBeatmap : ClockBackedTestWorkingBeatmap + { + private readonly bool hasColours; + + public CustomSkinWorkingBeatmap(AudioManager audio, bool hasColours) + : base(new Beatmap + { + BeatmapInfo = + { + BeatmapSet = new BeatmapSetInfo(), + Ruleset = new OsuRuleset().RulesetInfo, + }, + HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } + }, null, null, audio) + { + this.hasColours = hasColours; + } + + protected override ISkin GetSkin() => new TestBeatmapSkin(BeatmapInfo, hasColours); + } + + private class TestBeatmapSkin : LegacyBeatmapSkin + { + public static Color4[] Colours { get; } = + { + new Color4(50, 100, 150, 255), + new Color4(40, 80, 120, 255), + }; + + public TestBeatmapSkin(BeatmapInfo beatmap, bool hasColours) + : base(beatmap, new ResourceStore(), null) + { + if (hasColours) + Configuration.AddComboColours(Colours); + } + } + + private class TestSkin : LegacySkin, ISkinSource + { + public static Color4[] Colours { get; } = + { + new Color4(150, 100, 50, 255), + new Color4(20, 20, 20, 255), + }; + + public TestSkin(bool hasCustomColours) + : base(new SkinInfo(), null, null, string.Empty) + { + if (hasCustomColours) + Configuration.AddComboColours(Colours); + } + + public event Action SourceChanged + { + add { } + remove { } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs new file mode 100644 index 0000000000..39deba2f57 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneMissHitWindowJudgements.cs @@ -0,0 +1,98 @@ +// 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.Replays; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Tests.Visual; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneMissHitWindowJudgements : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + [Test] + public void TestMissViaEarlyHit() + { + var beatmap = new Beatmap + { + HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } + }; + + var hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + + CreateModTest(new ModTestData + { + Autoplay = false, + Mod = new TestAutoMod(), + Beatmap = new Beatmap + { + HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } + }, + PassCondition = () => Player.Results.Count > 0 && Player.Results[0].TimeOffset < -hitWindows.WindowFor(HitResult.Meh) && !Player.Results[0].IsHit + }); + } + + [Test] + public void TestMissViaNotHitting() + { + var beatmap = new Beatmap + { + HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } + }; + + var hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + + CreateModTest(new ModTestData + { + Autoplay = false, + Beatmap = beatmap, + PassCondition = () => Player.Results.Count > 0 && Player.Results[0].TimeOffset >= hitWindows.WindowFor(HitResult.Meh) && !Player.Results[0].IsHit + }); + } + + private class TestAutoMod : OsuModAutoplay + { + public override Score CreateReplayScore(IBeatmap beatmap) => new Score + { + ScoreInfo = new ScoreInfo { User = new User { Username = "Autoplay" } }, + Replay = new MissingAutoGenerator(beatmap).Generate() + }; + } + + private class MissingAutoGenerator : OsuAutoGeneratorBase + { + public new OsuBeatmap Beatmap => (OsuBeatmap)base.Beatmap; + + public MissingAutoGenerator(IBeatmap beatmap) + : base(beatmap) + { + } + + public override Replay Generate() + { + 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))); + + AddFrameToReplay(new OsuReplayFrame(Beatmap.HitObjects[0].StartTime - 450, Beatmap.HitObjects[0].StackedPosition)); + AddFrameToReplay(new OsuReplayFrame(Beatmap.HitObjects[0].StartTime - 350, Beatmap.HitObjects[0].StackedPosition, OsuAction.LeftButton)); + AddFrameToReplay(new OsuReplayFrame(Beatmap.HitObjects[0].StartTime - 325, Beatmap.HitObjects[0].StackedPosition)); + + return Replay; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs index 64e7632b1b..19736a7709 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuFlashlight.cs @@ -3,15 +3,15 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneOsuFlashlight : TestSceneOsuPlayer { - protected override Player CreatePlayer(Ruleset ruleset) + protected override TestPlayer CreatePlayer(Ruleset ruleset) { - Mods.Value = new Mod[] { new OsuModAutoplay(), new OsuModFlashlight(), }; + SelectedMods.Value = new Mod[] { new OsuModAutoplay(), new OsuModFlashlight(), }; return base.CreatePlayer(ruleset); } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs index 0a33b09ba8..f5b28b36c0 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuPlayer.cs @@ -9,9 +9,6 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneOsuPlayer : PlayerTestScene { - public TestSceneOsuPlayer() - : base(new OsuRuleset()) - { - } + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs new file mode 100644 index 0000000000..32a36ab317 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs @@ -0,0 +1,447 @@ +// 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.Extensions.TypeExtensions; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Replays; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneOutOfOrderHits : RateAdjustedBeatmapTestScene + { + private const double early_miss_window = 1000; // time after -1000 to -500 is considered a miss + private const double late_miss_window = 500; // time after +500 is considered a miss + + /// + /// Tests clicking a future circle before the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleBeforeFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Miss); + addJudgementOffsetAssert(hitObjects[0], late_miss_window); + } + + /// + /// Tests clicking a future circle at the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleAtFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], 0); + } + + /// + /// Tests clicking a future circle after the first circle's start time, while the first circle HAS NOT been judged. + /// + [Test] + public void TestClickSecondCircleAfterFirstCircleTime() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle + 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Miss); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], 100); + } + + /// + /// Tests clicking a future circle before the first circle's start time, while the first circle HAS been judged. + /// + [Test] + public void TestClickSecondCircleBeforeFirstCircleTimeWithFirstCircleJudged() + { + const double time_first_circle = 1500; + const double time_second_circle = 1600; + Vector2 positionFirstCircle = Vector2.Zero; + Vector2 positionSecondCircle = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_first_circle, + Position = positionFirstCircle + }, + new TestHitCircle + { + StartTime = time_second_circle, + Position = positionSecondCircle + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_first_circle - 200, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.RightButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + addJudgementOffsetAssert(hitObjects[0], -200); // time_first_circle - 200 + addJudgementOffsetAssert(hitObjects[0], -200); // time_second_circle - first_circle_time - 100 + } + + /// + /// Tests clicking a future circle after a slider's start time, but hitting all slider ticks. + /// + [Test] + public void TestMissSliderHeadAndHitAllSliderTicks() + { + const double time_slider = 1500; + const double time_circle = 1510; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_slider, Position = positionCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_slider + 10, Position = positionSlider, Actions = { OsuAction.RightButton } } + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.IgnoreHit); + addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.Miss); + addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit); + } + + /// + /// Tests clicking hitting future slider ticks before a circle. + /// + [Test] + public void TestHitSliderTicksBeforeCircle() + { + const double time_slider = 1500; + const double time_circle = 1510; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(30); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_circle + late_miss_window - 100, Position = positionCircle, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_circle + late_miss_window - 90, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.IgnoreHit); + addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.Great); + addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit); + } + + /// + /// Tests clicking a future circle before a spinner. + /// + [Test] + public void TestHitCircleBeforeSpinner() + { + const double time_spinner = 1500; + const double time_circle = 1800; + Vector2 positionCircle = Vector2.Zero; + + var hitObjects = new List + { + new TestSpinner + { + StartTime = time_spinner, + Position = new Vector2(256, 192), + EndTime = time_spinner + 1000, + }, + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_spinner - 100, Position = positionCircle, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + } + + [Test] + public void TestHitSliderHeadBeforeHitCircle() + { + const double time_circle = 1000; + const double time_slider = 1200; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_circle - 100, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_circle, Position = positionCircle, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.IgnoreHit); + } + + private void addJudgementAssert(OsuHitObject hitObject, HitResult result) + { + AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", + () => judgementResults.Single(r => r.HitObject == hitObject).Type == result); + } + + private void addJudgementAssert(string name, Func hitObject, HitResult result) + { + AddAssert($"{name} judgement is {result}", + () => judgementResults.Single(r => r.HitObject == hitObject()).Type == result); + } + + private void addJudgementOffsetAssert(OsuHitObject hitObject, double offset) + { + AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judged at {offset}", + () => Precision.AlmostEquals(judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, offset, 100)); + } + + private ScoreAccessibleReplayPlayer currentPlayer; + private List judgementResults; + + private void performTest(List hitObjects, List frames) + { + AddStep("load player", () => + { + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + HitObjects = hitObjects, + BeatmapInfo = + { + BaseDifficulty = new BeatmapDifficulty { SliderTickRate = 3 }, + Ruleset = new OsuRuleset().RulesetInfo + }, + }); + + Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); + + p.OnLoadComplete += _ => + { + p.ScoreProcessor.NewJudgement += result => + { + if (currentPlayer == p) judgementResults.Add(result); + }; + }; + + LoadScreen(currentPlayer = p); + judgementResults = new List(); + }); + + 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); + } + + private class TestHitCircle : HitCircle + { + protected override HitWindows CreateHitWindows() => new TestHitWindows(); + } + + private class TestSlider : Slider + { + public TestSlider() + { + DefaultsApplied += _ => + { + HeadCircle.HitWindows = new TestHitWindows(); + TailCircle.HitWindows = new TestHitWindows(); + + HeadCircle.HitWindows.SetDifficulty(0); + TailCircle.HitWindows.SetDifficulty(0); + }; + } + } + + private class TestSpinner : Spinner + { + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + { + base.ApplyDefaultsToSelf(controlPointInfo, difficulty); + SpinsRequired = 1; + } + } + + private class TestHitWindows : HitWindows + { + private static readonly DifficultyRange[] ranges = + { + new DifficultyRange(HitResult.Great, 500, 500, 500), + new DifficultyRange(HitResult.Miss, early_miss_window, early_miss_window, early_miss_window), + }; + + public override bool IsHitResultAllowed(HitResult result) => result == HitResult.Great || result == HitResult.Miss; + + protected override DifficultyRange[] GetRanges() => ranges; + } + + private class ScoreAccessibleReplayPlayer : ReplayPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + protected override bool PauseOnFocusLost => false; + + public ScoreAccessibleReplayPlayer(Score score) + : base(score, false, false) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs index 8e73d6152f..a7967c407a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneResumeOverlay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -12,13 +10,8 @@ using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Osu.Tests { - public class TestSceneResumeOverlay : ManualInputManagerTestScene + public class TestSceneResumeOverlay : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(OsuResumeOverlay), - }; - public TestSceneResumeOverlay() { ManualOsuInputManager osuInputManager; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs index 3d8afd66f4..d692be89b2 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs @@ -1,23 +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.Framework.Graphics; -using osu.Framework.MathUtils; +using System.Diagnostics; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneShaking : TestSceneHitCircle { - public override void Add(Drawable drawable) + protected override TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto) { - base.Add(drawable); + var drawableHitObject = base.CreateDrawableHitCircle(circle, auto); - if (drawable is TestDrawableHitCircle hitObject) - { - Scheduler.AddDelayed(() => hitObject.TriggerJudgement(), - hitObject.HitObject.StartTime - (hitObject.HitObject.HitWindows.HalfWindowFor(HitResult.Miss) + RNG.Next(0, 300)) - Time.Current); - } + Debug.Assert(drawableHitObject.HitObject.HitWindows != null); + + double delay = drawableHitObject.HitObject.StartTime - (drawableHitObject.HitObject.HitWindows.WindowFor(HitResult.Miss) + RNG.Next(0, 300)) - Time.Current; + Scheduler.AddDelayed(() => drawableHitObject.TriggerJudgement(), delay); + + return drawableHitObject; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs new file mode 100644 index 0000000000..075bf314bc --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs @@ -0,0 +1,160 @@ +// 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.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Timing; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class TestSceneSkinFallbacks : TestSceneOsuPlayer + { + private readonly TestSource testUserSkin; + private readonly TestSource testBeatmapSkin; + + public TestSceneSkinFallbacks() + { + testUserSkin = new TestSource("user"); + testBeatmapSkin = new TestSource("beatmap"); + } + + [Test] + public void TestBeatmapSkinDefault() + { + AddStep("enable user provider", () => testUserSkin.Enabled = true); + + AddStep("enable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, true)); + checkNextHitObject("beatmap"); + + AddStep("disable beatmap skin", () => LocalConfig.Set(OsuSetting.BeatmapSkins, false)); + checkNextHitObject("user"); + + AddStep("disable user provider", () => testUserSkin.Enabled = false); + checkNextHitObject(null); + } + + private void checkNextHitObject(string skin) => + AddUntilStep($"check skin from {skin}", () => + { + var firstObject = Player.DrawableRuleset.Playfield.HitObjectContainer.AliveObjects.OfType().FirstOrDefault(); + + if (firstObject == null) + return false; + + var skinnable = firstObject.ApproachCircle.Child as SkinnableDrawable; + + if (skin == null && skinnable?.Drawable is Sprite) + // check for default skin provider + return true; + + var text = skinnable?.Drawable as SpriteText; + + return text?.Text == skin; + }); + + [Resolved] + private AudioManager audio { get; set; } + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new SkinProvidingPlayer(testUserSkin); + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => new CustomSkinWorkingBeatmap(beatmap, storyboard, Clock, audio, testBeatmapSkin); + + public class CustomSkinWorkingBeatmap : ClockBackedTestWorkingBeatmap + { + private readonly ISkinSource skin; + + public CustomSkinWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock frameBasedClock, AudioManager audio, ISkinSource skin) + : base(beatmap, storyboard, frameBasedClock, audio) + { + this.skin = skin; + } + + protected override ISkin GetSkin() => skin; + } + + public class SkinProvidingPlayer : TestPlayer + { + private readonly TestSource userSkin; + + public SkinProvidingPlayer(TestSource userSkin) + { + this.userSkin = userSkin; + } + + private DependencyContainer dependencies; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + dependencies.CacheAs(userSkin); + + return dependencies; + } + } + + public class TestSource : ISkinSource + { + private readonly string identifier; + + public TestSource(string identifier) + { + this.identifier = identifier; + } + + public Drawable GetDrawableComponent(ISkinComponent component) + { + if (!enabled) return null; + + return new OsuSpriteText + { + Text = identifier, + Font = OsuFont.Default.With(size: 30), + }; + } + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; + + public SampleChannel GetSample(ISampleInfo sampleInfo) => null; + + public TValue GetValue(Func query) where TConfiguration : SkinConfiguration => default; + public IBindable GetConfig(TLookup lookup) => null; + + public event Action SourceChanged; + + private bool enabled = true; + + public bool Enabled + { + get => enabled; + set + { + if (value == enabled) + return; + + enabled = value; + SourceChanged?.Invoke(); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index c5a27205d6..c400e2f2ea 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -1,16 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Tests.Visual; using osuTK; using osuTK.Graphics; using osu.Game.Rulesets.Mods; @@ -22,92 +19,153 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSlider : OsuTestScene + public class TestSceneSlider : OsuSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SliderBall), - typeof(SliderBody), - typeof(SliderTick), - typeof(DrawableSlider), - typeof(DrawableSliderTick), - typeof(DrawableRepeatPoint), - typeof(DrawableOsuHitObject) - }; - - private readonly Container content; - protected override Container Content => content; - private int depthIndex; - public TestSceneSlider() + [Test] + public void TestVariousSliders() { - base.Content.Add(content = new OsuInputManager(new RulesetInfo { ID = 0 })); + 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", () => testSimpleBig()); - AddStep("Medium Single", () => testSimpleMedium()); - AddStep("Small Single", () => testSimpleSmall()); - AddStep("Big 1 Repeat", () => testSimpleBig(1)); - AddStep("Medium 1 Repeat", () => testSimpleMedium(1)); - AddStep("Small 1 Repeat", () => testSimpleSmall(1)); - AddStep("Big 2 Repeats", () => testSimpleBig(2)); - AddStep("Medium 2 Repeats", () => testSimpleMedium(2)); - AddStep("Small 2 Repeats", () => 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", testSlowSpeed); // slow long sliders take ages already so no repeat steps - AddStep("Slow Short Slider", () => testShortSlowSpeed()); - AddStep("Slow Short Slider 1 Repeats", () => testShortSlowSpeed(1)); - AddStep("Slow Short Slider 2 Repeats", () => 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", () => testHighSpeed()); - AddStep("Fast Slider 1 Repeat", () => testHighSpeed(1)); - AddStep("Fast Slider 2 Repeats", () => testHighSpeed(2)); - AddStep("Fast Short Slider", () => testShortHighSpeed()); - AddStep("Fast Short Slider 1 Repeat", () => testShortHighSpeed(1)); - AddStep("Fast Short Slider 2 Repeats", () => testShortHighSpeed(2)); - AddStep("Fast Short Slider 6 Repeats", () => 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", () => testPerfect()); - AddStep("Perfect Curve 1 Repeat", () => testPerfect(1)); - AddStep("Perfect Curve 2 Repeats", () => 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", () => testLinear()); - AddStep("Linear Slider 1 Repeat", () => testLinear(1)); - AddStep("Linear Slider 2 Repeats", () => 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", () => testBezier()); - AddStep("Bezier Slider 1 Repeat", () => testBezier(1)); - AddStep("Bezier Slider 2 Repeats", () => 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", () => testLinearOverlapping()); - AddStep("Linear Overlapping 1 Repeat", () => testLinearOverlapping(1)); - AddStep("Linear Overlapping 2 Repeats", () => 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", () => testCatmull()); - AddStep("Catmull Slider 1 Repeat", () => testCatmull(1)); - AddStep("Catmull Slider 2 Repeats", () => testCatmull(2)); + AddStep("Big Single, Large StackOffset", () => SetContents(() => testSimpleBigLargeStackOffset())); + AddStep("Big 1 Repeat, Large StackOffset", () => SetContents(() => testSimpleBigLargeStackOffset(1))); - AddStep("Big Single, Large StackOffset", () => testSimpleBigLargeStackOffset()); - AddStep("Big 1 Repeat, Large StackOffset", () => testSimpleBigLargeStackOffset(1)); - - AddStep("Distance Overflow", () => testDistanceOverflow()); - AddStep("Distance Overflow 1 Repeat", () => testDistanceOverflow(1)); + AddStep("Distance Overflow", () => SetContents(() => testDistanceOverflow())); + AddStep("Distance Overflow 1 Repeat", () => SetContents(() => testDistanceOverflow(1))); } - private void testSimpleBig(int repeats = 0) => createSlider(2, repeats: repeats); + [Test] + public void TestChangeStackHeight() + { + DrawableSlider slider = null; - private void testSimpleBigLargeStackOffset(int repeats = 0) => createSlider(2, repeats: repeats, stackHeight: 10); + AddStep("create slider", () => + { + slider = (DrawableSlider)createSlider(repeats: 1); + Add(slider); + }); - private void testDistanceOverflow(int repeats = 0) + AddStep("change stack height", () => slider.HitObject.StackHeight = 10); + AddAssert("body positioned correctly", () => slider.Position == slider.HitObject.StackedPosition); + } + + [Test] + public void TestChangeSamplesWithNoNodeSamples() + { + DrawableSlider slider = null; + + AddStep("create slider", () => + { + slider = (DrawableSlider)createSlider(repeats: 1); + Add(slider); + }); + + AddStep("change samples", () => slider.HitObject.Samples = new[] + { + new HitSampleInfo { Name = HitSampleInfo.HIT_CLAP }, + new HitSampleInfo { Name = HitSampleInfo.HIT_WHISTLE }, + }); + + AddAssert("head samples updated", () => assertSamples(slider.HitObject.HeadCircle)); + AddAssert("tick samples not updated", () => slider.HitObject.NestedHitObjects.OfType().All(assertTickSamples)); + AddAssert("repeat samples updated", () => slider.HitObject.NestedHitObjects.OfType().All(assertSamples)); + AddAssert("tail has no samples", () => slider.HitObject.TailCircle.Samples.Count == 0); + + static bool assertTickSamples(SliderTick tick) => tick.Samples.Single().Name == "slidertick"; + + static bool assertSamples(HitObject hitObject) + { + return hitObject.Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP) + && hitObject.Samples.Any(s => s.Name == HitSampleInfo.HIT_WHISTLE); + } + } + + [Test] + public void TestChangeSamplesWithNodeSamples() + { + DrawableSlider slider = null; + + AddStep("create slider", () => + { + slider = (DrawableSlider)createSlider(repeats: 1); + + for (int i = 0; i < 2; i++) + slider.HitObject.NodeSamples.Add(new List { new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH } }); + + Add(slider); + }); + + AddStep("change samples", () => slider.HitObject.Samples = new[] + { + new HitSampleInfo { Name = HitSampleInfo.HIT_CLAP }, + new HitSampleInfo { Name = HitSampleInfo.HIT_WHISTLE }, + }); + + AddAssert("head samples not updated", () => assertSamples(slider.HitObject.HeadCircle)); + AddAssert("tick samples not updated", () => slider.HitObject.NestedHitObjects.OfType().All(assertTickSamples)); + AddAssert("repeat samples not updated", () => slider.HitObject.NestedHitObjects.OfType().All(assertSamples)); + AddAssert("tail has no samples", () => slider.HitObject.TailCircle.Samples.Count == 0); + + static bool assertTickSamples(SliderTick tick) => tick.Samples.Single().Name == "slidertick"; + + static bool assertSamples(HitObject hitObject) => hitObject.Samples.All(s => s.Name != HitSampleInfo.HIT_CLAP && s.Name != HitSampleInfo.HIT_WHISTLE); + } + + private Drawable testSimpleBig(int repeats = 0) => createSlider(2, repeats: repeats); + + private Drawable testSimpleBigLargeStackOffset(int repeats = 0) => createSlider(2, repeats: repeats, stackHeight: 10); + + private Drawable testDistanceOverflow(int repeats = 0) { var slider = new Slider { - StartTime = Time.Current + 1000, + StartTime = Time.Current + time_offset, Position = new Vector2(239, 176), Path = new SliderPath(PathType.PerfectCurve, new[] { @@ -116,195 +174,186 @@ namespace osu.Game.Rulesets.Osu.Tests new Vector2(52, -34) }, 700), RepeatCount = repeats, - NodeSamples = createEmptySamples(repeats), StackHeight = 10 }; - addSlider(slider, 2, 2); + return createDrawable(slider, 2, 2); } - private void testSimpleMedium(int repeats = 0) => createSlider(5, repeats: repeats); + private Drawable testSimpleMedium(int repeats = 0) => createSlider(5, repeats: repeats); - private void testSimpleSmall(int repeats = 0) => createSlider(7, repeats: repeats); + private Drawable testSimpleSmall(int repeats = 0) => createSlider(7, repeats: repeats); - private void testSlowSpeed() => createSlider(speedMultiplier: 0.5); + private Drawable testSlowSpeed() => createSlider(speedMultiplier: 0.5); - private void testShortSlowSpeed(int repeats = 0) => createSlider(distance: 100, repeats: repeats, speedMultiplier: 0.5); + private Drawable testShortSlowSpeed(int repeats = 0) => createSlider(distance: max_length / 4, repeats: repeats, speedMultiplier: 0.5); - private void testHighSpeed(int repeats = 0) => createSlider(repeats: repeats, speedMultiplier: 15); + private Drawable testHighSpeed(int repeats = 0) => createSlider(repeats: repeats, speedMultiplier: 15); - private void testShortHighSpeed(int repeats = 0) => createSlider(distance: 100, repeats: repeats, speedMultiplier: 15); + private Drawable testShortHighSpeed(int repeats = 0) => createSlider(distance: max_length / 4, repeats: repeats, speedMultiplier: 15); - private void createSlider(float circleSize = 2, float distance = 400, int repeats = 0, double speedMultiplier = 2, int stackHeight = 0) + private const double time_offset = 1500; + + private const float max_length = 200; + + private Drawable createSlider(float circleSize = 2, float distance = max_length, int repeats = 0, double speedMultiplier = 2, int stackHeight = 0) { var slider = new Slider { - StartTime = Time.Current + 1000, - Position = new Vector2(-(distance / 2), 0), + StartTime = Time.Current + time_offset, + Position = new Vector2(0, -(distance / 2)), Path = new SliderPath(PathType.PerfectCurve, new[] { Vector2.Zero, - new Vector2(distance, 0), + new Vector2(0, distance), }, distance), RepeatCount = repeats, - NodeSamples = createEmptySamples(repeats), StackHeight = stackHeight }; - addSlider(slider, circleSize, speedMultiplier); + return createDrawable(slider, circleSize, speedMultiplier); } - private void testPerfect(int repeats = 0) + private Drawable testPerfect(int repeats = 0) { var slider = new Slider { - StartTime = Time.Current + 1000, - Position = new Vector2(-200, 0), + StartTime = Time.Current + time_offset, + Position = new Vector2(-max_length / 2, 0), Path = new SliderPath(PathType.PerfectCurve, new[] { Vector2.Zero, - new Vector2(200, 200), - new Vector2(400, 0) - }, 600), + new Vector2(max_length / 2, max_length / 2), + new Vector2(max_length, 0) + }, max_length * 1.5f), RepeatCount = repeats, - NodeSamples = createEmptySamples(repeats) }; - addSlider(slider, 2, 3); + return createDrawable(slider, 2, 3); } - private void testLinear(int repeats = 0) => createLinear(repeats); + private Drawable testLinear(int repeats = 0) => createLinear(repeats); - private void createLinear(int repeats) + private Drawable createLinear(int repeats) { var slider = new Slider { - StartTime = Time.Current + 1000, - Position = new Vector2(-200, 0), + StartTime = Time.Current + time_offset, + Position = new Vector2(-max_length / 2, 0), Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, - new Vector2(150, 75), - new Vector2(200, 0), - new Vector2(300, -200), - new Vector2(400, 0), - new Vector2(430, 0) + new Vector2(max_length * 0.375f, max_length * 0.18f), + new Vector2(max_length / 2, 0), + new Vector2(max_length * 0.75f, -max_length / 2), + new Vector2(max_length * 0.95f, 0), + new Vector2(max_length, 0) }), RepeatCount = repeats, - NodeSamples = createEmptySamples(repeats) }; - addSlider(slider, 2, 3); + return createDrawable(slider, 2, 3); } - private void testBezier(int repeats = 0) => createBezier(repeats); + private Drawable testBezier(int repeats = 0) => createBezier(repeats); - private void createBezier(int repeats) + private Drawable createBezier(int repeats) { var slider = new Slider { - StartTime = Time.Current + 1000, - Position = new Vector2(-200, 0), + StartTime = Time.Current + time_offset, + Position = new Vector2(-max_length / 2, 0), Path = new SliderPath(PathType.Bezier, new[] { Vector2.Zero, - new Vector2(150, 75), - new Vector2(200, 100), - new Vector2(300, -200), - new Vector2(430, 0) + new Vector2(max_length * 0.375f, max_length * 0.18f), + new Vector2(max_length / 2, max_length / 4), + new Vector2(max_length * 0.75f, -max_length / 2), + new Vector2(max_length, 0) }), RepeatCount = repeats, - NodeSamples = createEmptySamples(repeats) }; - addSlider(slider, 2, 3); + return createDrawable(slider, 2, 3); } - private void testLinearOverlapping(int repeats = 0) => createOverlapping(repeats); + private Drawable testLinearOverlapping(int repeats = 0) => createOverlapping(repeats); - private void createOverlapping(int repeats) + private Drawable createOverlapping(int repeats) { var slider = new Slider { - StartTime = Time.Current + 1000, + StartTime = Time.Current + time_offset, Position = new Vector2(0, 0), Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, - new Vector2(-200, 0), + new Vector2(-max_length / 2, 0), new Vector2(0, 0), - new Vector2(0, -200), - new Vector2(-200, -200), - new Vector2(0, -200) + new Vector2(0, -max_length / 2), + new Vector2(-max_length / 2, -max_length / 2), + new Vector2(0, -max_length / 2) }), RepeatCount = repeats, - NodeSamples = createEmptySamples(repeats) }; - addSlider(slider, 2, 3); + return createDrawable(slider, 2, 3); } - private void testCatmull(int repeats = 0) => createCatmull(repeats); + private Drawable testCatmull(int repeats = 0) => createCatmull(repeats); - private void createCatmull(int repeats = 0) + private Drawable createCatmull(int repeats = 0) { - var repeatSamples = new List>(); + var repeatSamples = new List>(); for (int i = 0; i < repeats; i++) repeatSamples.Add(new List()); var slider = new Slider { - StartTime = Time.Current + 1000, - Position = new Vector2(-100, 0), + StartTime = Time.Current + time_offset, + Position = new Vector2(-max_length / 4, 0), Path = new SliderPath(PathType.Catmull, new[] { Vector2.Zero, - new Vector2(50, -50), - new Vector2(150, 50), - new Vector2(200, 0) + new Vector2(max_length * 0.125f, max_length * 0.125f), + new Vector2(max_length * 0.375f, max_length * 0.125f), + new Vector2(max_length / 2, 0) }), RepeatCount = repeats, NodeSamples = repeatSamples }; - addSlider(slider, 3, 1); + return createDrawable(slider, 3, 1); } - private List> createEmptySamples(int repeats) - { - var repeatSamples = new List>(); - for (int i = 0; i < repeats; i++) - repeatSamples.Add(new List()); - return repeatSamples; - } - - private void addSlider(Slider slider, float circleSize, double speedMultiplier) + private Drawable createDrawable(Slider slider, float circleSize, double speedMultiplier) { var cpi = new ControlPointInfo(); - cpi.DifficultyPoints.Add(new DifficultyControlPoint { SpeedMultiplier = speedMultiplier }); + cpi.Add(0, new DifficultyControlPoint { SpeedMultiplier = speedMultiplier }); slider.ApplyDefaults(cpi, new BeatmapDifficulty { CircleSize = circleSize, SliderTickRate = 3 }); - var drawable = new DrawableSlider(slider) - { - Anchor = Anchor.Centre, - Depth = depthIndex++ - }; + var drawable = CreateDrawableSlider(slider); - foreach (var mod in Mods.Value.OfType()) + foreach (var mod in SelectedMods.Value.OfType()) mod.ApplyToDrawableHitObjects(new[] { drawable }); drawable.OnNewResult += onNewResult; - Add(drawable); + return drawable; } + protected virtual DrawableSlider CreateDrawableSlider(Slider slider) => new DrawableSlider(slider) + { + Anchor = Anchor.Centre, + Depth = depthIndex++ + }; + private float judgementOffsetDirection = 1; private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) { - var osuObject = judgedObject as DrawableOsuHitObject; - if (osuObject == null) + if (!(judgedObject is DrawableOsuHitObject osuObject)) return; OsuSpriteText text; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs new file mode 100644 index 0000000000..f76c7e2a3e --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.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 NUnit.Framework; +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.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneSliderApplication : OsuTestScene + { + [Test] + public void TestApplyNewSlider() + { + DrawableSlider dho = null; + + AddStep("create slider", () => Child = dho = new DrawableSlider(prepareObject(new Slider + { + Position = new Vector2(256, 192), + IndexInCurrentCombo = 0, + StartTime = Time.Current, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(150, 100), + new Vector2(300, 0), + }) + }))); + + AddWaitStep("wait for progression", 1); + + AddStep("apply new slider", () => dho.Apply(prepareObject(new Slider + { + Position = new Vector2(256, 192), + ComboIndex = 1, + StartTime = dho.HitObject.StartTime, + Path = new SliderPath(PathType.Bezier, new[] + { + Vector2.Zero, + new Vector2(150, 100), + new Vector2(300, 0), + }), + RepeatCount = 1 + }))); + } + + private Slider prepareObject(Slider slider) + { + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + return slider; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderComboChange.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderComboChange.cs new file mode 100644 index 0000000000..13ced3019e --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderComboChange.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 osu.Framework.Bindables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneSliderComboChange : TestSceneSlider + { + private readonly Bindable comboIndex = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + Scheduler.AddDelayed(() => comboIndex.Value++, 250, true); + } + + protected override DrawableSlider CreateDrawableSlider(Slider slider) + { + slider.ComboIndexBindable.BindTo(comboIndex); + slider.IndexInCurrentComboBindable.BindTo(comboIndex); + + return base.CreateDrawableSlider(slider); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs index 2a9c1d167b..b2bd727c6a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderHidden.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using osu.Game.Rulesets.Osu.Mods; @@ -12,11 +9,10 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneSliderHidden : TestSceneSlider { - public override IReadOnlyList RequiredTypes => base.RequiredTypes.Concat(new[] { typeof(OsuModHidden) }).ToList(); - - public TestSceneSliderHidden() + [SetUp] + public void SetUp() => Schedule(() => { - Mods.Value = new[] { new OsuModHidden() }; - } + SelectedMods.Value = new[] { new OsuModHidden() }; + }); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 2eb783233a..0164fb8bf4 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -13,8 +12,6 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; @@ -27,26 +24,15 @@ namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneSliderInput : RateAdjustedBeatmapTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SliderBall), - typeof(DrawableSlider), - typeof(DrawableSliderTick), - typeof(DrawableRepeatPoint), - typeof(DrawableOsuHitObject), - typeof(DrawableSliderHead), - typeof(DrawableSliderTail), - }; - private const double time_before_slider = 250; private const double time_slider_start = 1500; private const double time_during_slide_1 = 2500; private const double time_during_slide_2 = 3000; private const double time_during_slide_3 = 3500; private const double time_during_slide_4 = 3800; + private const double time_slider_end = 4000; private List judgementResults; - private bool allJudgedFired; /// /// Scenario: @@ -87,7 +73,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_2 }, }); - AddAssert("Tracking retained", assertGreatJudge); + AddAssert("Tracking retained", assertMaxJudge); } /// @@ -108,7 +94,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_1 }, }); - AddAssert("Tracking retained", assertGreatJudge); + AddAssert("Tracking retained", assertMaxJudge); } /// @@ -129,7 +115,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_1 }, }); - AddAssert("Tracking retained", assertGreatJudge); + AddAssert("Tracking retained", assertMaxJudge); } /// @@ -284,16 +270,60 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("Tracking acquired", assertMidSliderJudgements); } - private bool assertGreatJudge() => judgementResults.Last().Type == HitResult.Great; + /// + /// Scenario: + /// - Press a key on the slider head + /// - While holding the key, move cursor close to the edge of tracking area + /// - Keep the cursor on the edge of tracking area until the slider ends + /// Expected Result: + /// A passing test case will have the slider track the cursor throughout the whole test. + /// + [Test] + public void TestTrackingAreaEdge() + { + performTest(new List + { + new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_slider_start }, + new OsuReplayFrame { Position = new Vector2(0, OsuHitObject.OBJECT_RADIUS * 1.19f), Actions = { OsuAction.LeftButton }, Time = time_slider_start + 250 }, + new OsuReplayFrame { Position = new Vector2(slider_path_length, OsuHitObject.OBJECT_RADIUS * 1.199f), Actions = { OsuAction.LeftButton }, Time = time_slider_end }, + }); - private bool assertHeadMissTailTracked() => judgementResults[judgementResults.Count - 2].Type == HitResult.Great && judgementResults.First().Type == HitResult.Miss; + AddAssert("Tracking kept", assertMaxJudge); + } - private bool assertMidSliderJudgements() => judgementResults[judgementResults.Count - 2].Type == HitResult.Great; + /// + /// Scenario: + /// - Press a key on the slider head + /// - While holding the key, move cursor just outside the tracking area + /// - Keep the cursor just outside the tracking area until the slider ends + /// Expected Result: + /// A passing test case will have the slider drop the tracking on frame 2. + /// + [Test] + public void TestTrackingAreaOutsideEdge() + { + performTest(new List + { + new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.LeftButton }, Time = time_slider_start }, + new OsuReplayFrame { Position = new Vector2(0, OsuHitObject.OBJECT_RADIUS * 1.21f), Actions = { OsuAction.LeftButton }, Time = time_slider_start + 250 }, + new OsuReplayFrame { Position = new Vector2(slider_path_length, OsuHitObject.OBJECT_RADIUS * 1.201f), Actions = { OsuAction.LeftButton }, Time = time_slider_end }, + }); - private bool assertMidSliderJudgementFail() => judgementResults[judgementResults.Count - 2].Type == HitResult.Miss; + AddAssert("Tracking dropped", assertMidSliderJudgementFail); + } + + private bool assertMaxJudge() => judgementResults.Any() && judgementResults.All(t => t.Type == t.Judgement.MaxResult); + + private bool assertHeadMissTailTracked() => judgementResults[^2].Type == HitResult.SmallTickHit && !judgementResults.First().IsHit; + + private bool assertMidSliderJudgements() => judgementResults[^2].Type == HitResult.SmallTickHit; + + private bool assertMidSliderJudgementFail() => judgementResults[^2].Type == HitResult.SmallTickMiss; private ScoreAccessibleReplayPlayer currentPlayer; + private const float slider_path_length = 25; + private void performTest(List frames) { AddStep("load player", () => @@ -309,14 +339,10 @@ namespace osu.Game.Rulesets.Osu.Tests Path = new SliderPath(PathType.PerfectCurve, new[] { Vector2.Zero, - new Vector2(25, 0), - }, 25), + new Vector2(slider_path_length, 0), + }, slider_path_length), } }, - ControlPointInfo = - { - DifficultyPoints = { new DifficultyControlPoint { SpeedMultiplier = 0.1f } } - }, BeatmapInfo = { BaseDifficulty = new BeatmapDifficulty { SliderTickRate = 3 }, @@ -324,6 +350,8 @@ namespace osu.Game.Rulesets.Osu.Tests }, }); + Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); p.OnLoadComplete += _ => @@ -332,20 +360,15 @@ namespace osu.Game.Rulesets.Osu.Tests { if (currentPlayer == p) judgementResults.Add(result); }; - p.ScoreProcessor.AllJudged += () => - { - if (currentPlayer == p) allJudgedFired = true; - }; }; LoadScreen(currentPlayer = p); - allJudgedFired = false; judgementResults = new List(); }); AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); - AddUntilStep("Wait for all judged", () => allJudgedFired); + AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); } private class ScoreAccessibleReplayPlayer : ReplayPlayer diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderPlacementBlueprint.cs deleted file mode 100644 index 0522260150..0000000000 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderPlacementBlueprint.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.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Tests.Visual; - -namespace osu.Game.Rulesets.Osu.Tests -{ - public class TestSceneSliderPlacementBlueprint : PlacementBlueprintTestScene - { - protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSlider((Slider)hitObject); - protected override PlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint(); - } -} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSelectionBlueprint.cs deleted file mode 100644 index 8cf5a2f33e..0000000000 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSelectionBlueprint.cs +++ /dev/null @@ -1,54 +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 osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; -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; - -namespace osu.Game.Rulesets.Osu.Tests -{ - public class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SliderSelectionBlueprint), - typeof(SliderCircleSelectionBlueprint), - typeof(SliderBodyPiece), - typeof(SliderCircle), - typeof(PathControlPointVisualiser), - typeof(PathControlPointPiece) - }; - - private readonly DrawableSlider drawableObject; - - public TestSceneSliderSelectionBlueprint() - { - var slider = new Slider - { - Position = new Vector2(256, 192), - Path = new SliderPath(PathType.Bezier, new[] - { - Vector2.Zero, - new Vector2(150, 150), - new Vector2(300, 0) - }) - }; - - slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 }); - - Add(drawableObject = new DrawableSlider(slider)); - } - - protected override SelectionBlueprint CreateBlueprint() => new SliderSelectionBlueprint(drawableObject); - } -} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs new file mode 100644 index 0000000000..3d100e4b1c --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs @@ -0,0 +1,247 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; +using osu.Game.Storyboards; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class TestSceneSliderSnaking : TestSceneOsuPlayer + { + [Resolved] + private AudioManager audioManager { get; set; } + + protected override bool Autoplay => autoplay; + private bool autoplay; + + private readonly BindableBool snakingIn = new BindableBool(); + private readonly BindableBool snakingOut = new BindableBool(); + + private const double duration_of_span = 3605; + private const double fade_in_modifier = -1200; + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); + + [BackgroundDependencyLoader] + private void load(RulesetConfigCache configCache) + { + var config = (OsuRulesetConfigManager)configCache.GetConfigFor(Ruleset.Value.CreateInstance()); + config.BindWith(OsuRulesetSetting.SnakingInSliders, snakingIn); + config.BindWith(OsuRulesetSetting.SnakingOutSliders, snakingOut); + } + + private DrawableSlider slider; + + [SetUpSteps] + public override void SetUpSteps() + { + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + public void TestSnakingEnabled(int sliderIndex) + { + AddStep("enable autoplay", () => autoplay = true); + base.SetUpSteps(); + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + double startTime = hitObjects[sliderIndex].StartTime; + retrieveDrawableSlider(sliderIndex); + setSnaking(true); + + ensureSnakingIn(startTime + fade_in_modifier); + + for (int i = 0; i < sliderIndex; i++) + { + // non-final repeats should not snake out + ensureNoSnakingOut(startTime, i); + } + + // final repeat should snake out + ensureSnakingOut(startTime, sliderIndex); + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + public void TestSnakingDisabled(int sliderIndex) + { + AddStep("have autoplay", () => autoplay = true); + base.SetUpSteps(); + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + double startTime = hitObjects[sliderIndex].StartTime; + retrieveDrawableSlider(sliderIndex); + setSnaking(false); + + ensureNoSnakingIn(startTime + fade_in_modifier); + + for (int i = 0; i <= sliderIndex; i++) + { + // no snaking out ever, including final repeat + ensureNoSnakingOut(startTime, i); + } + } + + [Test] + public void TestRepeatArrowDoesNotMoveWhenHit() + { + AddStep("enable autoplay", () => autoplay = true); + setSnaking(true); + base.SetUpSteps(); + + // repeat might have a chance to update its position depending on where in the frame its hit, + // so some leniency is allowed here instead of checking strict equality + checkPositionChange(16600, sliderRepeat, positionAlmostSame); + } + + [Test] + public void TestRepeatArrowMovesWhenNotHit() + { + AddStep("disable autoplay", () => autoplay = false); + setSnaking(true); + base.SetUpSteps(); + + checkPositionChange(16600, sliderRepeat, positionDecreased); + } + + private void retrieveDrawableSlider(int index) => + AddStep($"retrieve {(index + 1).ToOrdinalWords()} slider", () => + slider = (DrawableSlider)Player.DrawableRuleset.Playfield.AllHitObjects.ElementAt(index)); + + private void ensureSnakingIn(double startTime) => checkPositionChange(startTime, sliderEnd, positionIncreased); + private void ensureNoSnakingIn(double startTime) => checkPositionChange(startTime, sliderEnd, positionRemainsSame); + + private void ensureSnakingOut(double startTime, int repeatIndex) + { + var repeatTime = timeAtRepeat(startTime, repeatIndex); + + if (repeatIndex % 2 == 0) + checkPositionChange(repeatTime, sliderStart, positionIncreased); + else + checkPositionChange(repeatTime, sliderEnd, positionDecreased); + } + + private void ensureNoSnakingOut(double startTime, int repeatIndex) => + checkPositionChange(timeAtRepeat(startTime, repeatIndex), positionAtRepeat(repeatIndex), positionRemainsSame); + + private double timeAtRepeat(double startTime, int repeatIndex) => startTime + 100 + duration_of_span * repeatIndex; + private Func positionAtRepeat(int repeatIndex) => repeatIndex % 2 == 0 ? (Func)sliderStart : sliderEnd; + + private List sliderCurve => ((PlaySliderBody)slider.Body.Drawable).CurrentCurve; + private Vector2 sliderStart() => sliderCurve.First(); + private Vector2 sliderEnd() => sliderCurve.Last(); + + private Vector2 sliderRepeat() + { + var drawable = Player.DrawableRuleset.Playfield.AllHitObjects.ElementAt(1); + var repeat = drawable.ChildrenOfType>().First().Children.First(); + return repeat.Position; + } + + private bool positionRemainsSame(Vector2 previous, Vector2 current) => previous == current; + private bool positionIncreased(Vector2 previous, Vector2 current) => current.X > previous.X && current.Y > previous.Y; + private bool positionDecreased(Vector2 previous, Vector2 current) => current.X < previous.X && current.Y < previous.Y; + private bool positionAlmostSame(Vector2 previous, Vector2 current) => Precision.AlmostEquals(previous, current, 1); + + private void checkPositionChange(double startTime, Func positionToCheck, Func positionAssertion) + { + Vector2 previousPosition = Vector2.Zero; + + string positionDescription = positionToCheck.Method.Name.Humanize(LetterCasing.LowerCase); + string assertionDescription = positionAssertion.Method.Name.Humanize(LetterCasing.LowerCase); + + addSeekStep(startTime); + AddStep($"save {positionDescription} position", () => previousPosition = positionToCheck.Invoke()); + addSeekStep(startTime + 100); + AddAssert($"{positionDescription} {assertionDescription}", () => + { + var currentPosition = positionToCheck.Invoke(); + return positionAssertion.Invoke(previousPosition, currentPosition); + }); + } + + private void setSnaking(bool value) + { + AddStep($"{(value ? "enable" : "disable")} snaking", () => + { + snakingIn.Value = value; + snakingOut.Value = value; + }); + } + + private void addSeekStep(double time) + { + AddStep($"seek to {time}", () => MusicController.SeekTo(time)); + + AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + { + HitObjects = hitObjects + }; + + private readonly List hitObjects = new List + { + new Slider + { + StartTime = 3000, + Position = new Vector2(100, 100), + Path = new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(300, 200) + }), + }, + new Slider + { + StartTime = 13000, + Position = new Vector2(100, 100), + Path = new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(300, 200) + }), + RepeatCount = 1, + }, + new Slider + { + StartTime = 23000, + Position = new Vector2(100, 100), + Path = new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(300, 200) + }), + RepeatCount = 2, + }, + new HitCircle + { + StartTime = 199999, + } + }; + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs index 3ed3f3e981..496b1b3559 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs @@ -1,70 +1,81 @@ // 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.Graphics; -using osu.Framework.Graphics.Containers; 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.Rulesets.Osu.Objects.Drawables.Pieces; -using osu.Game.Tests.Visual; +using osuTK; namespace osu.Game.Rulesets.Osu.Tests { [TestFixture] - public class TestSceneSpinner : OsuTestScene + public class TestSceneSpinner : OsuSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SpinnerDisc), - typeof(DrawableSpinner), - typeof(DrawableOsuHitObject) - }; - - private readonly Container content; - protected override Container Content => content; - private int depthIndex; - public TestSceneSpinner() - { - base.Content.Add(content = new OsuInputManager(new RulesetInfo { ID = 0 })); + private TestDrawableSpinner drawableSpinner; - AddStep("Miss Big", () => testSingle(2)); - AddStep("Miss Medium", () => testSingle(5)); - AddStep("Miss Small", () => testSingle(7)); - AddStep("Hit Big", () => testSingle(2, true)); - AddStep("Hit Medium", () => testSingle(5, true)); - AddStep("Hit Small", () => testSingle(7, true)); + [TestCase(true)] + [TestCase(false)] + 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))); } - private void testSingle(float circleSize, bool auto = false) + [TestCase(false)] + [TestCase(true)] + public void TestLongSpinner(bool autoplay) { - var spinner = new Spinner { StartTime = Time.Current + 1000, EndTime = Time.Current + 4000 }; + AddStep("Very short spinner", () => SetContents(() => testSingle(5, autoplay, 2000))); + AddUntilStep("Wait for completion", () => drawableSpinner.Result.HasResult); + AddUntilStep("Check correct progress", () => drawableSpinner.Progress == (autoplay ? 1 : 0)); + } + + [TestCase(false)] + [TestCase(true)] + public void TestSuperShortSpinner(bool autoplay) + { + AddStep("Very short spinner", () => SetContents(() => testSingle(5, autoplay, 200))); + AddUntilStep("Wait for completion", () => drawableSpinner.Result.HasResult); + AddUntilStep("Short spinner implicitly completes", () => drawableSpinner.Progress == 1); + } + + private Drawable testSingle(float circleSize, bool auto = false, double length = 3000) + { + const double delay = 2000; + + var spinner = new Spinner + { + StartTime = Time.Current + delay, + EndTime = Time.Current + delay + length + }; spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize }); - var drawable = new TestDrawableSpinner(spinner, auto) + drawableSpinner = new TestDrawableSpinner(spinner, auto) { Anchor = Anchor.Centre, - Depth = depthIndex++ + Depth = depthIndex++, + Scale = new Vector2(0.75f) }; - foreach (var mod in Mods.Value.OfType()) - mod.ApplyToDrawableHitObjects(new[] { drawable }); + foreach (var mod in SelectedMods.Value.OfType()) + mod.ApplyToDrawableHitObjects(new[] { drawableSpinner }); - Add(drawable); + return drawableSpinner; } private class TestDrawableSpinner : DrawableSpinner { - private bool auto; + private readonly bool auto; public TestDrawableSpinner(Spinner s, bool auto) : base(s) @@ -72,16 +83,11 @@ namespace osu.Game.Rulesets.Osu.Tests this.auto = auto; } - protected override void CheckForResult(bool userTriggered, double timeOffset) + protected override void Update() { - if (auto && !userTriggered && Time.Current > Spinner.StartTime + Spinner.Duration / 2 && Progress < 1) - { - // force completion only once to not break human interaction - Disc.RotationAbsolute = Spinner.SpinsRequired * 360; - auto = false; - } - - base.CheckForResult(userTriggered, timeOffset); + base.Update(); + if (auto) + RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * 3)); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.cs new file mode 100644 index 0000000000..5951574079 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerApplication.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.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneSpinnerApplication : OsuTestScene + { + [Test] + public void TestApplyNewCircle() + { + DrawableSpinner dho = null; + + AddStep("create spinner", () => Child = dho = new DrawableSpinner(prepareObject(new Spinner + { + Position = new Vector2(256, 192), + IndexInCurrentCombo = 0, + Duration = 0, + })) + { + Clock = new FramedClock(new StopwatchClock()) + }); + + AddStep("apply new spinner", () => dho.Apply(prepareObject(new Spinner + { + Position = new Vector2(256, 192), + ComboIndex = 1, + Duration = 1000, + }))); + } + + private Spinner prepareObject(Spinner circle) + { + circle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + return circle; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs index a0ab1908d6..91b6a05fe3 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerHidden.cs @@ -1,9 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using osu.Game.Rulesets.Osu.Mods; @@ -12,11 +9,10 @@ namespace osu.Game.Rulesets.Osu.Tests [TestFixture] public class TestSceneSpinnerHidden : TestSceneSpinner { - public override IReadOnlyList RequiredTypes => base.RequiredTypes.Concat(new[] { typeof(OsuModHidden) }).ToList(); - - public TestSceneSpinnerHidden() + [SetUp] + public void SetUp() => Schedule(() => { - Mods.Value = new[] { new OsuModHidden() }; - } + SelectedMods.Value = new[] { new OsuModHidden() }; + }); } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs new file mode 100644 index 0000000000..53bf1ea566 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -0,0 +1,258 @@ +// 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.Audio; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Replays; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests +{ + public class TestSceneSpinnerRotation : TestSceneOsuPlayer + { + [Resolved] + private AudioManager audioManager { get; set; } + + protected override bool Autoplay => true; + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new ScoreExposedPlayer(); + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); + + private DrawableSpinner drawableSpinner; + private SpriteIcon spinnerSymbol => drawableSpinner.ChildrenOfType().Single(); + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + AddStep("retrieve spinner", () => drawableSpinner = (DrawableSpinner)Player.DrawableRuleset.Playfield.AllHitObjects.First()); + } + + [Test] + public void TestSpinnerRewindingRotation() + { + double trackerRotationTolerance = 0; + + addSeekStep(5000); + AddStep("calculate rotation tolerance", () => + { + trackerRotationTolerance = Math.Abs(drawableSpinner.RotationTracker.Rotation * 0.1f); + }); + AddAssert("is disc rotation not almost 0", () => !Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, 100)); + AddAssert("is disc rotation absolute not almost 0", () => !Precision.AlmostEquals(drawableSpinner.RotationTracker.RateAdjustedRotation, 0, 100)); + + addSeekStep(0); + AddAssert("is disc rotation almost 0", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, trackerRotationTolerance)); + AddAssert("is disc rotation absolute almost 0", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.RateAdjustedRotation, 0, 100)); + } + + [Test] + public void TestSpinnerMiddleRewindingRotation() + { + double finalCumulativeTrackerRotation = 0; + double finalTrackerRotation = 0, trackerRotationTolerance = 0; + double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0; + + addSeekStep(5000); + AddStep("retrieve disc rotation", () => + { + finalTrackerRotation = drawableSpinner.RotationTracker.Rotation; + trackerRotationTolerance = Math.Abs(finalTrackerRotation * 0.05f); + }); + AddStep("retrieve spinner symbol rotation", () => + { + finalSpinnerSymbolRotation = spinnerSymbol.Rotation; + spinnerSymbolRotationTolerance = Math.Abs(finalSpinnerSymbolRotation * 0.05f); + }); + AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.RotationTracker.RateAdjustedRotation); + + addSeekStep(2500); + AddAssert("disc rotation rewound", + // we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in. + // due to the exponential damping applied we're allowing a larger margin of error of about 10% + // (5% relative to the final rotation value, but we're half-way through the spin). + () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation / 2, trackerRotationTolerance)); + AddAssert("symbol rotation rewound", + () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation / 2, spinnerSymbolRotationTolerance)); + AddAssert("is cumulative rotation rewound", + // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error. + () => Precision.AlmostEquals(drawableSpinner.RotationTracker.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100)); + + addSeekStep(5000); + AddAssert("is disc rotation almost same", + () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation, trackerRotationTolerance)); + AddAssert("is symbol rotation almost same", + () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation, spinnerSymbolRotationTolerance)); + AddAssert("is cumulative rotation almost same", + () => Precision.AlmostEquals(drawableSpinner.RotationTracker.RateAdjustedRotation, finalCumulativeTrackerRotation, 100)); + } + + [Test] + public void TestRotationDirection([Values(true, false)] bool clockwise) + { + if (clockwise) + transformReplay(flip); + + addSeekStep(5000); + + AddAssert("disc spin direction correct", () => clockwise ? drawableSpinner.RotationTracker.Rotation > 0 : drawableSpinner.RotationTracker.Rotation < 0); + AddAssert("spinner symbol direction correct", () => clockwise ? spinnerSymbol.Rotation > 0 : spinnerSymbol.Rotation < 0); + } + + private Replay flip(Replay scoreReplay) => new Replay + { + Frames = scoreReplay + .Frames + .Cast() + .Select(replayFrame => + { + var flippedPosition = new Vector2(OsuPlayfield.BASE_SIZE.X - replayFrame.Position.X, replayFrame.Position.Y); + return new OsuReplayFrame(replayFrame.Time, flippedPosition, replayFrame.Actions.ToArray()); + }) + .Cast() + .ToList() + }; + + [Test] + public void TestSpinnerNormalBonusRewinding() + { + addSeekStep(1000); + + AddAssert("player score matching expected bonus score", () => + { + // multipled by 2 to nullify the score multiplier. (autoplay mod selected) + var totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2; + return totalScore == (int)(drawableSpinner.RotationTracker.RateAdjustedRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult; + }); + + addSeekStep(0); + + AddAssert("player score is 0", () => ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value == 0); + } + + [Test] + public void TestSpinnerCompleteBonusRewinding() + { + addSeekStep(2500); + addSeekStep(0); + + AddAssert("player score is 0", () => ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value == 0); + } + + [Test] + public void TestSpinPerMinuteOnRewind() + { + double estimatedSpm = 0; + + addSeekStep(1000); + AddStep("retrieve spm", () => estimatedSpm = drawableSpinner.SpmCounter.SpinsPerMinute); + + addSeekStep(2000); + AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpmCounter.SpinsPerMinute, estimatedSpm, 1.0)); + + addSeekStep(1000); + AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpmCounter.SpinsPerMinute, estimatedSpm, 1.0)); + } + + [TestCase(0.5)] + [TestCase(2.0)] + public void TestSpinUnaffectedByClockRate(double rate) + { + double expectedProgress = 0; + double expectedSpm = 0; + + addSeekStep(1000); + AddStep("retrieve spinner state", () => + { + expectedProgress = drawableSpinner.Progress; + expectedSpm = drawableSpinner.SpmCounter.SpinsPerMinute; + }); + + addSeekStep(0); + + AddStep("adjust track rate", () => 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)); + } + + private Replay applyRateAdjustment(Replay scoreReplay, double rate) => new Replay + { + Frames = scoreReplay + .Frames + .Cast() + .Select(replayFrame => + { + var adjustedTime = replayFrame.Time * rate; + return new OsuReplayFrame(adjustedTime, replayFrame.Position, replayFrame.Actions.ToArray()); + }) + .Cast() + .ToList() + }; + + private void addSeekStep(double time) + { + AddStep($"seek to {time}", () => MusicController.SeekTo(time)); + + AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); + } + + private void transformReplay(Func replayTransformation) => AddStep("set replay", () => + { + var drawableRuleset = this.ChildrenOfType().Single(); + var score = drawableRuleset.ReplayScore; + var transformedScore = new Score + { + ScoreInfo = score.ScoreInfo, + Replay = replayTransformation.Invoke(score.Replay) + }; + drawableRuleset.SetReplayScore(transformedScore); + }); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + { + HitObjects = new List + { + new Spinner + { + Position = new Vector2(256, 192), + EndTime = 6000, + }, + } + }; + + private class ScoreExposedPlayer : TestPlayer + { + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + public ScoreExposedPlayer() + : base(false, false) + { + } + } + } +} 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 bb3e5a66f3..3639c3616f 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,14 +2,14 @@ - + - + WinExe - netcoreapp2.2 + netcoreapp3.1 diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs index 491d82b89e..2d3cc3c103 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmap.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; @@ -23,19 +22,19 @@ namespace osu.Game.Rulesets.Osu.Beatmaps { Name = @"Circle Count", Content = circles.ToString(), - Icon = FontAwesome.Regular.Circle + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles), }, new BeatmapStatistic { Name = @"Slider Count", Content = sliders.ToString(), - Icon = FontAwesome.Regular.Circle + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), }, new BeatmapStatistic { Name = @"Spinner Count", Content = spinners.ToString(), - Icon = FontAwesome.Regular.Circle + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners), } }; } diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs index 6a41e93c35..a2fc4848af 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs @@ -7,68 +7,66 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; using System.Collections.Generic; using osu.Game.Rulesets.Objects.Types; -using System; +using System.Linq; +using System.Threading; using osu.Game.Rulesets.Osu.UI; +using osu.Framework.Extensions.IEnumerableExtensions; namespace osu.Game.Rulesets.Osu.Beatmaps { public class OsuBeatmapConverter : BeatmapConverter { - public OsuBeatmapConverter(IBeatmap beatmap) - : base(beatmap) + public OsuBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) + : base(beatmap, ruleset) { } - protected override IEnumerable ValidConversionTypes { get; } = new[] { typeof(IHasPosition) }; + public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasPosition); - protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap) + protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) { - var curveData = original as IHasCurve; - var endTimeData = original as IHasEndTime; var positionData = original as IHasPosition; var comboData = original as IHasCombo; - var legacyOffset = original as IHasLegacyLastTickOffset; - if (curveData != null) + switch (original) { - yield return new Slider - { - StartTime = original.StartTime, - Samples = original.Samples, - Path = curveData.Path, - NodeSamples = curveData.NodeSamples, - RepeatCount = curveData.RepeatCount, - Position = positionData?.Position ?? Vector2.Zero, - NewCombo = comboData?.NewCombo ?? false, - ComboOffset = comboData?.ComboOffset ?? 0, - LegacyLastTickOffset = legacyOffset?.LegacyLastTickOffset, - // prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance. - // this results in more (or less) ticks being generated in stackThreshold) - //We are no longer within stacking range of the next object. + // We are no longer within stacking range of the next object. break; if (Vector2Extensions.Distance(stackBaseObject.Position, objectN.Position) < stack_distance @@ -87,7 +88,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps } } - //Reverse pass for stack calculation. + // Reverse pass for stack calculation. int extendedStartIndex = startIndex; for (int i = extendedEndIndex; i > startIndex; i--) @@ -120,10 +121,10 @@ namespace osu.Game.Rulesets.Osu.Beatmaps OsuHitObject objectN = beatmap.HitObjects[n]; if (objectN is Spinner) continue; - double endTime = (objectN as IHasEndTime)?.EndTime ?? objectN.StartTime; + double endTime = objectN.GetEndTime(); if (objectI.StartTime - endTime > stackThreshold) - //We are no longer within stacking range of the previous object. + // We are no longer within stacking range of the previous object. break; // HitObjects before the specified update range haven't been reset yet @@ -144,20 +145,20 @@ namespace osu.Game.Rulesets.Osu.Beatmaps for (int j = n + 1; j <= i; j++) { - //For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above). + // For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above). OsuHitObject objectJ = beatmap.HitObjects[j]; if (Vector2Extensions.Distance(objectN.EndPosition, objectJ.Position) < stack_distance) objectJ.StackHeight -= offset; } - //We have hit a slider. We should restart calculation using this as the new base. - //Breaking here will mean that the slider still has StackCount of 0, so will be handled in the i-outer-loop. + // We have hit a slider. We should restart calculation using this as the new base. + // Breaking here will mean that the slider still has StackCount of 0, so will be handled in the i-outer-loop. break; } if (Vector2Extensions.Distance(objectN.Position, objectI.Position) < stack_distance) { - //Keep processing as if there are no sliders. If we come across a slider, this gets cancelled out. + // Keep processing as if there are no sliders. If we come across a slider, this gets cancelled out. //NOTE: Sliders with start positions stacking are a special case that is also handled here. objectN.StackHeight = objectI.StackHeight + 1; @@ -176,7 +177,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps if (objectN is Spinner) continue; if (objectI.StartTime - objectN.StartTime > stackThreshold) - //We are no longer within stacking range of the previous object. + // We are no longer within stacking range of the previous object. break; if (Vector2Extensions.Distance(objectN.EndPosition, objectI.Position) < stack_distance) @@ -198,7 +199,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps if (currHitObject.StackHeight != 0 && !(currHitObject is Slider)) continue; - double startTime = (currHitObject as IHasEndTime)?.EndTime ?? currHitObject.StartTime; + double startTime = currHitObject.GetEndTime(); int sliderStack = 0; for (int j = i + 1; j < beatmap.HitObjects.Count; j++) @@ -208,17 +209,22 @@ namespace osu.Game.Rulesets.Osu.Beatmaps if (beatmap.HitObjects[j].StartTime - stackThreshold > startTime) break; + // The start position of the hitobject, or the position at the end of the path if the hitobject is a slider + Vector2 position2 = currHitObject is Slider currSlider + ? currSlider.Position + currSlider.Path.PositionAt(1) + : currHitObject.Position; + if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, currHitObject.Position) < stack_distance) { currHitObject.StackHeight++; - startTime = (beatmap.HitObjects[j] as IHasEndTime)?.EndTime ?? beatmap.HitObjects[i].StartTime; + startTime = beatmap.HitObjects[j].GetEndTime(); } - else if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, currHitObject.EndPosition) < stack_distance) + else if (Vector2Extensions.Distance(beatmap.HitObjects[j].Position, position2) < stack_distance) { - //Case for sliders - bump notes down and right, rather than up and left. + // Case for sliders - bump notes down and right, rather than up and left. sliderStack++; beatmap.HitObjects[j].StackHeight -= sliderStack; - startTime = (beatmap.HitObjects[j] as IHasEndTime)?.EndTime ?? beatmap.HitObjects[i].StartTime; + startTime = beatmap.HitObjects[j].GetEndTime(); } } } diff --git a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs index f76635a932..e8272057f3 100644 --- a/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Osu/Configuration/OsuRulesetConfigManager.cs @@ -3,6 +3,7 @@ using osu.Game.Configuration; using osu.Game.Rulesets.Configuration; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Configuration { @@ -19,6 +20,7 @@ namespace osu.Game.Rulesets.Osu.Configuration Set(OsuRulesetSetting.SnakingInSliders, true); Set(OsuRulesetSetting.SnakingOutSliders, true); Set(OsuRulesetSetting.ShowCursorTrail, true); + Set(OsuRulesetSetting.PlayfieldBorderStyle, PlayfieldBorderStyle.None); } } @@ -26,6 +28,7 @@ namespace osu.Game.Rulesets.Osu.Configuration { SnakingInSliders, SnakingOutSliders, - ShowCursorTrail + ShowCursorTrail, + PlayfieldBorderStyle, } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 6e991a1d08..fff033357d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -11,6 +11,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty public double SpeedStrain; public double ApproachRate; public double OverallDifficulty; - public int MaxCombo; + public int HitCircleCount; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index c197933233..6027635b75 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -13,6 +13,8 @@ using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Difficulty { @@ -34,14 +36,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty double speedRating = Math.Sqrt(skills[1].DifficultyValue()) * difficulty_multiplier; double starRating = aimRating + speedRating + Math.Abs(aimRating - speedRating) / 2; + HitWindows hitWindows = new OsuHitWindows(); + hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + // Todo: These int casts are temporary to achieve 1:1 results with osu!stable, and should be removed in the future - double hitWindowGreat = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / clockRate; + double hitWindowGreat = (int)(hitWindows.WindowFor(HitResult.Great)) / clockRate; double preempt = (int)BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.ApproachRate, 1800, 1200, 450) / clockRate; int maxCombo = beatmap.HitObjects.Count; // Add the ticks + tail of the slider. 1 is subtracted because the head circle would be counted twice (once for the slider itself in the line above) maxCombo += beatmap.HitObjects.OfType().Sum(s => s.NestedHitObjects.Count - 1); + int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle); + return new OsuDifficultyAttributes { StarRating = starRating, @@ -51,6 +58,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5, OverallDifficulty = (80 - hitWindowGreat) / 6, MaxCombo = maxCombo, + HitCircleCount = hitCirclesCount, Skills = skills }; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 093081b6a1..063cde8747 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -4,11 +4,10 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Game.Beatmaps; +using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; @@ -18,26 +17,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public new OsuDifficultyAttributes Attributes => (OsuDifficultyAttributes)base.Attributes; - private readonly int countHitCircles; - private readonly int beatmapMaxCombo; - private Mod[] mods; private double accuracy; private int scoreMaxCombo; private int countGreat; - private int countGood; + private int countOk; private int countMeh; private int countMiss; - public OsuPerformanceCalculator(Ruleset ruleset, WorkingBeatmap beatmap, ScoreInfo score) - : base(ruleset, beatmap, score) + public OsuPerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) + : base(ruleset, attributes, score) { - countHitCircles = Beatmap.HitObjects.Count(h => h is HitCircle); - - beatmapMaxCombo = Beatmap.HitObjects.Count; - // Add the ticks + tail of the slider. 1 is subtracted because the "headcircle" would be counted twice (once for the slider itself in the line above) - beatmapMaxCombo += Beatmap.HitObjects.OfType().Sum(s => s.NestedHitObjects.Count - 1); } public override double Calculate(Dictionary categoryRatings = null) @@ -45,32 +36,32 @@ namespace osu.Game.Rulesets.Osu.Difficulty mods = Score.Mods; accuracy = Score.Accuracy; scoreMaxCombo = Score.MaxCombo; - countGreat = Convert.ToInt32(Score.Statistics[HitResult.Great]); - countGood = Convert.ToInt32(Score.Statistics[HitResult.Good]); - countMeh = Convert.ToInt32(Score.Statistics[HitResult.Meh]); - countMiss = Convert.ToInt32(Score.Statistics[HitResult.Miss]); + countGreat = Score.Statistics.GetOrDefault(HitResult.Great); + countOk = Score.Statistics.GetOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetOrDefault(HitResult.Miss); // Don't count scores made with supposedly unranked mods if (mods.Any(m => !m.Ranked)) return 0; // Custom multipliers for NoFail and SpunOut. - double multiplier = 1.12f; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things + double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things if (mods.Any(m => m is OsuModNoFail)) - multiplier *= 0.90f; + multiplier *= 0.90; if (mods.Any(m => m is OsuModSpunOut)) - multiplier *= 0.95f; + multiplier *= 0.95; double aimValue = computeAimValue(); double speedValue = computeSpeedValue(); double accuracyValue = computeAccuracyValue(); double totalValue = Math.Pow( - Math.Pow(aimValue, 1.1f) + - Math.Pow(speedValue, 1.1f) + - Math.Pow(accuracyValue, 1.1f), 1.0f / 1.1f + Math.Pow(aimValue, 1.1) + + Math.Pow(speedValue, 1.1) + + Math.Pow(accuracyValue, 1.1), 1.0 / 1.1 ) * multiplier; if (categoryRatings != null) @@ -80,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty categoryRatings.Add("Accuracy", accuracyValue); categoryRatings.Add("OD", Attributes.OverallDifficulty); categoryRatings.Add("AR", Attributes.ApproachRate); - categoryRatings.Add("Max Combo", beatmapMaxCombo); + categoryRatings.Add("Max Combo", Attributes.MaxCombo); } return totalValue; @@ -93,82 +84,82 @@ namespace osu.Game.Rulesets.Osu.Difficulty if (mods.Any(m => m is OsuModTouchDevice)) rawAim = Math.Pow(rawAim, 0.8); - double aimValue = Math.Pow(5.0f * Math.Max(1.0f, rawAim / 0.0675f) - 4.0f, 3.0f) / 100000.0f; + double aimValue = Math.Pow(5.0 * Math.Max(1.0, rawAim / 0.0675) - 4.0, 3.0) / 100000.0; // Longer maps are worth more - double lengthBonus = 0.95f + 0.4f * Math.Min(1.0f, totalHits / 2000.0f) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0f) * 0.5f : 0.0f); + double lengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); aimValue *= lengthBonus; // Penalize misses exponentially. This mainly fixes tag4 maps and the likes until a per-hitobject solution is available - aimValue *= Math.Pow(0.97f, countMiss); + aimValue *= Math.Pow(0.97, countMiss); // Combo scaling - if (beatmapMaxCombo > 0) - aimValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8f) / Math.Pow(beatmapMaxCombo, 0.8f), 1.0f); + if (Attributes.MaxCombo > 0) + aimValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); - double approachRateFactor = 1.0f; + double approachRateFactor = 1.0; - if (Attributes.ApproachRate > 10.33f) - approachRateFactor += 0.3f * (Attributes.ApproachRate - 10.33f); - else if (Attributes.ApproachRate < 8.0f) + if (Attributes.ApproachRate > 10.33) + approachRateFactor += 0.3 * (Attributes.ApproachRate - 10.33); + else if (Attributes.ApproachRate < 8.0) { - approachRateFactor += 0.01f * (8.0f - Attributes.ApproachRate); + approachRateFactor += 0.01 * (8.0 - Attributes.ApproachRate); } aimValue *= approachRateFactor; // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. if (mods.Any(h => h is OsuModHidden)) - aimValue *= 1.0f + 0.04f * (12.0f - Attributes.ApproachRate); + aimValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); if (mods.Any(h => h is OsuModFlashlight)) { // Apply object-based bonus for flashlight. - aimValue *= 1.0f + 0.35f * Math.Min(1.0f, totalHits / 200.0f) + + aimValue *= 1.0 + 0.35 * Math.Min(1.0, totalHits / 200.0) + (totalHits > 200 - ? 0.3f * Math.Min(1.0f, (totalHits - 200) / 300.0f) + - (totalHits > 500 ? (totalHits - 500) / 1200.0f : 0.0f) - : 0.0f); + ? 0.3 * Math.Min(1.0, (totalHits - 200) / 300.0) + + (totalHits > 500 ? (totalHits - 500) / 1200.0 : 0.0) + : 0.0); } // Scale the aim value with accuracy _slightly_ - aimValue *= 0.5f + accuracy / 2.0f; + aimValue *= 0.5 + accuracy / 2.0; // It is important to also consider accuracy difficulty when doing that - aimValue *= 0.98f + Math.Pow(Attributes.OverallDifficulty, 2) / 2500; + aimValue *= 0.98 + Math.Pow(Attributes.OverallDifficulty, 2) / 2500; return aimValue; } private double computeSpeedValue() { - double speedValue = Math.Pow(5.0f * Math.Max(1.0f, Attributes.SpeedStrain / 0.0675f) - 4.0f, 3.0f) / 100000.0f; + double speedValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.SpeedStrain / 0.0675) - 4.0, 3.0) / 100000.0; // Longer maps are worth more - speedValue *= 0.95f + 0.4f * Math.Min(1.0f, totalHits / 2000.0f) + - (totalHits > 2000 ? Math.Log10(totalHits / 2000.0f) * 0.5f : 0.0f); + speedValue *= 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) + + (totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0); // Penalize misses exponentially. This mainly fixes tag4 maps and the likes until a per-hitobject solution is available - speedValue *= Math.Pow(0.97f, countMiss); + speedValue *= Math.Pow(0.97, countMiss); // Combo scaling - if (beatmapMaxCombo > 0) - speedValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8f) / Math.Pow(beatmapMaxCombo, 0.8f), 1.0f); + if (Attributes.MaxCombo > 0) + speedValue *= Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(Attributes.MaxCombo, 0.8), 1.0); - double approachRateFactor = 1.0f; - if (Attributes.ApproachRate > 10.33f) - approachRateFactor += 0.3f * (Attributes.ApproachRate - 10.33f); + double approachRateFactor = 1.0; + if (Attributes.ApproachRate > 10.33) + approachRateFactor += 0.3 * (Attributes.ApproachRate - 10.33); speedValue *= approachRateFactor; if (mods.Any(m => m is OsuModHidden)) - speedValue *= 1.0f + 0.04f * (12.0f - Attributes.ApproachRate); + speedValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); // Scale the speed value with accuracy _slightly_ - speedValue *= 0.02f + accuracy; + speedValue *= 0.02 + accuracy; // It is important to also consider accuracy difficulty when doing that - speedValue *= 0.96f + Math.Pow(Attributes.OverallDifficulty, 2) / 1600; + speedValue *= 0.96 + Math.Pow(Attributes.OverallDifficulty, 2) / 1600; return speedValue; } @@ -177,10 +168,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty { // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window double betterAccuracyPercentage; - int amountHitObjectsWithAccuracy = countHitCircles; + int amountHitObjectsWithAccuracy = Attributes.HitCircleCount; if (amountHitObjectsWithAccuracy > 0) - betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countGood * 2 + countMeh) / (amountHitObjectsWithAccuracy * 6); + betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6); else betterAccuracyPercentage = 0; @@ -190,20 +181,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty // Lots of arbitrary values from testing. // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution - double accuracyValue = Math.Pow(1.52163f, Attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83f; + double accuracyValue = Math.Pow(1.52163, Attributes.OverallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83; // Bonus for many hitcircles - it's harder to keep good accuracy up for longer - accuracyValue *= Math.Min(1.15f, Math.Pow(amountHitObjectsWithAccuracy / 1000.0f, 0.3f)); + accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); if (mods.Any(m => m is OsuModHidden)) - accuracyValue *= 1.08f; + accuracyValue *= 1.08; if (mods.Any(m => m is OsuModFlashlight)) - accuracyValue *= 1.02f; + accuracyValue *= 1.02; return accuracyValue; } - private double totalHits => countGreat + countGood + countMeh + countMiss; - private double totalSuccessfulHits => countGreat + countGood + countMeh; + private int totalHits => countGreat + countOk + countMeh + countMiss; + private int totalSuccessfulHits => countGreat + countOk + countMeh; } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index eacac7ae6a..fa6c5c4d9c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -103,7 +103,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing if (progress % 2 >= 1) progress = 1 - progress % 1; else - progress = progress % 1; + progress %= 1; // ReSharper disable once PossibleInvalidOperationException (bugged in current r# version) var diff = slider.StackedPosition + slider.Path.PositionAt(progress) - slider.LazyEndPosition.Value; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.cs new file mode 100644 index 0000000000..b9c77d3f56 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/BlueprintPiece.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.Framework.Graphics.Containers; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Edit.Blueprints +{ + /// + /// A piece of a selection or placement blueprint which visualises an . + /// + /// The type of which this visualises. + public abstract class BlueprintPiece : CompositeDrawable + where T : OsuHitObject + { + /// + /// Updates this using the properties of a . + /// + /// The to reference properties from. + public virtual void UpdateFrom(T hitObject) + { + Position = hitObject.StackedPosition; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs index fe11ead94d..2868ddeaa4 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/Components/HitCirclePiece.cs @@ -10,19 +10,16 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components { - public class HitCirclePiece : HitObjectPiece + public class HitCirclePiece : BlueprintPiece { - private readonly HitCircle hitCircle; - - public HitCirclePiece(HitCircle hitCircle) - : base(hitCircle) + public HitCirclePiece() { - this.hitCircle = hitCircle; Origin = Anchor.Centre; Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); - Scale = new Vector2(hitCircle.Scale); + CornerRadius = Size.X / 2; + CornerExponent = 2; InternalChild = new RingPiece(); } @@ -31,12 +28,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components private void load(OsuColour colours) { Colour = colours.Yellow; - - PositionBindable.BindValueChanged(_ => UpdatePosition(), true); - StackHeightBindable.BindValueChanged(_ => UpdatePosition()); - ScaleBindable.BindValueChanged(scale => Scale = new Vector2(scale.NewValue), true); } - protected virtual void UpdatePosition() => Position = hitCircle.StackedPosition; + public override void UpdateFrom(HitCircle hitObject) + { + base.UpdateFrom(hitObject); + + Scale = new Vector2(hitObject.Scale); + } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs index a4050f0c31..e14d6647d2 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs @@ -5,7 +5,7 @@ using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; -using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles { @@ -13,31 +13,42 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles { public new HitCircle HitObject => (HitCircle)base.HitObject; + private readonly HitCirclePiece circlePiece; + public HitCirclePlacementBlueprint() : base(new HitCircle()) { - InternalChild = new HitCirclePiece(HitObject); + InternalChild = circlePiece = new HitCirclePiece(); } protected override void LoadComplete() { base.LoadComplete(); - - // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame - HitObject.Position = Parent?.ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position) ?? Vector2.Zero; + BeginPlacement(); } - protected override bool OnClick(ClickEvent e) + protected override void Update() { - HitObject.StartTime = EditorClock.CurrentTime; - EndPlacement(); - return true; + base.Update(); + + circlePiece.UpdateFrom(HitObject); } - protected override bool OnMouseMove(MouseMoveEvent e) + protected override bool OnMouseDown(MouseDownEvent e) { - HitObject.Position = e.MousePosition; - return true; + if (e.Button == MouseButton.Left) + { + EndPlacement(true); + return true; + } + + return base.OnMouseDown(e); + } + + public override void UpdatePosition(SnapResult result) + { + base.UpdatePosition(result); + HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs index 83787e2219..093bae854e 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCircleSelectionBlueprint.cs @@ -1,18 +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.Primitives; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles { - public class HitCircleSelectionBlueprint : OsuSelectionBlueprint + public class HitCircleSelectionBlueprint : OsuSelectionBlueprint { - public HitCircleSelectionBlueprint(DrawableHitCircle hitCircle) - : base(hitCircle) + protected new DrawableHitCircle DrawableObject => (DrawableHitCircle)base.DrawableObject; + + protected readonly HitCirclePiece CirclePiece; + + public HitCircleSelectionBlueprint(DrawableHitCircle drawableCircle) + : base(drawableCircle) { - InternalChild = new HitCirclePiece((HitCircle)hitCircle.HitObject); + InternalChild = CirclePiece = new HitCirclePiece(); } + + protected override void Update() + { + base.Update(); + + CirclePiece.UpdateFrom(HitObject); + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.HitArea.ReceivePositionalInputAt(screenSpacePos); + + public override Quad SelectionQuad => DrawableObject.HitArea.ScreenSpaceDrawQuad; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitObjectPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitObjectPiece.cs deleted file mode 100644 index 315a5a2b9d..0000000000 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitObjectPiece.cs +++ /dev/null @@ -1,36 +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.Graphics.Containers; -using osu.Game.Rulesets.Osu.Objects; -using osuTK; - -namespace osu.Game.Rulesets.Osu.Edit.Blueprints -{ - /// - /// A piece of a blueprint which responds to changes in the state of a . - /// - public abstract class HitObjectPiece : CompositeDrawable - { - protected readonly IBindable PositionBindable = new Bindable(); - protected readonly IBindable StackHeightBindable = new Bindable(); - protected readonly IBindable ScaleBindable = new Bindable(); - - private readonly OsuHitObject hitObject; - - protected HitObjectPiece(OsuHitObject hitObject) - { - this.hitObject = hitObject; - } - - [BackgroundDependencyLoader] - private void load() - { - PositionBindable.BindTo(hitObject.PositionBindable); - StackHeightBindable.BindTo(hitObject.StackHeightBindable); - ScaleBindable.BindTo(hitObject.ScaleBindable); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs index dd524252f3..8dd550bb96 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/OsuSelectionBlueprint.cs @@ -7,12 +7,15 @@ using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Edit.Blueprints { - public class OsuSelectionBlueprint : SelectionBlueprint + public abstract class OsuSelectionBlueprint : OverlaySelectionBlueprint + where T : OsuHitObject { - protected OsuHitObject OsuObject => (OsuHitObject)HitObject.HitObject; + protected new T HitObject => (T)DrawableObject.HitObject; - public OsuSelectionBlueprint(DrawableHitObject hitObject) - : base(hitObject) + protected override bool AlwaysShowWhenSelected => true; + + protected OsuSelectionBlueprint(DrawableHitObject drawableObject) + : base(drawableObject) { } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/SliderPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/SliderPiece.cs deleted file mode 100644 index 8fd1d6d6f9..0000000000 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/SliderPiece.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 osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Osu.Objects; - -namespace osu.Game.Rulesets.Osu.Edit.Blueprints -{ - /// - /// A piece of a blueprint which responds to changes in the state of a . - /// - public abstract class SliderPiece : HitObjectPiece - { - protected readonly IBindable PathBindable = new Bindable(); - - private readonly Slider slider; - - protected SliderPiece(Slider slider) - : base(slider) - { - this.slider = slider; - } - - [BackgroundDependencyLoader] - private void load() - { - PathBindable.BindTo(slider.PathBindable); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.cs new file mode 100644 index 0000000000..ba1d35c35c --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointConnectionPiece.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 osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components +{ + /// + /// A visualisation of the line between two s. + /// + public class PathControlPointConnectionPiece : CompositeDrawable + { + public readonly PathControlPoint ControlPoint; + + private readonly Path path; + private readonly Slider slider; + private readonly int controlPointIndex; + + private IBindable sliderPosition; + private IBindable pathVersion; + + public PathControlPointConnectionPiece(Slider slider, int controlPointIndex) + { + this.slider = slider; + this.controlPointIndex = controlPointIndex; + + Origin = Anchor.Centre; + AutoSizeAxes = Axes.Both; + + ControlPoint = slider.Path.ControlPoints[controlPointIndex]; + + InternalChild = path = new SmoothPath + { + Anchor = Anchor.Centre, + PathRadius = 1 + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + sliderPosition = slider.PositionBindable.GetBoundCopy(); + sliderPosition.BindValueChanged(_ => updateConnectingPath()); + + pathVersion = slider.Path.Version.GetBoundCopy(); + pathVersion.BindValueChanged(_ => updateConnectingPath()); + + updateConnectingPath(); + } + + /// + /// Updates the path connecting this control point to the next one. + /// + private void updateConnectingPath() + { + Position = slider.StackedPosition + ControlPoint.Position.Value; + + path.ClearVertices(); + + int nextIndex = controlPointIndex + 1; + if (nextIndex == 0 || nextIndex >= slider.Path.ControlPoints.Count) + return; + + path.AddVertex(Vector2.Zero); + path.AddVertex(slider.Path.ControlPoints[nextIndex].Position.Value - ControlPoint.Position.Value); + + path.OriginPosition = path.PositionInBoundingBox(Vector2.Zero); + } + } +} 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 e257369ad9..c06904c0c2 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -1,112 +1,199 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Lines; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Graphics; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; using osuTK; +using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { - public class PathControlPointPiece : CompositeDrawable + /// + /// A visualisation of a single in a . + /// + public class PathControlPointPiece : BlueprintPiece { - private readonly Slider slider; - private readonly int index; + public Action RequestSelection; - private readonly Path path; - private readonly CircularContainer marker; + public readonly BindableBool IsSelected = new BindableBool(); + public readonly PathControlPoint ControlPoint; + + private readonly Slider slider; + private readonly Container marker; + private readonly Drawable markerRing; + + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } + + [Resolved(CanBeNull = true)] + private IPositionSnapProvider snapProvider { get; set; } [Resolved] private OsuColour colours { get; set; } - public PathControlPointPiece(Slider slider, int index) + private IBindable sliderPosition; + private IBindable controlPointPosition; + + public PathControlPointPiece(Slider slider, PathControlPoint controlPoint) { this.slider = slider; - this.index = index; + ControlPoint = controlPoint; + + controlPoint.Type.BindValueChanged(_ => updateMarkerDisplay()); Origin = Anchor.Centre; AutoSizeAxes = Axes.Both; InternalChildren = new Drawable[] { - path = new SmoothPath - { - Anchor = Anchor.Centre, - PathRadius = 1 - }, - marker = new CircularContainer + marker = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(10), - Masking = true, - Child = new Box { RelativeSizeAxes = Axes.Both } + AutoSizeAxes = Axes.Both, + Children = new[] + { + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(10), + }, + markerRing = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(14), + Masking = true, + BorderThickness = 2, + BorderColour = Color4.White, + Alpha = 0, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + } } }; } - protected override void Update() + protected override void LoadComplete() { - base.Update(); + base.LoadComplete(); - Position = slider.StackedPosition + slider.Path.ControlPoints[index]; + sliderPosition = slider.PositionBindable.GetBoundCopy(); + sliderPosition.BindValueChanged(_ => updateMarkerDisplay()); - marker.Colour = isSegmentSeparator ? colours.Red : colours.Yellow; + controlPointPosition = ControlPoint.Position.GetBoundCopy(); + controlPointPosition.BindValueChanged(_ => updateMarkerDisplay()); - path.ClearVertices(); + IsSelected.BindValueChanged(_ => updateMarkerDisplay()); - if (index != slider.Path.ControlPoints.Length - 1) - { - path.AddVertex(Vector2.Zero); - path.AddVertex(slider.Path.ControlPoints[index + 1] - slider.Path.ControlPoints[index]); - } - - path.OriginPosition = path.PositionInBoundingBox(Vector2.Zero); + updateMarkerDisplay(); } + // The connecting path is excluded from positional input public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => marker.ReceivePositionalInputAt(screenSpacePos); - protected override bool OnDragStart(DragStartEvent e) => true; - - protected override bool OnDrag(DragEvent e) + protected override bool OnHover(HoverEvent e) { - var newControlPoints = slider.Path.ControlPoints.ToArray(); - - if (index == 0) - { - // Special handling for the head - only the position of the slider changes - slider.Position += e.Delta; - - // Since control points are relative to the position of the slider, they all need to be offset backwards by the delta - for (int i = 1; i < newControlPoints.Length; i++) - newControlPoints[i] -= e.Delta; - } - else - newControlPoints[index] += e.Delta; - - if (isSegmentSeparatorWithNext) - newControlPoints[index + 1] = newControlPoints[index]; - - if (isSegmentSeparatorWithPrevious) - newControlPoints[index - 1] = newControlPoints[index]; - - slider.Path = new SliderPath(slider.Path.Type, newControlPoints); - - return true; + updateMarkerDisplay(); + return false; } - protected override bool OnDragEnd(DragEndEvent e) => true; + protected override void OnHoverLost(HoverLostEvent e) + { + updateMarkerDisplay(); + } - private bool isSegmentSeparator => isSegmentSeparatorWithNext || isSegmentSeparatorWithPrevious; + protected override bool OnMouseDown(MouseDownEvent e) + { + if (RequestSelection == null) + return false; - private bool isSegmentSeparatorWithNext => index < slider.Path.ControlPoints.Length - 1 && slider.Path.ControlPoints[index + 1] == slider.Path.ControlPoints[index]; + switch (e.Button) + { + case MouseButton.Left: + RequestSelection.Invoke(this, e); + return true; - private bool isSegmentSeparatorWithPrevious => index > 0 && slider.Path.ControlPoints[index - 1] == slider.Path.ControlPoints[index]; + case MouseButton.Right: + if (!IsSelected.Value) + RequestSelection.Invoke(this, e); + return false; // Allow context menu to show + } + + return false; + } + + protected override bool OnClick(ClickEvent e) => RequestSelection != null; + + protected override bool OnDragStart(DragStartEvent e) + { + if (RequestSelection == null) + return false; + + if (e.Button == MouseButton.Left) + { + changeHandler?.BeginChange(); + return true; + } + + return false; + } + + protected override void OnDrag(DragEvent e) + { + 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 + var result = snapProvider?.SnapScreenSpacePositionToValidTime(e.ScreenSpaceMousePosition); + + Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? e.ScreenSpaceMousePosition) - slider.Position; + + slider.Position += movementDelta; + slider.StartTime = result?.Time ?? slider.StartTime; + + // Since control points are relative to the position of the slider, they all need to be offset backwards by the delta + for (int i = 1; i < slider.Path.ControlPoints.Count; i++) + slider.Path.ControlPoints[i].Position.Value -= movementDelta; + } + else + ControlPoint.Position.Value += e.Delta; + } + + protected override void OnDragEnd(DragEndEvent e) => changeHandler?.EndChange(); + + /// + /// Updates the state of the circular control point marker. + /// + private void updateMarkerDisplay() + { + Position = slider.StackedPosition + ControlPoint.Position.Value; + + markerRing.Alpha = IsSelected.Value ? 1 : 0; + + Color4 colour = ControlPoint.Type.Value != null ? colours.Red : colours.Yellow; + + if (IsHovered || IsSelected.Value) + colour = colour.Lighten(1); + + marker.Colour = colour; + } } } 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 df846b5d5b..17541866ec 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -1,39 +1,219 @@ // 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 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.UserInterface; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { - public class PathControlPointVisualiser : SliderPiece + public class PathControlPointVisualiser : CompositeDrawable, IKeyBindingHandler, IHasContextMenu { + internal readonly Container Pieces; + internal readonly Container Connections; + + private readonly IBindableList controlPoints = new BindableList(); private readonly Slider slider; + private readonly bool allowSelection; - private readonly Container pieces; + private InputManager inputManager; - public PathControlPointVisualiser(Slider slider) - : base(slider) + public Action> RemoveControlPointsRequested; + + public PathControlPointVisualiser(Slider slider, bool allowSelection) { this.slider = slider; + this.allowSelection = allowSelection; - InternalChild = pieces = new Container { RelativeSizeAxes = Axes.Both }; + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + Connections = new Container { RelativeSizeAxes = Axes.Both }, + Pieces = new Container { RelativeSizeAxes = Axes.Both } + }; } - [BackgroundDependencyLoader] - private void load() + protected override void LoadComplete() { - PathBindable.BindValueChanged(_ => updatePathControlPoints(), true); + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + + controlPoints.CollectionChanged += onControlPointsChanged; + controlPoints.BindTo(slider.Path.ControlPoints); } - private void updatePathControlPoints() + private void onControlPointsChanged(object sender, NotifyCollectionChangedEventArgs e) { - while (slider.Path.ControlPoints.Length > pieces.Count) - pieces.Add(new PathControlPointPiece(slider, pieces.Count)); - while (slider.Path.ControlPoints.Length < pieces.Count) - pieces.Remove(pieces[pieces.Count - 1]); + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + for (int i = 0; i < e.NewItems.Count; i++) + { + var point = (PathControlPoint)e.NewItems[i]; + + Pieces.Add(new PathControlPointPiece(slider, point).With(d => + { + if (allowSelection) + d.RequestSelection = selectPiece; + })); + + Connections.Add(new PathControlPointConnectionPiece(slider, e.NewStartingIndex + i)); + } + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var point in e.OldItems.Cast()) + { + Pieces.RemoveAll(p => p.ControlPoint == point); + Connections.RemoveAll(c => c.ControlPoint == point); + } + + break; + } + } + + protected override bool OnClick(ClickEvent e) + { + foreach (var piece in Pieces) + { + piece.IsSelected.Value = false; + } + + return false; + } + + public bool OnPressed(PlatformAction action) + { + switch (action.ActionMethod) + { + case PlatformActionMethod.Delete: + return DeleteSelected(); + } + + return false; + } + + public void OnReleased(PlatformAction action) + { + } + + private void selectPiece(PathControlPointPiece piece, MouseButtonEvent e) + { + if (e.Button == MouseButton.Left && inputManager.CurrentState.Keyboard.ControlPressed) + piece.IsSelected.Toggle(); + else + { + foreach (var p in Pieces) + p.IsSelected.Value = p == piece; + } + } + + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } + + public bool DeleteSelected() + { + List toRemove = Pieces.Where(p => p.IsSelected.Value).Select(p => p.ControlPoint).ToList(); + + // Ensure that there are any points to be deleted + if (toRemove.Count == 0) + return false; + + changeHandler?.BeginChange(); + RemoveControlPointsRequested?.Invoke(toRemove); + changeHandler?.EndChange(); + + // Since pieces are re-used, they will not point to the deleted control points while remaining selected + foreach (var piece in Pieces) + piece.IsSelected.Value = false; + + return true; + } + + public MenuItem[] ContextMenuItems + { + get + { + if (!Pieces.Any(p => p.IsHovered)) + return null; + + var selectedPieces = Pieces.Where(p => p.IsSelected.Value).ToList(); + int count = selectedPieces.Count; + + if (count == 0) + return null; + + List items = new List(); + + if (!selectedPieces.Contains(Pieces[0])) + items.Add(createMenuItemForPathType(null)); + + // todo: hide/disable items which aren't valid for selected points + items.Add(createMenuItemForPathType(PathType.Linear)); + items.Add(createMenuItemForPathType(PathType.PerfectCurve)); + items.Add(createMenuItemForPathType(PathType.Bezier)); + items.Add(createMenuItemForPathType(PathType.Catmull)); + + return new MenuItem[] + { + new OsuMenuItem($"Delete {"control point".ToQuantity(count, count > 1 ? ShowQuantityAs.Numeric : ShowQuantityAs.None)}", MenuItemType.Destructive, () => DeleteSelected()), + new OsuMenuItem("Curve type") + { + Items = items + } + }; + } + } + + private MenuItem createMenuItemForPathType(PathType? type) + { + 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, () => + { + foreach (var p in Pieces.Where(p => p.IsSelected.Value)) + p.ControlPoint.Type.Value = type; + }); + + if (countOfState == totalCount) + item.State.Value = TernaryState.True; + else if (countOfState > 0) + item.State.Value = TernaryState.Indeterminate; + else + item.State.Value = TernaryState.False; + + 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 f1f55731b6..5581ce4bfd 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs @@ -11,19 +11,20 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { - public class SliderBodyPiece : SliderPiece + public class SliderBodyPiece : BlueprintPiece { - private readonly Slider slider; private readonly ManualSliderBody body; - public SliderBodyPiece(Slider slider) - : base(slider) - { - this.slider = slider; + /// + /// Offset in absolute (local) coordinates from the start of the curve. + /// + public Vector2 PathStartLocation => body.PathOffset; + public SliderBodyPiece() + { InternalChild = body = new ManualSliderBody { - AccentColour = Color4.Transparent, + AccentColour = Color4.Transparent }; } @@ -31,24 +32,25 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components private void load(OsuColour colours) { body.BorderColour = colours.Yellow; - - PositionBindable.BindValueChanged(_ => updatePosition(), true); - ScaleBindable.BindValueChanged(scale => body.PathRadius = scale.NewValue * OsuHitObject.OBJECT_RADIUS, true); } - private void updatePosition() => Position = slider.StackedPosition; - - protected override void Update() + public override void UpdateFrom(Slider hitObject) { - base.Update(); + base.UpdateFrom(hitObject); + + body.PathRadius = hitObject.Scale * OsuHitObject.OBJECT_RADIUS; var vertices = new List(); - slider.Path.GetPathToProgress(vertices, 0, 1); + hitObject.Path.GetPathToProgress(vertices, 0, 1); body.SetVertices(vertices); Size = body.Size; OriginPosition = body.PathOffset; } + + public void RecyclePath() => body.RecyclePath(); + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => body.ReceivePositionalInputAt(screenSpacePos); } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderCirclePiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderCirclePiece.cs deleted file mode 100644 index 2ecfea2e3e..0000000000 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderCirclePiece.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.Allocation; -using osu.Framework.Bindables; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; -using osu.Game.Rulesets.Osu.Objects; - -namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components -{ - public class SliderCirclePiece : HitCirclePiece - { - private readonly IBindable pathBindable = new Bindable(); - - private readonly Slider slider; - private readonly SliderPosition position; - - public SliderCirclePiece(Slider slider, SliderPosition position) - : base(slider.HeadCircle) - { - this.slider = slider; - this.position = position; - } - - [BackgroundDependencyLoader] - private void load() - { - pathBindable.BindTo(slider.PathBindable); - pathBindable.BindValueChanged(_ => UpdatePosition(), true); - } - - protected override void UpdatePosition() - { - switch (position) - { - case SliderPosition.Start: - Position = slider.StackedPosition + slider.Path.PositionAt(0); - break; - - case SliderPosition.End: - Position = slider.StackedPosition + slider.Path.PositionAt(1); - break; - } - } - } -} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs index c9f005495c..a0392fe536 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderCircleSelectionBlueprint.cs @@ -1,22 +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.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +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 SliderCircleSelectionBlueprint : OsuSelectionBlueprint { - public SliderCircleSelectionBlueprint(DrawableOsuHitObject hitObject, Slider slider, SliderPosition position) - : base(hitObject) + protected readonly HitCirclePiece CirclePiece; + + private readonly SliderPosition position; + + public SliderCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) + : base(slider) { - InternalChild = new SliderCirclePiece(slider, position); + this.position = position; + + InternalChild = CirclePiece = new HitCirclePiece(); Select(); } + protected override void Update() + { + base.Update(); + + CirclePiece.UpdateFrom(position == SliderPosition.Start ? HitObject.HeadCircle : HitObject.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 55de626d7d..4b99cc23ed 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -1,16 +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 System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Framework.Input; using osu.Framework.Input.Events; 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.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osuTK; using osuTK.Input; @@ -21,16 +23,28 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { public new Objects.Slider HitObject => (Objects.Slider)base.HitObject; - private readonly List segments = new List(); - private Vector2 cursor; + private SliderBodyPiece bodyPiece; + private HitCirclePiece headCirclePiece; + private HitCirclePiece tailCirclePiece; + private PathControlPointVisualiser controlPointVisualiser; + + private InputManager inputManager; private PlacementState state; + private PathControlPoint segmentStart; + private PathControlPoint cursor; + private int currentSegmentLength; + + [Resolved(CanBeNull = true)] + private HitObjectComposer composer { get; set; } public SliderPlacementBlueprint() : base(new Objects.Slider()) { RelativeSizeAxes = Axes.Both; - segments.Add(new Segment(Vector2.Zero)); + + HitObject.Path.ControlPoints.Add(segmentStart = new PathControlPoint(Vector2.Zero, PathType.Linear)); + currentSegmentLength = 1; } [BackgroundDependencyLoader] @@ -38,10 +52,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { InternalChildren = new Drawable[] { - new SliderBodyPiece(HitObject), - new SliderCirclePiece(HitObject, SliderPosition.Start), - new SliderCirclePiece(HitObject, SliderPosition.End), - new PathControlPointVisualiser(HitObject), + bodyPiece = new SliderBodyPiece(), + headCirclePiece = new HitCirclePiece(), + tailCirclePiece = new HitCirclePiece(), + controlPointVisualiser = new PathControlPointVisualiser(HitObject, false) }; setState(PlacementState.Initial); @@ -50,29 +64,31 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders protected override void LoadComplete() { base.LoadComplete(); - - // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame - HitObject.Position = Parent?.ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position) ?? Vector2.Zero; + inputManager = GetContainingInputManager(); } - protected override bool OnMouseMove(MouseMoveEvent e) + public override void UpdatePosition(SnapResult result) { + base.UpdatePosition(result); + switch (state) { case PlacementState.Initial: - HitObject.Position = e.MousePosition; - return true; + BeginPlacement(); + HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); + break; case PlacementState.Body: - cursor = e.MousePosition - HitObject.Position; - return true; + updateCursor(); + break; } - - return false; } - protected override bool OnClick(ClickEvent e) + protected override bool OnMouseDown(MouseDownEvent e) { + if (e.Button != MouseButton.Left) + return base.OnMouseDown(e); + switch (state) { case PlacementState.Initial: @@ -80,11 +96,21 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders break; case PlacementState.Body: - switch (e.Button) + if (canPlaceNewControlPoint(out var lastPoint)) { - case MouseButton.Left: - segments.Last().ControlPoints.Add(cursor); - break; + // Place a new point by detatching the current cursor. + updateCursor(); + cursor = null; + } + else + { + // Transform the last point into a new segment. + Debug.Assert(lastPoint != null); + + segmentStart = lastPoint; + segmentStart.Type.Value = PathType.Linear; + + currentSegmentLength = 1; } break; @@ -93,31 +119,23 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { if (state == PlacementState.Body && e.Button == MouseButton.Right) endCurve(); - return base.OnMouseUp(e); - } - - protected override bool OnDoubleClick(DoubleClickEvent e) - { - segments.Add(new Segment(segments[segments.Count - 1].ControlPoints.Last())); - return true; + base.OnMouseUp(e); } private void beginCurve() { - BeginPlacement(); - - HitObject.StartTime = EditorClock.CurrentTime; + BeginPlacement(commitStart: true); setState(PlacementState.Body); } private void endCurve() { updateSlider(); - EndPlacement(); + EndPlacement(true); } protected override void Update() @@ -126,10 +144,76 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders updateSlider(); } + private void updatePathType() + { + switch (currentSegmentLength) + { + case 1: + case 2: + segmentStart.Type.Value = PathType.Linear; + break; + + case 3: + segmentStart.Type.Value = PathType.PerfectCurve; + break; + + default: + segmentStart.Type.Value = PathType.Bezier; + break; + } + } + + private void updateCursor() + { + if (canPlaceNewControlPoint(out _)) + { + // The cursor does not overlap a previous control point, so it can be added if not already existing. + if (cursor == null) + { + HitObject.Path.ControlPoints.Add(cursor = new PathControlPoint { Position = { Value = Vector2.Zero } }); + + // The path type should be adjusted in the progression of updatePathType() (Linear -> PC -> Bezier). + currentSegmentLength++; + updatePathType(); + } + + // Update the cursor position. + cursor.Position.Value = ToLocalSpace(inputManager.CurrentState.Mouse.Position) - HitObject.Position; + } + else if (cursor != null) + { + // The cursor overlaps a previous control point, so it's removed. + HitObject.Path.ControlPoints.Remove(cursor); + cursor = null; + + // The path type should be adjusted in the reverse progression of updatePathType() (Bezier -> PC -> Linear). + currentSegmentLength--; + updatePathType(); + } + } + + /// + /// Whether a new control point can be placed at the current mouse position. + /// + /// The last-placed control point. May be null, but is not null if false is returned. + /// Whether a new control point can be placed at the current position. + private bool canPlaceNewControlPoint([CanBeNull] out PathControlPoint lastPoint) + { + // We cannot rely on the ordering of drawable pieces, so find the respective drawable piece by searching for the last non-cursor control point. + var last = HitObject.Path.ControlPoints.LastOrDefault(p => p != cursor); + var lastPiece = controlPointVisualiser.Pieces.Single(p => p.ControlPoint == last); + + lastPoint = last; + return lastPiece?.IsHovered != true; + } + private void updateSlider() { - var newControlPoints = segments.SelectMany(s => s.ControlPoints).Concat(cursor.Yield()).ToArray(); - HitObject.Path = new SliderPath(newControlPoints.Length > 2 ? PathType.Bezier : PathType.Linear, newControlPoints); + HitObject.Path.ExpectedDistance.Value = composer?.GetSnappedDistanceFromDistance(HitObject.StartTime, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; + + bodyPiece.UpdateFrom(HitObject); + headCirclePiece.UpdateFrom(HitObject.HeadCircle); + tailCirclePiece.UpdateFrom(HitObject.TailCircle); } private void setState(PlacementState newState) @@ -142,15 +226,5 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders Initial, Body, } - - private class Segment - { - public readonly List ControlPoints = new List(); - - public Segment(Vector2 offset) - { - ControlPoints.Add(offset); - } - } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index fb8c081ff7..7ae4f387ca 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -1,32 +1,242 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; 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.Screens.Edit; +using osu.Game.Screens.Edit.Compose; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { - public class SliderSelectionBlueprint : OsuSelectionBlueprint + public class SliderSelectionBlueprint : OsuSelectionBlueprint { - private readonly SliderCircleSelectionBlueprint headBlueprint; + protected SliderBodyPiece BodyPiece { get; private set; } + protected SliderCircleSelectionBlueprint HeadBlueprint { get; private set; } + protected SliderCircleSelectionBlueprint TailBlueprint { get; private set; } + protected PathControlPointVisualiser ControlPointVisualiser { get; private set; } + + private readonly DrawableSlider slider; + + [Resolved(CanBeNull = true)] + private HitObjectComposer composer { get; set; } + + [Resolved(CanBeNull = true)] + private IPlacementHandler placementHandler { get; set; } + + [Resolved(CanBeNull = true)] + private EditorBeatmap editorBeatmap { get; set; } + + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } public SliderSelectionBlueprint(DrawableSlider slider) : base(slider) { - var sliderObject = (Slider)slider.HitObject; + this.slider = slider; + } + [BackgroundDependencyLoader] + private void load() + { InternalChildren = new Drawable[] { - new SliderBodyPiece(sliderObject), - headBlueprint = new SliderCircleSelectionBlueprint(slider.HeadCircle, sliderObject, SliderPosition.Start), - new SliderCircleSelectionBlueprint(slider.TailCircle, sliderObject, SliderPosition.End), - new PathControlPointVisualiser(sliderObject), + BodyPiece = new SliderBodyPiece(), + HeadBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.Start), + TailBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.End), }; } - public override Vector2 SelectionPoint => headBlueprint.SelectionPoint; + private IBindable pathVersion; + + protected override void LoadComplete() + { + base.LoadComplete(); + + pathVersion = HitObject.Path.Version.GetBoundCopy(); + pathVersion.BindValueChanged(_ => updatePath()); + + BodyPiece.UpdateFrom(HitObject); + } + + public override bool HandleQuickDeletion() + { + var hoveredControlPoint = ControlPointVisualiser?.Pieces.FirstOrDefault(p => p.IsHovered); + + if (hoveredControlPoint == null) + return false; + + hoveredControlPoint.IsSelected.Value = true; + ControlPointVisualiser.DeleteSelected(); + return true; + } + + protected override void Update() + { + base.Update(); + + if (IsSelected) + BodyPiece.UpdateFrom(HitObject); + } + + protected override void OnSelected() + { + AddInternal(ControlPointVisualiser = new PathControlPointVisualiser(slider.HitObject, true) + { + RemoveControlPointsRequested = removeControlPoints + }); + + base.OnSelected(); + } + + protected override void OnDeselected() + { + base.OnDeselected(); + + // throw away frame buffers on deselection. + ControlPointVisualiser?.Expire(); + BodyPiece.RecyclePath(); + } + + private Vector2 rightClickPosition; + + protected override bool OnMouseDown(MouseDownEvent e) + { + switch (e.Button) + { + case MouseButton.Right: + rightClickPosition = e.MouseDownPosition; + return false; // Allow right click to be handled by context menu + + case MouseButton.Left: + if (e.ControlPressed && IsSelected) + { + placementControlPointIndex = addControlPoint(e.MousePosition); + return true; // Stop input from being handled and modifying the selection + } + + break; + } + + return false; + } + + private int? placementControlPointIndex; + + protected override bool OnDragStart(DragStartEvent e) + { + if (placementControlPointIndex != null) + { + changeHandler?.BeginChange(); + return true; + } + + return false; + } + + protected override void OnDrag(DragEvent e) + { + Debug.Assert(placementControlPointIndex != null); + + HitObject.Path.ControlPoints[placementControlPointIndex.Value].Position.Value = e.MousePosition - HitObject.Position; + } + + protected override void OnDragEnd(DragEndEvent e) + { + if (placementControlPointIndex != null) + { + placementControlPointIndex = null; + changeHandler?.EndChange(); + } + } + + private BindableList controlPoints => HitObject.Path.ControlPoints; + + private int addControlPoint(Vector2 position) + { + position -= HitObject.Position; + + int insertionIndex = 0; + float minDistance = float.MaxValue; + + for (int i = 0; i < controlPoints.Count - 1; i++) + { + float dist = new Line(controlPoints[i].Position.Value, controlPoints[i + 1].Position.Value).DistanceToPoint(position); + + if (dist < minDistance) + { + insertionIndex = i + 1; + minDistance = dist; + } + } + + // Move the control points from the insertion index onwards to make room for the insertion + controlPoints.Insert(insertionIndex, new PathControlPoint { Position = { Value = position } }); + + return insertionIndex; + } + + private void removeControlPoints(List toRemove) + { + // Ensure that there are any points to be deleted + if (toRemove.Count == 0) + return; + + foreach (var c in toRemove) + { + // The first control point in the slider must have a type, so take it from the previous "first" one + // Todo: Should be handled within SliderPath itself + if (c == controlPoints[0] && controlPoints.Count > 1 && controlPoints[1].Type.Value == null) + controlPoints[1].Type.Value = controlPoints[0].Type.Value; + + controlPoints.Remove(c); + } + + // 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) + { + placementHandler?.Delete(HitObject); + return; + } + + // The path will have a non-zero offset if the head is removed, but sliders don't support this behaviour since the head is positioned at the slider's position + // So the slider needs to be offset by this amount instead, and all control points offset backwards such that the path is re-positioned at (0, 0) + Vector2 first = controlPoints[0].Position.Value; + foreach (var c in controlPoints) + c.Position.Value -= first; + HitObject.Position += first; + } + + private void updatePath() + { + HitObject.Path.ExpectedDistance.Value = composer?.GetSnappedDistanceFromDistance(HitObject.StartTime, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; + editorBeatmap?.Update(HitObject); + } + + public override MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("Add control point", MenuItemType.Standard, () => addControlPoint(rightClickPosition)), + }; + + public override Vector2 ScreenSpaceSelectionPoint => 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); } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs index ae94848c81..2347d8a34c 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs @@ -12,17 +12,13 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components { - public class SpinnerPiece : HitObjectPiece + public class SpinnerPiece : BlueprintPiece { - private readonly Spinner spinner; private readonly CircularContainer circle; private readonly RingPiece ring; - public SpinnerPiece(Spinner spinner) - : base(spinner) + public SpinnerPiece() { - this.spinner = spinner; - Origin = Anchor.Centre; RelativeSizeAxes = Axes.Both; @@ -38,27 +34,22 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components Alpha = 0.5f, Child = new Box { RelativeSizeAxes = Axes.Both } }, - ring = new RingPiece - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - } + ring = new RingPiece() }; - - ring.Scale = new Vector2(spinner.Scale); } [BackgroundDependencyLoader] private void load(OsuColour colours) { Colour = colours.Yellow; - - PositionBindable.BindValueChanged(_ => updatePosition(), true); - StackHeightBindable.BindValueChanged(_ => updatePosition()); - ScaleBindable.BindValueChanged(scale => ring.Scale = new Vector2(scale.NewValue), true); } - private void updatePosition() => Position = spinner.Position; + public override void UpdateFrom(Spinner hitObject) + { + base.UpdateFrom(hitObject); + + ring.Scale = new Vector2(hitObject.Scale); + } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => circle.ReceivePositionalInputAt(screenSpacePos); } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs index 03d761c67f..cc4ed0eccf 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs @@ -1,12 +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 System; using osu.Framework.Graphics; using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; +using osuTK.Input; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners { @@ -21,24 +23,38 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners public SpinnerPlacementBlueprint() : base(new Spinner { Position = OsuPlayfield.BASE_SIZE / 2 }) { - InternalChild = piece = new SpinnerPiece(HitObject) { Alpha = 0.5f }; + InternalChild = piece = new SpinnerPiece { Alpha = 0.5f }; } - protected override bool OnClick(ClickEvent e) + protected override void Update() + { + base.Update(); + + if (isPlacingEnd) + HitObject.EndTime = Math.Max(HitObject.StartTime, EditorClock.CurrentTime); + + piece.UpdateFrom(HitObject); + } + + protected override bool OnMouseDown(MouseDownEvent e) { if (isPlacingEnd) { + if (e.Button != MouseButton.Right) + return false; + HitObject.EndTime = EditorClock.CurrentTime; - EndPlacement(); + EndPlacement(true); } else { - HitObject.StartTime = EditorClock.CurrentTime; + if (e.Button != MouseButton.Left) + return false; - isPlacingEnd = true; + BeginPlacement(commitStart: true); piece.FadeTo(1f, 150, Easing.OutQuint); - BeginPlacement(); + isPlacingEnd = true; } return true; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs index 25cef3b251..f05d4f8435 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerSelectionBlueprint.cs @@ -8,14 +8,21 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners { - public class SpinnerSelectionBlueprint : OsuSelectionBlueprint + public class SpinnerSelectionBlueprint : OsuSelectionBlueprint { private readonly SpinnerPiece piece; public SpinnerSelectionBlueprint(DrawableSpinner spinner) : base(spinner) { - InternalChild = piece = new SpinnerPiece((Spinner)spinner.HitObject); + InternalChild = piece = new SpinnerPiece(); + } + + protected override void Update() + { + base.Update(); + + piece.UpdateFrom(HitObject); } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => piece.ReceivePositionalInputAt(screenSpacePos); diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs index bcb6099cfb..746ff4ac19 100644 --- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs +++ b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditRuleset.cs @@ -2,8 +2,13 @@ // 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; +using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.UI; using osuTK; @@ -12,11 +17,55 @@ namespace osu.Game.Rulesets.Osu.Edit { public class DrawableOsuEditRuleset : DrawableOsuRuleset { - public DrawableOsuEditRuleset(Ruleset ruleset, WorkingBeatmap beatmap, IReadOnlyList mods) + /// + /// 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; + + public DrawableOsuEditRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) : base(ruleset, beatmap, mods) { } + public override DrawableHitObject CreateDrawableRepresentation(OsuHitObject h) + => base.CreateDrawableRepresentation(h)?.With(d => d.ApplyCustomUpdateState += updateState); + + 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 extent (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(); + } + protected override Playfield CreatePlayfield() => new OsuPlayfieldNoCursor(); public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer { Size = Vector2.One }; diff --git a/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs index 9c94fe0e3d..5f7c8b77b0 100644 --- a/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.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 osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); + public override PlacementBlueprint CreatePlacementBlueprint() => new HitCirclePlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.cs new file mode 100644 index 0000000000..330f34b85c --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuBlueprintContainer.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 System.Collections.Generic; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects.Drawables; +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.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public class OsuBlueprintContainer : ComposeBlueprintContainer + { + public OsuBlueprintContainer(IEnumerable drawableHitObjects) + : base(drawableHitObjects) + { + } + + protected override SelectionHandler CreateSelectionHandler() => new OsuSelectionHandler(); + + public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) + { + switch (hitObject) + { + case DrawableHitCircle circle: + return new HitCircleSelectionBlueprint(circle); + + case DrawableSlider slider: + return new SliderSelectionBlueprint(slider); + + case DrawableSpinner spinner: + return new SpinnerSelectionBlueprint(spinner); + } + + return base.CreateBlueprintFor(hitObject); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs new file mode 100644 index 0000000000..ff3be97427 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.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 JetBrains.Annotations; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public class OsuDistanceSnapGrid : CircularDistanceSnapGrid + { + public OsuDistanceSnapGrid(OsuHitObject hitObject, [CanBeNull] OsuHitObject nextHitObject = null) + : base(hitObject.StackedEndPosition, hitObject.GetEndTime(), nextHitObject?.StartTime) + { + Masking = true; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index c5452ae0aa..edd684d886 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -1,19 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -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; -using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; +using osuTK; namespace osu.Game.Rulesets.Osu.Edit { @@ -24,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Edit { } - protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, WorkingBeatmap beatmap, IReadOnlyList mods) + protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableOsuEditRuleset(ruleset, beatmap, mods); protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] @@ -34,23 +42,224 @@ namespace osu.Game.Rulesets.Osu.Edit new SpinnerCompositionTool() }; - public override SelectionHandler CreateSelectionHandler() => new OsuSelectionHandler(); + private readonly Bindable distanceSnapToggle = new Bindable(); - public override SelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) + protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] { - switch (hitObject) + new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) + }); + + private BindableList selectedHitObjects; + + private Bindable placementObject; + + [BackgroundDependencyLoader] + private void load() + { + LayerBelowRuleset.AddRange(new Drawable[] { - case DrawableHitCircle circle: - return new HitCircleSelectionBlueprint(circle); + new PlayfieldBorder + { + RelativeSizeAxes = Axes.Both, + PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } + }, + distanceSnapGridContainer = new Container + { + RelativeSizeAxes = Axes.Both + } + }); - case DrawableSlider slider: - return new SliderSelectionBlueprint(slider); + selectedHitObjects = EditorBeatmap.SelectedHitObjects.GetBoundCopy(); + selectedHitObjects.CollectionChanged += (_, __) => updateDistanceSnapGrid(); - case DrawableSpinner spinner: - return new SpinnerSelectionBlueprint(spinner); + placementObject = EditorBeatmap.PlacementObject.GetBoundCopy(); + placementObject.ValueChanged += _ => updateDistanceSnapGrid(); + distanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); + + // we may be entering the screen with a selection already active + updateDistanceSnapGrid(); + } + + protected override ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects) + => new OsuBlueprintContainer(hitObjects); + + private DistanceSnapGrid distanceSnapGrid; + private Container distanceSnapGridContainer; + + private readonly Cached distanceSnapGridCache = new Cached(); + private double? lastDistanceSnapGridTime; + + protected override void Update() + { + base.Update(); + + if (!(BlueprintContainer.CurrentTool is SelectTool)) + { + if (EditorClock.CurrentTime != lastDistanceSnapGridTime) + { + distanceSnapGridCache.Invalidate(); + lastDistanceSnapGridTime = EditorClock.CurrentTime; + } + + if (!distanceSnapGridCache.IsValid) + updateDistanceSnapGrid(); + } + } + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + { + if (snapToVisibleBlueprints(screenSpacePosition, out var snapResult)) + return snapResult; + + // will be null if distance snap is disabled or not feasible for the current time value. + if (distanceSnapGrid == null) + return base.SnapScreenSpacePositionToValidTime(screenSpacePosition); + + (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); + + return new SnapResult(distanceSnapGrid.ToScreenSpace(pos), time, PlayfieldAtScreenSpacePosition(screenSpacePosition)); + } + + private bool snapToVisibleBlueprints(Vector2 screenSpacePosition, out SnapResult snapResult) + { + // check other on-screen objects for snapping/stacking + var blueprints = BlueprintContainer.SelectionBlueprints.AliveChildren; + + var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); + + float snapRadius = + playfield.GamefieldToScreenSpace(new Vector2(OsuHitObject.OBJECT_RADIUS / 5)).X - + playfield.GamefieldToScreenSpace(Vector2.Zero).X; + + foreach (var b in blueprints) + { + if (b.IsSelected) + continue; + + var hitObject = (OsuHitObject)b.HitObject; + + Vector2? snap = checkSnap(hitObject.Position); + if (snap == null && hitObject.Position != hitObject.EndPosition) + snap = checkSnap(hitObject.EndPosition); + + if (snap != null) + { + // only return distance portion, since time is not really valid + snapResult = new SnapResult(snap.Value, null, playfield); + return true; + } + + Vector2? checkSnap(Vector2 checkPos) + { + Vector2 checkScreenPos = playfield.GamefieldToScreenSpace(checkPos); + + if (Vector2.Distance(checkScreenPos, screenSpacePosition) < snapRadius) + return checkScreenPos; + + return null; + } } - return base.CreateBlueprintFor(hitObject); + snapResult = null; + return false; + } + + private void updateDistanceSnapGrid() + { + distanceSnapGridContainer.Clear(); + distanceSnapGridCache.Invalidate(); + distanceSnapGrid = null; + + if (distanceSnapToggle.Value != TernaryState.True) + return; + + switch (BlueprintContainer.CurrentTool) + { + case SelectTool _: + if (!EditorBeatmap.SelectedHitObjects.Any()) + return; + + distanceSnapGrid = createDistanceSnapGrid(EditorBeatmap.SelectedHitObjects); + break; + + default: + if (!CursorInPlacementArea) + return; + + distanceSnapGrid = createDistanceSnapGrid(Enumerable.Empty()); + break; + } + + if (distanceSnapGrid != null) + { + distanceSnapGridContainer.Add(distanceSnapGrid); + distanceSnapGridCache.Validate(); + } + } + + private DistanceSnapGrid createDistanceSnapGrid(IEnumerable selectedHitObjects) + { + if (BlueprintContainer.CurrentTool is SpinnerCompositionTool) + return null; + + var objects = selectedHitObjects.ToList(); + + if (objects.Count == 0) + // use accurate time value to give more instantaneous feedback to the user. + return createGrid(h => h.StartTime <= EditorClock.CurrentTimeAccurate); + + double minTime = objects.Min(h => h.StartTime); + return createGrid(h => h.StartTime < minTime, objects.Count + 1); + } + + /// + /// Creates a grid from the last matching a predicate to a target . + /// + /// A predicate that matches s where the grid can start from. + /// Only the last matching the predicate is used. + /// An offset from the selected via at which the grid should stop. + /// The from a selected to a target . + private OsuDistanceSnapGrid createGrid(Func sourceSelector, int targetOffset = 1) + { + if (targetOffset < 1) throw new ArgumentOutOfRangeException(nameof(targetOffset)); + + int sourceIndex = -1; + + for (int i = 0; i < EditorBeatmap.HitObjects.Count; i++) + { + if (!sourceSelector(EditorBeatmap.HitObjects[i])) + break; + + sourceIndex = i; + } + + if (sourceIndex == -1) + return null; + + HitObject sourceObject = EditorBeatmap.HitObjects[sourceIndex]; + + int targetIndex = sourceIndex + targetOffset; + HitObject targetObject = null; + + // Keep advancing the target object while its start time falls before the end time of the source object + while (true) + { + if (targetIndex >= EditorBeatmap.HitObjects.Count) + break; + + if (EditorBeatmap.HitObjects[targetIndex].StartTime >= sourceObject.GetEndTime()) + { + targetObject = EditorBeatmap.HitObjects[targetIndex]; + break; + } + + targetIndex++; + } + + if (sourceObject is Spinner) + return null; + + return new OsuDistanceSnapGrid((OsuHitObject)sourceObject, (OsuHitObject)targetObject); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 1ab1219ab0..a72dcff1e9 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -1,30 +1,284 @@ // 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.Input.Events; -using osu.Game.Rulesets.Edit; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit.Compose.Components; +using osuTK; namespace osu.Game.Rulesets.Osu.Edit { public class OsuSelectionHandler : SelectionHandler { - public override void HandleDrag(SelectionBlueprint blueprint, DragEvent dragEvent) + protected override void OnSelectionChanged() { - foreach (var h in SelectedHitObjects.OfType()) - { - if (h is Spinner) - { - // Spinners don't support position adjustments - continue; - } + base.OnSelectionChanged(); - h.Position += dragEvent.Delta; + bool canOperate = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider); + + SelectionBox.CanRotate = canOperate; + SelectionBox.CanScaleX = canOperate; + SelectionBox.CanScaleY = canOperate; + SelectionBox.CanReverse = canOperate; + } + + protected override void OnOperationEnded() + { + base.OnOperationEnded(); + referenceOrigin = null; + } + + public override bool HandleMovement(MoveSelectionEvent moveEvent) => + moveSelection(moveEvent.InstantDelta); + + /// + /// During a transform, the initial origin is stored so it can be used throughout the operation. + /// + private Vector2? referenceOrigin; + + public override bool HandleReverse() + { + var hitObjects = selectedMovableObjects; + + double endTime = hitObjects.Max(h => h.GetEndTime()); + double startTime = hitObjects.Min(h => h.StartTime); + + bool moreThanOneObject = hitObjects.Length > 1; + + foreach (var h in hitObjects) + { + if (moreThanOneObject) + h.StartTime = endTime - (h.GetEndTime() - startTime); + + if (h is Slider slider) + { + var points = slider.Path.ControlPoints.ToArray(); + Vector2 endPos = points.Last().Position.Value; + + slider.Path.ControlPoints.Clear(); + + slider.Position += endPos; + + PathType? lastType = null; + + for (var i = 0; i < points.Length; i++) + { + var p = points[i]; + p.Position.Value -= endPos; + + // propagate types forwards to last null type + if (i == points.Length - 1) + p.Type.Value = lastType; + else if (p.Type.Value != null) + { + var newType = p.Type.Value; + p.Type.Value = lastType; + lastType = newType; + } + + slider.Path.ControlPoints.Insert(0, p); + } + } } - base.HandleDrag(blueprint, dragEvent); + return true; + } + + public override bool HandleFlip(Direction direction) + { + 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; + + if (h is Slider slider) + { + foreach (var point in slider.Path.ControlPoints) + { + point.Position.Value = new Vector2( + (direction == Direction.Horizontal ? -1 : 1) * point.Position.Value.X, + (direction == Direction.Vertical ? -1 : 1) * point.Position.Value.Y + ); + } + } + } + + return true; + } + + public override bool HandleScale(Vector2 scale, Anchor reference) + { + adjustScaleFromAnchor(ref scale, reference); + + var hitObjects = selectedMovableObjects; + + // for the time being, allow resizing of slider paths only if the slider is + // 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; + } + 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) + { + h.Position = new Vector2( + quad.TopLeft.X + (h.X - quad.TopLeft.X) / quad.Width * (quad.Width + scale.X), + quad.TopLeft.Y + (h.Y - quad.TopLeft.Y) / quad.Height * (quad.Height + scale.Y) + ); + } + } + + return true; + } + + 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; + } + + public override bool HandleRotation(float delta) + { + var hitObjects = selectedMovableObjects; + + Quad quad = getSurroundingQuad(hitObjects); + + referenceOrigin ??= quad.Centre; + + foreach (var h in hitObjects) + { + 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); + } + } + + // this isn't always the case but let's be lenient for now. + return true; + } + + private bool moveSelection(Vector2 delta) + { + var hitObjects = selectedMovableObjects; + + Quad quad = getSurroundingQuad(hitObjects); + + if (quad.TopLeft.X + delta.X < 0 || + quad.TopLeft.Y + delta.Y < 0 || + quad.BottomRight.X + delta.X > DrawWidth || + quad.BottomRight.Y + delta.Y > DrawHeight) + return false; + + foreach (var h in hitObjects) + h.Position += delta; + + return true; + } + + /// + /// Returns a gamefield-space quad surrounding the provided hit objects. + /// + /// The hit objects to calculate a quad for. + private Quad getSurroundingQuad(OsuHitObject[] hitObjects) => + getSurroundingQuad(hitObjects.SelectMany(h => new[] { h.Position, h.EndPosition })); + + /// + /// 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() + .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/Edit/SliderCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs index a377deb35f..596224e5c6 100644 --- a/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.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 osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); + public override PlacementBlueprint CreatePlacementBlueprint() => new SliderPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs index 0de0af8f8c..c5e90da3bd 100644 --- a/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.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 osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners); + public override PlacementBlueprint CreatePlacementBlueprint() => new SpinnerPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.cs new file mode 100644 index 0000000000..9b33e746b3 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Judgements/OsuHitCircleJudgementResult.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 osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Judgements +{ + public class OsuHitCircleJudgementResult : OsuJudgementResult + { + /// + /// The . + /// + public HitCircle HitCircle => (HitCircle)HitObject; + + /// + /// The position of the player's cursor when was hit. + /// + public Vector2? CursorPositionAtHit; + + public OsuHitCircleJudgementResult(HitObject hitObject, Judgement judgement) + : base(hitObject, judgement) + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuIgnoreJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/OsuIgnoreJudgement.cs new file mode 100644 index 0000000000..1999785efe --- /dev/null +++ b/osu.Game.Rulesets.Osu/Judgements/OsuIgnoreJudgement.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.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Judgements +{ + public class OsuIgnoreJudgement : OsuJudgement + { + public override HitResult MaxResult => HitResult.IgnoreHit; + } +} diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs index 7a5b98864c..1a88e2a8b2 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuJudgement.cs @@ -9,40 +9,5 @@ namespace osu.Game.Rulesets.Osu.Judgements public class OsuJudgement : Judgement { public override HitResult MaxResult => HitResult.Great; - - protected override int NumericResultFor(HitResult result) - { - switch (result) - { - default: - return 0; - - case HitResult.Meh: - return 50; - - case HitResult.Good: - return 100; - - case HitResult.Great: - return 300; - } - } - - protected override double HealthIncreaseFor(HitResult result) - { - switch (result) - { - case HitResult.Miss: - return -0.02; - - case HitResult.Meh: - case HitResult.Good: - case HitResult.Great: - return 0.01; - - default: - return 0; - } - } } } diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuJudgementResult.cs b/osu.Game.Rulesets.Osu/Judgements/OsuJudgementResult.cs index c7661bddb1..15444b847b 100644 --- a/osu.Game.Rulesets.Osu/Judgements/OsuJudgementResult.cs +++ b/osu.Game.Rulesets.Osu/Judgements/OsuJudgementResult.cs @@ -2,6 +2,7 @@ // 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.Osu.Judgements { @@ -9,8 +10,8 @@ namespace osu.Game.Rulesets.Osu.Judgements { public ComboResult ComboType; - public OsuJudgementResult(Judgement judgement) - : base(judgement) + public OsuJudgementResult(HitObject hitObject, Judgement judgement) + : base(hitObject, judgement) { } } diff --git a/osu.Game.Rulesets.Osu/Judgements/OsuSliderTailJudgement.cs b/osu.Game.Rulesets.Osu/Judgements/OsuSliderTailJudgement.cs deleted file mode 100644 index 5104d9494b..0000000000 --- a/osu.Game.Rulesets.Osu/Judgements/OsuSliderTailJudgement.cs +++ /dev/null @@ -1,14 +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.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Osu.Judgements -{ - public class OsuSliderTailJudgement : OsuJudgement - { - public override bool AffectsCombo => false; - - protected override int NumericResultFor(HitResult result) => 0; - } -} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index 401bd28d7c..8c819c4773 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -2,20 +2,67 @@ // 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.Sprites; +using osu.Framework.Input.StateChanges; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModAutopilot : Mod + public class OsuModAutopilot : Mod, IApplicableFailOverride, IUpdatableByPlayfield, IApplicableToDrawableRuleset { public override string Name => "Autopilot"; public override string Acronym => "AP"; - public override IconUsage Icon => OsuIcon.ModAutopilot; + public override IconUsage? Icon => OsuIcon.ModAutopilot; 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 bool PerformFail() => false; + + public bool RestartOnFail => false; + + private OsuInputManager inputManager; + + private IFrameStableClock gameplayClock; + + private List replayFrames; + + private int currentFrame; + + public void Update(Playfield playfield) + { + if (currentFrame == replayFrames.Count - 1) return; + + double time = gameplayClock.CurrentTime; + + // Very naive implementation of autopilot based on proximity to replay frames. + // TODO: this needs to be based on user interactions to better match stable (pausing until judgement is registered). + if (Math.Abs(replayFrames[currentFrame + 1].Time - time) <= Math.Abs(replayFrames[currentFrame].Time - time)) + { + currentFrame++; + new MousePositionAbsoluteInput { Position = playfield.ToScreenSpace(replayFrames[currentFrame].Position) }.Apply(inputManager.CurrentState, inputManager); + } + + // TODO: Implement the functionality to automatically spin spinners + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + gameplayClock = drawableRuleset.FrameStableClock; + + // Grab the input manager to disable the user's cursor, and for future use + inputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager; + inputManager.AllowUserCursorMovement = false; + + // Generate the replay frames the cursor should follow + replayFrames = new OsuAutoGenerator(drawableRuleset.Beatmap).Generate().Frames.Cast().ToList(); + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs index 445f81c6d4..6841ecd23c 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,18 +14,17 @@ using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; -using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModBlinds : Mod, IApplicableToDrawableRuleset, IApplicableToScoreProcessor + public class OsuModBlinds : Mod, IApplicableToDrawableRuleset, IApplicableToHealthProcessor { public override string Name => "Blinds"; public override string Description => "Play with blinds on your screen."; public override string Acronym => "BL"; - public override IconUsage Icon => FontAwesome.Solid.Adjust; + public override IconUsage? Icon => FontAwesome.Solid.Adjust; public override ModType Type => ModType.DifficultyIncrease; public override bool Ranked => false; @@ -37,9 +37,9 @@ namespace osu.Game.Rulesets.Osu.Mods drawableRuleset.Overlays.Add(blinds = new DrawableOsuBlinds(drawableRuleset.Playfield.HitObjectContainer, drawableRuleset.Beatmap)); } - public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + public void ApplyToHealthProcessor(HealthProcessor healthProcessor) { - scoreProcessor.Health.ValueChanged += health => { blinds.AnimateClosedness((float)health.NewValue); }; + healthProcessor.Health.ValueChanged += health => { blinds.AnimateClosedness((float)health.NewValue); }; } public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; @@ -64,8 +64,8 @@ namespace osu.Game.Rulesets.Osu.Mods /// private const float target_clamp = 1; - private readonly float targetBreakMultiplier = 0; - private readonly float easing = 1; + private readonly float targetBreakMultiplier; + private readonly float easing; private readonly CompositeDrawable restrictTo; @@ -86,6 +86,9 @@ namespace osu.Game.Rulesets.Osu.Mods { this.restrictTo = restrictTo; this.beatmap = beatmap; + + targetBreakMultiplier = 0; + easing = 1; } [BackgroundDependencyLoader] @@ -120,7 +123,7 @@ namespace osu.Game.Rulesets.Osu.Mods }; } - private float calculateGap(float value) => MathHelper.Clamp(value, 0, target_clamp) * targetBreakMultiplier; + private float calculateGap(float value) => Math.Clamp(value, 0, target_clamp) * targetBreakMultiplier; // lagrange polinominal for (0,0) (0.6,0.4) (1,1) should make a good curve private static float applyAdjustmentCurve(float value) => 0.6f * value * value + 0.4f * value; @@ -188,7 +191,7 @@ namespace osu.Game.Rulesets.Osu.Mods [BackgroundDependencyLoader] private void load(TextureStore textures) { - Texture = textures.Get("Play/osu/blinds-panel"); + Texture = textures.Get("Gameplay/osu/blinds-panel"); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.cs new file mode 100644 index 0000000000..5d9a524577 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModCinema.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 System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModCinema : ModCinema + { + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).Append(typeof(OsuModSpunOut)).ToArray(); + + public override Score CreateReplayScore(IBeatmap beatmap) => new Score + { + ScoreInfo = new ScoreInfo { User = new User { Username = "Autoplay" } }, + Replay = new OsuAutoGenerator(beatmap).Generate() + }; + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs index adca95cf8a..ee6a7815e2 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs @@ -1,20 +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.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModDeflate : OsuModeObjectScaleTween + public class OsuModDeflate : OsuModObjectScaleTween { public override string Name => "Deflate"; public override string Acronym => "DF"; - public override IconUsage Icon => FontAwesome.Solid.CompressArrowsAlt; + public override IconUsage? Icon => FontAwesome.Solid.CompressArrowsAlt; public override string Description => "Hit them at the right size!"; - protected override float StartScale => 2f; + [SettingSource("Starting Size", "The initial size multiplier applied to all objects.")] + public override BindableNumber StartScale { get; } = new BindableFloat + { + MinValue = 1f, + MaxValue = 25f, + Default = 2f, + Value = 2f, + Precision = 0.1f, + }; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs new file mode 100644 index 0000000000..ff995e38ce --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.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 System.Linq; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModDifficultyAdjust : ModDifficultyAdjust + { + [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)] + public BindableNumber CircleSize { get; } = new BindableFloat + { + Precision = 0.1f, + MinValue = 0, + MaxValue = 10, + Default = 5, + Value = 5, + }; + + [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)] + public BindableNumber ApproachRate { get; } = new BindableFloat + { + Precision = 0.1f, + MinValue = 0, + MaxValue = 10, + Default = 5, + Value = 5, + }; + + public override string SettingDescription + { + get + { + string circleSize = CircleSize.IsDefault ? string.Empty : $"CS {CircleSize.Value:N1}"; + string approachRate = ApproachRate.IsDefault ? string.Empty : $"AR {ApproachRate.Value:N1}"; + + return string.Join(", ", new[] + { + circleSize, + base.SettingDescription, + approachRate + }.Where(s => !string.IsNullOrEmpty(s))); + } + } + + protected override void TransferSettings(BeatmapDifficulty difficulty) + { + base.TransferSettings(difficulty); + + TransferSetting(CircleSize, difficulty.CircleSize); + TransferSetting(ApproachRate, difficulty.ApproachRate); + } + + protected override void ApplySettings(BeatmapDifficulty difficulty) + { + base.ApplySettings(difficulty); + + difficulty.CircleSize = CircleSize.Value; + difficulty.ApproachRate = ApproachRate.Value; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index 7fa3dbe07e..ac20407ed2 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs @@ -1,13 +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 System; using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -55,7 +56,7 @@ namespace osu.Game.Rulesets.Osu.Mods var destination = e.MousePosition; FlashlightPosition = Interpolation.ValueAt( - MathHelper.Clamp(Clock.ElapsedFrameTime, 0, follow_delay), position, destination, 0, follow_delay, Easing.Out); + Math.Clamp(Clock.ElapsedFrameTime, 0, follow_delay), position, destination, 0, follow_delay, Easing.Out); return base.OnMouseMove(e); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs index 3c81203ad7..182d6eeb4b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs @@ -1,20 +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.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModGrow : OsuModeObjectScaleTween + internal class OsuModGrow : OsuModObjectScaleTween { public override string Name => "Grow"; public override string Acronym => "GR"; - public override IconUsage Icon => FontAwesome.Solid.ArrowsAltV; + public override IconUsage? Icon => FontAwesome.Solid.ArrowsAltV; public override string Description => "Hit them at the right size!"; - protected override float StartScale => 0.5f; + [SettingSource("Starting Size", "The initial size multiplier applied to all objects.")] + public override BindableNumber StartScale { get; } = new BindableFloat + { + MinValue = 0f, + MaxValue = 0.99f, + Default = 0.5f, + Value = 0.5f, + Precision = 0.01f, + }; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs index 80686b7983..e0577dd464 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs @@ -22,18 +22,17 @@ namespace osu.Game.Rulesets.Osu.Mods osuObject.Position = new Vector2(osuObject.Position.X, OsuPlayfield.BASE_SIZE.Y - osuObject.Y); - var slider = hitObject as Slider; - if (slider == null) + if (!(hitObject is Slider slider)) return; slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); - slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); + slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); - var newControlPoints = new Vector2[slider.Path.ControlPoints.Length]; - for (int i = 0; i < slider.Path.ControlPoints.Length; i++) - newControlPoints[i] = new Vector2(slider.Path.ControlPoints[i].X, -slider.Path.ControlPoints[i].Y); + var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray(); + foreach (var point in controlPoints) + point.Position.Value = new Vector2(point.Position.Value.X, -point.Position.Value.Y); - slider.Path = new SliderPath(slider.Path.Type, newControlPoints, slider.Path.ExpectedDistance); + slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index ddf708d0f1..025e202666 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -3,12 +3,11 @@ using System; using System.Collections.Generic; -using System.Linq; using osu.Framework.Graphics; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Mods @@ -17,24 +16,46 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Description => @"Play with no approach circles and fading circles/sliders."; public override double ScoreMultiplier => 1.06; + + public override Type[] IncompatibleMods => new[] { typeof(OsuModTraceable), typeof(OsuModSpinIn) }; + private const double fade_in_duration_multiplier = 0.4; private const double fade_out_duration_multiplier = 0.3; + protected override bool IsFirstAdjustableObject(HitObject hitObject) => !(hitObject is Spinner); + public override void ApplyToDrawableHitObjects(IEnumerable drawables) { - void adjustFadeIn(OsuHitObject h) => h.TimeFadeIn = h.TimePreempt * fade_in_duration_multiplier; - - foreach (var d in drawables.OfType()) - { - adjustFadeIn(d.HitObject); - foreach (var h in d.HitObject.NestedHitObjects.OfType()) - adjustFadeIn(h); - } + foreach (var d in drawables) + d.ApplyCustomUpdateState += applyFadeInAdjustment; base.ApplyToDrawableHitObjects(drawables); } - protected override void ApplyHiddenState(DrawableHitObject drawable, ArmedState state) + private void applyFadeInAdjustment(DrawableHitObject hitObject, ArmedState state) + { + if (!(hitObject is DrawableOsuHitObject d)) + return; + + d.HitObject.TimeFadeIn = d.HitObject.TimePreempt * fade_in_duration_multiplier; + } + + private double lastSliderHeadFadeOutStartTime; + private double lastSliderHeadFadeOutDuration; + + 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); + } + + private void applyState(DrawableHitObject drawable, bool increaseVisibility) { if (!(drawable is DrawableOsuHitObject d)) return; @@ -45,19 +66,56 @@ namespace osu.Game.Rulesets.Osu.Mods var fadeOutDuration = h.TimePreempt * fade_out_duration_multiplier; // new duration from completed fade in to end (before fading out) - var longFadeDuration = ((h as IHasEndTime)?.EndTime ?? h.StartTime) - fadeOutStartTime; + var longFadeDuration = h.GetEndTime() - fadeOutStartTime; switch (drawable) { + case DrawableSliderTail sliderTail: + // use stored values from head circle to achieve same fade sequence. + fadeOutDuration = lastSliderHeadFadeOutDuration; + fadeOutStartTime = lastSliderHeadFadeOutStartTime; + + using (drawable.BeginAbsoluteSequence(fadeOutStartTime, true)) + sliderTail.FadeOut(fadeOutDuration); + + break; + + case DrawableSliderRepeat sliderRepeat: + // use stored values from head circle to achieve same fade sequence. + fadeOutDuration = lastSliderHeadFadeOutDuration; + fadeOutStartTime = lastSliderHeadFadeOutStartTime; + + using (drawable.BeginAbsoluteSequence(fadeOutStartTime, true)) + // only apply to circle piece – reverse arrow is not affected by hidden. + sliderRepeat.CirclePiece.FadeOut(fadeOutDuration); + + break; + case DrawableHitCircle circle: - // we don't want to see the approach circle - using (circle.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) - circle.ApproachCircle.Hide(); + + if (circle is DrawableSliderHead) + { + lastSliderHeadFadeOutDuration = fadeOutDuration; + lastSliderHeadFadeOutStartTime = fadeOutStartTime; + } + + Drawable fadeTarget = circle; + + if (increaseVisibility) + { + // only fade the circle piece (not the approach circle) for the increased visibility object. + fadeTarget = circle.CirclePiece; + } + else + { + // we don't want to see the approach circle + using (circle.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) + circle.ApproachCircle.Hide(); + } // fade out immediately after fade in. using (drawable.BeginAbsoluteSequence(fadeOutStartTime, true)) - circle.FadeOut(fadeOutDuration); - + fadeTarget.FadeOut(fadeOutDuration); break; case DrawableSlider slider: @@ -77,9 +135,7 @@ namespace osu.Game.Rulesets.Osu.Mods case DrawableSpinner spinner: // hide elements we don't care about. - spinner.Disc.Hide(); - spinner.Ticks.Hide(); - spinner.Background.Hide(); + // todo: hide background using (spinner.BeginAbsoluteSequence(fadeOutStartTime + longFadeDuration, true)) spinner.FadeOut(fadeOutDuration); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs index 5668c17792..7780e23a26 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNightcore.cs @@ -2,10 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Objects; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModNightcore : ModNightcore + public class OsuModNightcore : ModNightcore { public override double ScoreMultiplier => 1.12; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModeObjectScaleTween.cs b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs similarity index 55% rename from osu.Game.Rulesets.Osu/Mods/OsuModeObjectScaleTween.cs rename to osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs index ad6a15718a..d1be162f73 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModeObjectScaleTween.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.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.Collections.Generic; -using System.Linq; +using System; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -16,41 +14,29 @@ namespace osu.Game.Rulesets.Osu.Mods /// /// Adjusts the size of hit objects during their fade in animation. /// - public abstract class OsuModeObjectScaleTween : Mod, IReadFromConfig, IApplicableToDrawableHitObjects + public abstract class OsuModObjectScaleTween : ModWithVisibilityAdjustment { public override ModType Type => ModType.Fun; public override double ScoreMultiplier => 1; - protected virtual float StartScale => 1; + public abstract BindableNumber StartScale { get; } protected virtual float EndScale => 1; - private Bindable increaseFirstObjectVisibility = new Bindable(); + public override Type[] IncompatibleMods => new[] { typeof(OsuModSpinIn), typeof(OsuModTraceable) }; - public void ReadFromConfig(OsuConfigManager config) + protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) { - increaseFirstObjectVisibility = config.GetBindable(OsuSetting.IncreaseFirstObjectVisibility); } - public void ApplyToDrawableHitObjects(IEnumerable drawables) - { - foreach (var drawable in drawables.Skip(increaseFirstObjectVisibility.Value ? 1 : 0)) - { - switch (drawable) - { - case DrawableSpinner _: - continue; + protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) => applyCustomState(hitObject, state); - default: - drawable.ApplyCustomUpdateState += ApplyCustomState; - break; - } - } - } - - protected virtual void ApplyCustomState(DrawableHitObject drawable, ArmedState state) + private void applyCustomState(DrawableHitObject drawable, ArmedState state) { + if (drawable is DrawableSpinner) + return; + var h = (OsuHitObject)drawable.HitObject; // apply grow effect @@ -64,8 +50,8 @@ namespace osu.Game.Rulesets.Osu.Mods case DrawableSlider _: case DrawableHitCircle _: { - using (drawable.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) - drawable.ScaleTo(StartScale).Then().ScaleTo(EndScale, h.TimePreempt, Easing.OutSine); + using (drawable.BeginAbsoluteSequence(h.StartTime - h.TimePreempt)) + drawable.ScaleTo(StartScale.Value).Then().ScaleTo(EndScale, h.TimePreempt, Easing.OutSine); break; } } @@ -75,7 +61,7 @@ namespace osu.Game.Rulesets.Osu.Mods { case DrawableHitCircle circle: // we don't want to see the approach circle - using (circle.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) + using (circle.BeginAbsoluteSequence(h.StartTime - h.TimePreempt)) circle.ApproachCircle.Hide(); break; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 5625028707..5d191119b9 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -3,85 +3,137 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; using static osu.Game.Input.Handlers.ReplayInputHandler; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset + public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer { public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).ToArray(); - public void Update(Playfield playfield) - { - bool requiresHold = false; - bool requiresHit = false; + /// + /// How early before a hitobject's start time to trigger a hit. + /// + private const float relax_leniency = 3; - const float relax_leniency = 3; - - foreach (var drawable in playfield.HitObjectContainer.AliveObjects) - { - if (!(drawable is DrawableOsuHitObject osuHit)) - continue; - - double time = osuHit.Clock.CurrentTime; - double relativetime = time - osuHit.HitObject.StartTime; - - if (time < osuHit.HitObject.StartTime - relax_leniency) continue; - - if ((osuHit.HitObject is IHasEndTime hasEnd && time > hasEnd.EndTime) || osuHit.IsHit) - continue; - - requiresHit |= osuHit is DrawableHitCircle && osuHit.IsHovered && osuHit.HitObject.HitWindows.CanBeHit(relativetime); - requiresHold |= (osuHit is DrawableSlider slider && (slider.Ball.IsHovered || osuHit.IsHovered)) || osuHit is DrawableSpinner; - } - - if (requiresHit) - { - addAction(false); - addAction(true); - } - - addAction(requiresHold); - } - - private bool wasHit; + private bool isDownState; private bool wasLeft; private OsuInputManager osuInputManager; - private void addAction(bool hitting) - { - if (wasHit == hitting) - return; + private ReplayState state; + private double lastStateChangeTime; - wasHit = hitting; - - var state = new ReplayState - { - PressedActions = new List() - }; - - if (hitting) - { - state.PressedActions.Add(wasLeft ? OsuAction.LeftButton : OsuAction.RightButton); - wasLeft = !wasLeft; - } - - state.Apply(osuInputManager.CurrentState, osuInputManager); - } + private bool hasReplay; public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { // grab the input manager for future use. osuInputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager; + } + + public void ApplyToPlayer(Player player) + { + if (osuInputManager.ReplayInputHandler != null) + { + hasReplay = true; + return; + } + osuInputManager.AllowUserPresses = false; } + + public void Update(Playfield playfield) + { + if (hasReplay) + return; + + bool requiresHold = false; + bool requiresHit = false; + + double time = playfield.Clock.CurrentTime; + + foreach (var h in playfield.HitObjectContainer.AliveObjects.OfType()) + { + // we are not yet close enough to the object. + if (time < h.HitObject.StartTime - relax_leniency) + break; + + // already hit or beyond the hittable end time. + if (h.IsHit || (h.HitObject is IHasDuration hasEnd && time > hasEnd.EndTime)) + continue; + + switch (h) + { + case DrawableHitCircle circle: + handleHitCircle(circle); + break; + + case DrawableSlider slider: + // Handles cases like "2B" beatmaps, where sliders may be overlapping and simply holding is not enough. + if (!slider.HeadCircle.IsHit) + handleHitCircle(slider.HeadCircle); + + requiresHold |= slider.Ball.IsHovered || h.IsHovered; + break; + + case DrawableSpinner _: + requiresHold = true; + break; + } + } + + if (requiresHit) + { + changeState(false); + changeState(true); + } + + if (requiresHold) + changeState(true); + else if (isDownState && time - lastStateChangeTime > AutoGenerator.KEY_UP_DELAY) + changeState(false); + + void handleHitCircle(DrawableHitCircle circle) + { + if (!circle.HitArea.IsHovered) + return; + + Debug.Assert(circle.HitObject.HitWindows != null); + requiresHit |= circle.HitObject.HitWindows.CanBeHit(time - circle.HitObject.StartTime); + } + + void changeState(bool down) + { + if (isDownState == down) + return; + + isDownState = down; + lastStateChangeTime = time; + + state = new ReplayState + { + PressedActions = new List() + }; + + if (down) + { + state.PressedActions.Add(wasLeft ? OsuAction.LeftButton : OsuAction.RightButton); + wasLeft = !wasLeft; + } + + state?.Apply(osuInputManager.CurrentState, osuInputManager); + } + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs new file mode 100644 index 0000000000..96ba58da23 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.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 osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +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 osuTK; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModSpinIn : ModWithVisibilityAdjustment + { + public override string Name => "Spin In"; + public override string Acronym => "SI"; + public override IconUsage? Icon => FontAwesome.Solid.Undo; + public override ModType Type => ModType.Fun; + public override string Description => "Circles spin in. No approach circles."; + public override double ScoreMultiplier => 1; + + // todo: this mod should be able to be compatible with hidden with a bit of further implementation. + public override Type[] IncompatibleMods => new[] { typeof(OsuModObjectScaleTween), typeof(OsuModHidden), typeof(OsuModTraceable) }; + + private const int rotate_offset = 360; + private const float rotate_starting_width = 2; + + protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) + { + } + + protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) => applyZoomState(hitObject, state); + + private void applyZoomState(DrawableHitObject drawable, ArmedState state) + { + if (drawable is DrawableSpinner) + return; + + var h = (OsuHitObject)drawable.HitObject; + + switch (drawable) + { + case DrawableHitCircle circle: + using (circle.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) + { + circle.ApproachCircle.Hide(); + + circle.RotateTo(rotate_offset).Then().RotateTo(0, h.TimePreempt, Easing.InOutSine); + circle.ScaleTo(new Vector2(rotate_starting_width, 0)).Then().ScaleTo(1, h.TimePreempt, Easing.InOutSine); + + // bypass fade in. + if (state == ArmedState.Idle) + circle.FadeIn(); + } + + break; + + case DrawableSlider slider: + using (slider.BeginAbsoluteSequence(h.StartTime - h.TimePreempt)) + { + slider.ScaleTo(0).Then().ScaleTo(1, h.TimePreempt, Easing.InOutSine); + + // bypass fade in. + if (state == ArmedState.Idle) + slider.FadeIn(); + } + + break; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs index 1cdcddbd33..f080e11933 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs @@ -2,21 +2,55 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModSpunOut : Mod + public class OsuModSpunOut : Mod, IApplicableToDrawableHitObjects { public override string Name => "Spun Out"; public override string Acronym => "SO"; - public override IconUsage Icon => OsuIcon.ModSpunout; - public override ModType Type => ModType.DifficultyReduction; + public override IconUsage? Icon => OsuIcon.ModSpunout; + public override ModType Type => ModType.Automation; public override string Description => @"Spinners will be automatically completed."; public override double ScoreMultiplier => 0.9; public override bool Ranked => true; public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(OsuModAutopilot) }; + + public void ApplyToDrawableHitObjects(IEnumerable drawables) + { + foreach (var hitObject in drawables) + { + if (hitObject is DrawableSpinner spinner) + { + spinner.HandleUserInput = false; + spinner.OnUpdate += onSpinnerUpdate; + } + } + } + + private void onSpinnerUpdate(Drawable drawable) + { + var spinner = (DrawableSpinner)drawable; + + spinner.RotationTracker.Tracking = true; + + // early-return if we were paused to avoid division-by-zero in the subsequent calculations. + if (Precision.AlmostEquals(spinner.Clock.Rate, 0)) + return; + + // because the spinner is under the gameplay clock, it is affected by rate adjustments on the track; + // for that reason using ElapsedFrameTime directly leads to fewer SPM with Half Time and more SPM with Double Time. + // for spinners we want the real (wall clock) elapsed time; to achieve that, unapply the clock rate locally here. + var rateIndependentElapsedTime = spinner.Clock.ElapsedFrameTime / spinner.Clock.Rate; + spinner.RotationTracker.AddRotation(MathUtils.RadiansToDegrees((float)rateIndependentElapsedTime * 0.03f)); + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs index 8360e2692e..2464308347 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Name => "Target"; public override string Acronym => "TP"; public override ModType Type => ModType.Conversion; - public override IconUsage Icon => OsuIcon.ModTarget; + public override IconUsage? Icon => OsuIcon.ModTarget; public override string Description => @"Practice keeping up with the beat of the song."; public override double ScoreMultiplier => 1; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs new file mode 100644 index 0000000000..b7e60295cb --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.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 osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; + +namespace osu.Game.Rulesets.Osu.Mods +{ + internal class OsuModTraceable : ModWithVisibilityAdjustment + { + public override string Name => "Traceable"; + public override string Acronym => "TC"; + public override ModType Type => ModType.Fun; + public override string Description => "Put your faith in the approach circles..."; + public override double ScoreMultiplier => 1; + + public override Type[] IncompatibleMods => new[] { typeof(OsuModHidden), typeof(OsuModSpinIn), typeof(OsuModObjectScaleTween) }; + + protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) + { + } + + protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) => applyTraceableState(hitObject, state); + + private void applyTraceableState(DrawableHitObject drawable, ArmedState state) + { + if (!(drawable is DrawableOsuHitObject)) + return; + + //todo: expose and hide spinner background somehow + + switch (drawable) + { + case DrawableHitCircle circle: + // we only want to see the approach circle + applyCirclePieceState(circle, circle.CirclePiece); + break; + + case DrawableSliderTail sliderTail: + applyCirclePieceState(sliderTail); + break; + + case DrawableSliderRepeat sliderRepeat: + // show only the repeat arrow + applyCirclePieceState(sliderRepeat, sliderRepeat.CirclePiece); + break; + + case DrawableSlider slider: + slider.Body.OnSkinChanged += () => applySliderState(slider); + applySliderState(slider); + break; + } + } + + private void applyCirclePieceState(DrawableOsuHitObject hitObject, IDrawable hitCircle = null) + { + var h = hitObject.HitObject; + using (hitObject.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) + (hitCircle ?? hitObject).Hide(); + } + + private void applySliderState(DrawableSlider slider) + { + ((PlaySliderBody)slider.Body.Drawable).AccentColour = slider.AccentColour.Value.Opacity(0); + ((PlaySliderBody)slider.Body.Drawable).BorderColour = slider.AccentColour.Value; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs index 9b079895fa..b5905d7015 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs @@ -2,21 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; 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 osuTK; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModTransform : Mod, IApplicableToDrawableHitObjects + internal class OsuModTransform : ModWithVisibilityAdjustment { public override string Name => "Transform"; public override string Acronym => "TR"; - public override IconUsage Icon => FontAwesome.Solid.ArrowsAlt; + public override IconUsage? Icon => FontAwesome.Solid.ArrowsAlt; public override ModType Type => ModType.Fun; public override string Description => "Everything rotates. EVERYTHING."; public override double ScoreMultiplier => 1; @@ -24,29 +24,41 @@ namespace osu.Game.Rulesets.Osu.Mods private float theta; - public void ApplyToDrawableHitObjects(IEnumerable drawables) + protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) => applyTransform(hitObject, state); + + protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) => applyTransform(hitObject, state); + + private void applyTransform(DrawableHitObject drawable, ArmedState state) { - foreach (var drawable in drawables) + switch (drawable) { - var hitObject = (OsuHitObject)drawable.HitObject; + case DrawableSliderHead _: + case DrawableSliderTail _: + case DrawableSliderTick _: + case DrawableSliderRepeat _: + return; - float appearDistance = (float)(hitObject.TimePreempt - hitObject.TimeFadeIn) / 2; + default: + var hitObject = (OsuHitObject)drawable.HitObject; - Vector2 originalPosition = drawable.Position; - Vector2 appearOffset = new Vector2((float)Math.Cos(theta), (float)Math.Sin(theta)) * appearDistance; + float appearDistance = (float)(hitObject.TimePreempt - hitObject.TimeFadeIn) / 2; - //the - 1 and + 1 prevents the hit objects to appear in the wrong position. - double appearTime = hitObject.StartTime - hitObject.TimePreempt - 1; - double moveDuration = hitObject.TimePreempt + 1; + Vector2 originalPosition = drawable.Position; + Vector2 appearOffset = new Vector2(MathF.Cos(theta), MathF.Sin(theta)) * appearDistance; - using (drawable.BeginAbsoluteSequence(appearTime, true)) - { - drawable - .MoveToOffset(appearOffset) - .MoveTo(originalPosition, moveDuration, Easing.InOutSine); - } + // the - 1 and + 1 prevents the hit objects to appear in the wrong position. + double appearTime = hitObject.StartTime - hitObject.TimePreempt - 1; + double moveDuration = hitObject.TimePreempt + 1; - theta += (float)hitObject.TimeFadeIn / 1000; + using (drawable.BeginAbsoluteSequence(appearTime, true)) + { + drawable + .MoveToOffset(appearOffset) + .MoveTo(originalPosition, moveDuration, Easing.InOutSine); + } + + theta += (float)hitObject.TimeFadeIn / 1000; + break; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs index 17fcd03dd5..9c5e41f245 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Rulesets.Mods; @@ -13,11 +12,11 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Mods { - internal class OsuModWiggle : Mod, IApplicableToDrawableHitObjects + internal class OsuModWiggle : ModWithVisibilityAdjustment { public override string Name => "Wiggle"; public override string Acronym => "WG"; - public override IconUsage Icon => FontAwesome.Solid.Certificate; + public override IconUsage? Icon => FontAwesome.Solid.Certificate; public override ModType Type => ModType.Fun; public override string Description => "They just won't stay still..."; public override double ScoreMultiplier => 1; @@ -26,11 +25,9 @@ namespace osu.Game.Rulesets.Osu.Mods private const int wiggle_duration = 90; // (ms) Higher = fewer wiggles private const int wiggle_strength = 10; // Higher = stronger wiggles - public void ApplyToDrawableHitObjects(IEnumerable drawables) - { - foreach (var drawable in drawables) - drawable.ApplyCustomUpdateState += drawableOnApplyCustomUpdateState; - } + protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) => drawableOnApplyCustomUpdateState(hitObject, state); + + protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) => drawableOnApplyCustomUpdateState(hitObject, state); private void drawableOnApplyCustomUpdateState(DrawableHitObject drawable, ArmedState state) { @@ -39,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Mods // Wiggle the repeat points with the slider instead of independently. // Also fixes an issue with repeat points being positioned incorrectly. - if (osuObject is RepeatPoint) + if (osuObject is SliderRepeat) return; Random objRand = new Random((int)osuObject.StartTime); @@ -55,18 +52,22 @@ namespace osu.Game.Rulesets.Osu.Mods } for (int i = 0; i < amountWiggles; i++) + { using (drawable.BeginAbsoluteSequence(osuObject.StartTime - osuObject.TimePreempt + i * wiggle_duration, true)) wiggle(); + } // Keep wiggling sliders and spinners for their duration - if (!(osuObject is IHasEndTime endTime)) + if (!(osuObject is IHasDuration endTime)) return; amountWiggles = (int)(endTime.Duration / wiggle_duration); for (int i = 0; i < amountWiggles; i++) + { using (drawable.BeginAbsoluteSequence(osuObject.StartTime + i * wiggle_duration, true)) wiggle(); + } } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/ConnectionRenderer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/ConnectionRenderer.cs deleted file mode 100644 index 9106f4c7bd..0000000000 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/ConnectionRenderer.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Objects; -using System.Collections.Generic; - -namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections -{ - /// - /// Connects hit objects visually, for example with follow points. - /// - public abstract class ConnectionRenderer : LifetimeManagementContainer - where T : HitObject - { - /// - /// Hit objects to create connections for - /// - public abstract IEnumerable HitObjects { get; set; } - } -} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs index aacf3ee08d..a981648444 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPoint.cs @@ -12,7 +12,10 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { - public class FollowPoint : Container + /// + /// A single follow point positioned between two adjacent s. + /// + public class FollowPoint : Container, IAnimationTimeReference { private const float width = 8; @@ -22,11 +25,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { Origin = Anchor.Centre; - Child = new SkinnableDrawable("Play/osu/followpoint", _ => new Container + Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.FollowPoint), _ => new CircularContainer { Masking = true, AutoSizeAxes = Axes.Both, - CornerRadius = width / 2, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, @@ -36,12 +38,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections Child = new Box { Size = new Vector2(width), - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Origin = Anchor.Centre, Anchor = Anchor.Centre, Alpha = 0.5f, } - }, restrictSize: false); + }); } + + public double AnimationStartTime { get; set; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs new file mode 100644 index 0000000000..3a9e19b361 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -0,0 +1,164 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections +{ + /// + /// Visualises the s between two s. + /// + public class FollowPointConnection : CompositeDrawable + { + // Todo: These shouldn't be constants + private const int spacing = 32; + private const double preempt = 800; + + public override bool RemoveWhenNotAlive => false; + + /// + /// The start time of . + /// + public readonly Bindable StartTime = new BindableDouble(); + + /// + /// The which s will exit from. + /// + [NotNull] + public readonly OsuHitObject Start; + + /// + /// Creates a new . + /// + /// The which s will exit from. + public FollowPointConnection([NotNull] OsuHitObject start) + { + Start = start; + + RelativeSizeAxes = Axes.Both; + + StartTime.BindTo(start.StartTimeBindable); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + bindEvents(Start); + } + + private OsuHitObject end; + + /// + /// The which s will enter. + /// + [CanBeNull] + public OsuHitObject End + { + get => end; + set + { + end = value; + + if (end != null) + bindEvents(end); + + if (IsLoaded) + scheduleRefresh(); + else + refresh(); + } + } + + private void bindEvents(OsuHitObject obj) + { + obj.PositionBindable.BindValueChanged(_ => scheduleRefresh()); + obj.DefaultsApplied += _ => scheduleRefresh(); + } + + private void scheduleRefresh() + { + Scheduler.AddOnce(refresh); + } + + private void refresh() + { + double startTime = Start.GetEndTime(); + + LifetimeStart = startTime; + + if (End == null || End.NewCombo || Start is Spinner || End is Spinner) + { + // ensure we always set a lifetime for full LifetimeManagementContainer benefits + LifetimeEnd = LifetimeStart; + return; + } + + Vector2 startPosition = Start.StackedEndPosition; + Vector2 endPosition = End.StackedPosition; + double endTime = End.StartTime; + + Vector2 distanceVector = endPosition - startPosition; + int distance = (int)distanceVector.Length; + float rotation = (float)(Math.Atan2(distanceVector.Y, distanceVector.X) * (180 / Math.PI)); + double duration = endTime - startTime; + + double? firstTransformStartTime = null; + double finalTransformEndTime = startTime; + + int point = 0; + + ClearInternal(); + + for (int d = (int)(spacing * 1.5); d < distance - spacing; d += spacing) + { + float fraction = (float)d / distance; + Vector2 pointStartPosition = startPosition + (fraction - 0.1f) * distanceVector; + Vector2 pointEndPosition = startPosition + fraction * distanceVector; + double fadeOutTime = startTime + fraction * duration; + double fadeInTime = fadeOutTime - preempt; + + FollowPoint fp; + + AddInternal(fp = new FollowPoint()); + + Debug.Assert(End != null); + + fp.Position = pointStartPosition; + fp.Rotation = rotation; + fp.Alpha = 0; + fp.Scale = new Vector2(1.5f * End.Scale); + + firstTransformStartTime ??= fadeInTime; + + fp.AnimationStartTime = fadeInTime; + + using (fp.BeginAbsoluteSequence(fadeInTime)) + { + fp.FadeIn(End.TimeFadeIn); + fp.ScaleTo(End.Scale, End.TimeFadeIn, Easing.Out); + fp.MoveTo(pointEndPosition, End.TimeFadeIn, Easing.Out); + fp.Delay(fadeOutTime - fadeInTime).FadeOut(End.TimeFadeIn); + + finalTransformEndTime = fadeOutTime + End.TimeFadeIn; + } + + point++; + } + + int excessPoints = InternalChildren.Count - point; + for (int i = 0; i < excessPoints; i++) + RemoveInternal(InternalChildren[^1]); + + // todo: use Expire() on FollowPoints and take lifetime from them when https://github.com/ppy/osu-framework/issues/3300 is fixed. + LifetimeStart = firstTransformStartTime ?? startTime; + LifetimeEnd = finalTransformEndTime; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs index 7569626230..be1392d7c3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs @@ -1,121 +1,123 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; -using osuTK; +using System.Linq; +using osu.Framework.Extensions; using osu.Framework.Graphics; -using osu.Game.Rulesets.Objects.Types; +using osu.Framework.Graphics.Containers; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { - public class FollowPointRenderer : ConnectionRenderer + /// + /// Visualises connections between s. + /// + public class FollowPointRenderer : LifetimeManagementContainer { - private int pointDistance = 32; - /// - /// Determines how much space there is between points. + /// All the s contained by this . /// - public int PointDistance - { - get => pointDistance; - set - { - if (pointDistance == value) return; + internal IReadOnlyList Connections => connections; - pointDistance = value; - update(); - } - } - - private int preEmpt = 800; - - /// - /// Follow points to the next hitobject start appearing for this many milliseconds before an hitobject's end time. - /// - public int PreEmpt - { - get => preEmpt; - set - { - if (preEmpt == value) return; - - preEmpt = value; - update(); - } - } - - private IEnumerable hitObjects; - - public override IEnumerable HitObjects - { - get => hitObjects; - set - { - hitObjects = value; - update(); - } - } + private readonly List connections = new List(); public override bool RemoveCompletedTransforms => false; - private void update() + /// + /// Adds the s around an . + /// This includes s leading into , and s exiting . + /// + /// The to add s for. + public void AddFollowPoints(OsuHitObject hitObject) + => addConnection(new FollowPointConnection(hitObject).With(g => g.StartTime.BindValueChanged(_ => onStartTimeChanged(g)))); + + /// + /// Removes the s around an . + /// This includes s leading into , and s exiting . + /// + /// The to remove s for. + public void RemoveFollowPoints(OsuHitObject hitObject) => removeGroup(connections.Single(g => g.Start == hitObject)); + + /// + /// Adds a to this . + /// + /// The to add. + /// The index of in . + private void addConnection(FollowPointConnection connection) { - ClearInternal(); - - if (hitObjects == null) - return; - - OsuHitObject prevHitObject = null; - - foreach (var currHitObject in hitObjects) + // Groups are sorted by their start time when added such that the index can be used to post-process other surrounding connections + int index = connections.AddInPlace(connection, Comparer.Create((g1, g2) => { - if (prevHitObject != null && !currHitObject.NewCombo && !(prevHitObject is Spinner) && !(currHitObject is Spinner)) - { - Vector2 startPosition = prevHitObject.EndPosition; - Vector2 endPosition = currHitObject.Position; - double startTime = (prevHitObject as IHasEndTime)?.EndTime ?? prevHitObject.StartTime; - double endTime = currHitObject.StartTime; + int comp = g1.StartTime.Value.CompareTo(g2.StartTime.Value); - Vector2 distanceVector = endPosition - startPosition; - int distance = (int)distanceVector.Length; - float rotation = (float)(Math.Atan2(distanceVector.Y, distanceVector.X) * (180 / Math.PI)); - double duration = endTime - startTime; + if (comp != 0) + return comp; - for (int d = (int)(PointDistance * 1.5); d < distance - PointDistance; d += PointDistance) - { - float fraction = (float)d / distance; - Vector2 pointStartPosition = startPosition + (fraction - 0.1f) * distanceVector; - Vector2 pointEndPosition = startPosition + fraction * distanceVector; - double fadeOutTime = startTime + fraction * duration; - double fadeInTime = fadeOutTime - PreEmpt; + // we always want to insert the new item after equal ones. + // this is important for beatmaps with multiple hitobjects at the same point in time. + // if we use standard comparison insert order, there will be a churn of connections getting re-updated to + // the next object at the point-in-time, adding a construction/disposal overhead (see FollowPointConnection.End implementation's ClearInternal). + // this is easily visible on https://osu.ppy.sh/beatmapsets/150945#osu/372245 + return -1; + })); - FollowPoint fp; + if (index < connections.Count - 1) + { + // Update the connection's end point to the next connection's start point + // h1 -> -> -> h2 + // connection nextGroup - AddInternal(fp = new FollowPoint - { - Position = pointStartPosition, - Rotation = rotation, - Alpha = 0, - Scale = new Vector2(1.5f), - }); - - using (fp.BeginAbsoluteSequence(fadeInTime)) - { - fp.FadeIn(currHitObject.TimeFadeIn); - fp.ScaleTo(1, currHitObject.TimeFadeIn, Easing.Out); - - fp.MoveTo(pointEndPosition, currHitObject.TimeFadeIn, Easing.Out); - - fp.Delay(fadeOutTime - fadeInTime).FadeOut(currHitObject.TimeFadeIn); - } - - fp.Expire(true); - } - } - - prevHitObject = currHitObject; + FollowPointConnection nextConnection = connections[index + 1]; + connection.End = nextConnection.Start; } + else + { + // The end point may be non-null during re-ordering + connection.End = null; + } + + if (index > 0) + { + // Update the previous connection's end point to the current connection's start point + // h1 -> -> -> h2 + // prevGroup connection + + FollowPointConnection previousConnection = connections[index - 1]; + previousConnection.End = connection.Start; + } + + AddInternal(connection); + } + + /// + /// Removes a from this . + /// + /// The to remove. + /// Whether was removed. + private void removeGroup(FollowPointConnection connection) + { + RemoveInternal(connection); + + int index = connections.IndexOf(connection); + + if (index > 0) + { + // Update the previous connection's end point to the next connection's start point + // h1 -> -> -> h2 -> -> -> h3 + // prevGroup connection nextGroup + // The current connection's end point is used since there may not be a next connection + FollowPointConnection previousConnection = connections[index - 1]; + previousConnection.End = connection.End; + } + + connections.Remove(connection); + } + + private void onStartTimeChanged(FollowPointConnection connection) + { + // Naive but can be improved if performance becomes an issue + removeGroup(connection); + addConnection(connection); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index d3d763daf3..77d24db084 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -2,43 +2,44 @@ // 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; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; -using osuTK; using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableHitCircle : DrawableOsuHitObject, IDrawableHitObjectWithProxiedApproach { - public ApproachCircle ApproachCircle; - private readonly CirclePiece circle; - private readonly RingPiece ring; - private readonly FlashPiece flash; - private readonly ExplodePiece explode; - private readonly NumberPiece number; - private readonly GlowPiece glow; + public OsuAction? HitAction => HitArea.HitAction; + protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; - private readonly IBindable positionBindable = new Bindable(); - private readonly IBindable stackHeightBindable = new Bindable(); - private readonly IBindable scaleBindable = new Bindable(); + public ApproachCircle ApproachCircle { get; private set; } + public HitReceptor HitArea { get; private set; } + public SkinnableDrawable CirclePiece { get; private set; } - public OsuAction? HitAction => circle.HitAction; + private Container scaleContainer; + private InputManager inputManager; - private readonly Container explodeContainer; - - private readonly Container scaleContainer; - - public DrawableHitCircle(HitCircle h) + public DrawableHitCircle([CanBeNull] HitCircle h = null) : base(h) { - Origin = Anchor.Centre; + } - Position = HitObject.StackedPosition; + [BackgroundDependencyLoader] + private void load() + { + Origin = Anchor.Centre; InternalChildren = new Drawable[] { @@ -47,145 +48,180 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, Anchor = Anchor.Centre, - Child = explodeContainer = new Container + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Children = new Drawable[] + HitArea = new HitReceptor { - glow = new GlowPiece(), - circle = new CirclePiece + Hit = () => { - Hit = () => - { - if (AllJudged) - return false; + if (AllJudged) + return false; - UpdateResult(true); - return true; - }, + UpdateResult(true); + return true; }, - number = new NumberPiece - { - Text = (HitObject.IndexInCurrentCombo + 1).ToString(), - }, - ring = new RingPiece(), - flash = new FlashPiece(), - explode = new ExplodePiece(), - ApproachCircle = new ApproachCircle - { - Alpha = 0, - Scale = new Vector2(4), - } + }, + CirclePiece = new SkinnableDrawable(new OsuSkinComponent(CirclePieceComponent), _ => new MainCirclePiece()), + ApproachCircle = new ApproachCircle + { + Alpha = 0, + Scale = new Vector2(4), } } }, }; - //may not be so correct - Size = circle.DrawSize; + Size = HitArea.DrawSize; + + PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); + StackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); + ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); + AccentColour.BindValueChanged(accent => ApproachCircle.Colour = accent.NewValue); } - [BackgroundDependencyLoader] - private void load() + protected override void LoadComplete() { - positionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); - stackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); - scaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue), true); + base.LoadComplete(); - positionBindable.BindTo(HitObject.PositionBindable); - stackHeightBindable.BindTo(HitObject.StackHeightBindable); - scaleBindable.BindTo(HitObject.ScaleBindable); + inputManager = GetContainingInputManager(); + } - AccentColour.BindValueChanged(colour => + public override double LifetimeStart + { + get => base.LifetimeStart; + set { - explode.Colour = colour.NewValue; - glow.Colour = colour.NewValue; - circle.Colour = colour.NewValue; - ApproachCircle.Colour = colour.NewValue; - }, true); + base.LifetimeStart = value; + ApproachCircle.LifetimeStart = value; + } + } + + public override double LifetimeEnd + { + get => base.LifetimeEnd; + set + { + base.LifetimeEnd = value; + ApproachCircle.LifetimeEnd = value; + } } protected override void CheckForResult(bool userTriggered, double timeOffset) { + Debug.Assert(HitObject.HitWindows != null); + if (!userTriggered) { if (!HitObject.HitWindows.CanBeHit(timeOffset)) - ApplyResult(r => r.Type = HitResult.Miss); + ApplyResult(r => r.Type = r.Judgement.MinResult); return; } var result = HitObject.HitWindows.ResultFor(timeOffset); - if (result == HitResult.None) + if (result == HitResult.None || CheckHittable?.Invoke(this, Time.Current) == false) { - Shake(Math.Abs(timeOffset) - HitObject.HitWindows.HalfWindowFor(HitResult.Miss)); + Shake(Math.Abs(timeOffset) - HitObject.HitWindows.WindowFor(HitResult.Miss)); return; } - ApplyResult(r => r.Type = result); + ApplyResult(r => + { + var circleResult = (OsuHitCircleJudgementResult)r; + + // Todo: This should also consider misses, but they're a little more interesting to handle, since we don't necessarily know the position at the time of a miss. + if (result.IsHit()) + { + var localMousePosition = ToLocalSpace(inputManager.CurrentState.Mouse.Position); + circleResult.CursorPositionAtHit = HitObject.StackedPosition + (localMousePosition - DrawSize / 2); + } + + circleResult.Type = result; + }); } protected override void UpdateInitialTransforms() { base.UpdateInitialTransforms(); + CirclePiece.FadeInFromZero(HitObject.TimeFadeIn); + ApproachCircle.FadeIn(Math.Min(HitObject.TimeFadeIn * 2, HitObject.TimePreempt)); - ApproachCircle.ScaleTo(1.1f, HitObject.TimePreempt); + ApproachCircle.ScaleTo(1f, HitObject.TimePreempt); ApproachCircle.Expire(true); } - protected override void UpdateStateTransforms(ArmedState state) + protected override void UpdateHitStateTransforms(ArmedState state) { - glow.FadeOut(400); + Debug.Assert(HitObject.HitWindows != null); switch (state) { case ArmedState.Idle: this.Delay(HitObject.TimePreempt).FadeOut(500); - - Expire(true); - - circle.HitAction = null; - - // override lifetime end as FadeIn may have been changed externally, causing out expiration to be too early. - LifetimeEnd = HitObject.StartTime + HitObject.HitWindows.HalfWindowFor(HitResult.Miss); + HitArea.HitAction = null; break; case ArmedState.Miss: ApproachCircle.FadeOut(50); this.FadeOut(100); - Expire(); break; case ArmedState.Hit: ApproachCircle.FadeOut(50); - const double flash_in = 40; - flash.FadeTo(0.8f, flash_in) - .Then() - .FadeOut(100); - - explode.FadeIn(flash_in); - - using (BeginDelayedSequence(flash_in, true)) - { - //after the flash, we can hide some elements that were behind it - ring.FadeOut(); - circle.FadeOut(); - number.FadeOut(); - - this.FadeOut(800); - explodeContainer.ScaleTo(1.5f, 400, Easing.OutQuad); - } - - Expire(); + // todo: temporary / arbitrary + this.Delay(800).FadeOut(); break; } } public Drawable ProxiedLayer => ApproachCircle; + + protected override JudgementResult CreateResult(Judgement judgement) => new OsuHitCircleJudgementResult(HitObject, judgement); + + public class HitReceptor : CompositeDrawable, IKeyBindingHandler + { + // IsHovered is used + public override bool HandlePositionalInput => true; + + public Func Hit; + + public OsuAction? HitAction; + + public HitReceptor() + { + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + CornerRadius = OsuHitObject.OBJECT_RADIUS; + CornerExponent = 2; + } + + public bool OnPressed(OsuAction action) + { + switch (action) + { + case OsuAction.LeftButton: + case OsuAction.RightButton: + if (IsHovered && (Hit?.Invoke() ?? false)) + { + HitAction = action; + return true; + } + + break; + } + + return false; + } + + public void OnReleased(OsuAction action) + { + } + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index 579f16e0d4..d17bf93fa0 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -1,31 +1,75 @@ -// 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 osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Drawables; using osu.Framework.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.UI; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableOsuHitObject : DrawableHitObject { - private readonly ShakeContainer shakeContainer; + public readonly IBindable PositionBindable = new Bindable(); + public readonly IBindable StackHeightBindable = new Bindable(); + public readonly IBindable ScaleBindable = new BindableFloat(); + public readonly IBindable IndexInCurrentComboBindable = new Bindable(); // Must be set to update IsHovered as it's used in relax mdo to detect osu hit objects. public override bool HandlePositionalInput => true; + protected override float SamplePlaybackPosition => HitObject.X / OsuPlayfield.BASE_SIZE.X; + + /// + /// Whether this can be hit, given a time value. + /// If non-null, judgements will be ignored (resulting in a shake) whilst the function returns false. + /// + public Func CheckHittable; + + private ShakeContainer shakeContainer; + protected DrawableOsuHitObject(OsuHitObject hitObject) : base(hitObject) { + } + + [BackgroundDependencyLoader] + private void load() + { + Alpha = 0; + base.AddInternal(shakeContainer = new ShakeContainer { ShakeDuration = 30, RelativeSizeAxes = Axes.Both }); + } - Alpha = 0; + protected override void OnApply(HitObject hitObject) + { + base.OnApply(hitObject); + + IndexInCurrentComboBindable.BindTo(HitObject.IndexInCurrentComboBindable); + PositionBindable.BindTo(HitObject.PositionBindable); + StackHeightBindable.BindTo(HitObject.StackHeightBindable); + ScaleBindable.BindTo(HitObject.ScaleBindable); + } + + protected override void OnFree(HitObject hitObject) + { + base.OnFree(hitObject); + + IndexInCurrentComboBindable.UnbindFrom(HitObject.IndexInCurrentComboBindable); + PositionBindable.UnbindFrom(HitObject.PositionBindable); + StackHeightBindable.UnbindFrom(HitObject.StackHeightBindable); + ScaleBindable.UnbindFrom(HitObject.ScaleBindable); } // Forward all internal management to shakeContainer. @@ -36,13 +80,24 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt; - protected override void UpdateInitialTransforms() => this.FadeIn(HitObject.TimeFadeIn); - private OsuInputManager osuActionInputManager; - internal OsuInputManager OsuActionInputManager => osuActionInputManager ?? (osuActionInputManager = GetContainingInputManager() as OsuInputManager); + internal OsuInputManager OsuActionInputManager => osuActionInputManager ??= GetContainingInputManager() as OsuInputManager; protected virtual void Shake(double maximumLength) => shakeContainer.Shake(maximumLength); - protected override JudgementResult CreateResult(Judgement judgement) => new OsuJudgementResult(judgement); + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + // Manually set to reduce the number of future alive objects to a bare minimum. + LifetimeStart = HitObject.StartTime - HitObject.TimePreempt; + } + + /// + /// Causes this to get missed, disregarding all conditions in implementations of . + /// + public void MissForcefully() => ApplyResult(r => r.Type = r.Judgement.MinResult); + + protected override JudgementResult CreateResult(Judgement judgement) => new OsuJudgementResult(HitObject, judgement); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs index 938a2293ba..98898ce1b4 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuJudgement.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.Graphics; +using osu.Game.Configuration; using osuTK; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; @@ -10,14 +12,74 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableOsuJudgement : DrawableJudgement { + protected SkinnableLighting Lighting { get; private set; } + + [Resolved] + private OsuConfigManager config { get; set; } + public DrawableOsuJudgement(JudgementResult result, DrawableHitObject judgedObject) : base(result, judgedObject) { } + public DrawableOsuJudgement() + { + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(Lighting = new SkinnableLighting + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Depth = float.MaxValue, + Alpha = 0 + }); + } + + public override void Apply(JudgementResult result, DrawableHitObject judgedObject) + { + base.Apply(result, judgedObject); + + if (judgedObject?.HitObject is OsuHitObject osuObject) + { + Position = osuObject.StackedPosition; + Scale = new Vector2(osuObject.Scale); + } + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Lighting.ResetAnimation(); + Lighting.SetColourFrom(JudgedObject, Result); + } + + private double fadeOutDelay; + protected override double FadeOutDelay => fadeOutDelay; + protected override void ApplyHitAnimations() { - JudgementText?.TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); + bool hitLightingEnabled = config.Get(OsuSetting.HitLighting); + + if (hitLightingEnabled) + { + JudgementBody.FadeIn().Delay(FadeInDuration).FadeOut(400); + + Lighting.ScaleTo(0.8f).ScaleTo(1.2f, 600, Easing.Out); + Lighting.FadeIn(200).Then().Delay(200).FadeOut(1000); + } + else + { + JudgementBody.Alpha = 1; + } + + fadeOutDelay = hitLightingEnabled ? 1400 : base.FadeOutDelay; + + JudgementText?.TransformSpacingTo(Vector2.Zero).Then().TransformSpacingTo(new Vector2(14, 0), 1800, Easing.OutQuint); base.ApplyHitAnimations(); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableRepeatPoint.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableRepeatPoint.cs deleted file mode 100644 index 1e2c0ae59f..0000000000 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableRepeatPoint.cs +++ /dev/null @@ -1,122 +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 osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.MathUtils; -using osu.Game.Rulesets.Objects.Drawables; -using osuTK; -using osu.Game.Rulesets.Scoring; -using osu.Game.Skinning; - -namespace osu.Game.Rulesets.Osu.Objects.Drawables -{ - public class DrawableRepeatPoint : DrawableOsuHitObject, ITrackSnaking - { - private readonly RepeatPoint repeatPoint; - private readonly DrawableSlider drawableSlider; - - private double animDuration; - - public DrawableRepeatPoint(RepeatPoint repeatPoint, DrawableSlider drawableSlider) - : base(repeatPoint) - { - this.repeatPoint = repeatPoint; - this.drawableSlider = drawableSlider; - - Size = new Vector2(45 * repeatPoint.Scale); - - Blending = BlendingMode.Additive; - Origin = Anchor.Centre; - - InternalChildren = new Drawable[] - { - new SkinnableDrawable("Play/osu/reversearrow", _ => new SpriteIcon - { - RelativeSizeAxes = Axes.Both, - Icon = FontAwesome.Solid.ChevronRight - }, restrictSize: false) - }; - } - - protected override void CheckForResult(bool userTriggered, double timeOffset) - { - if (repeatPoint.StartTime <= Time.Current) - ApplyResult(r => r.Type = drawableSlider.Tracking.Value ? HitResult.Great : HitResult.Miss); - } - - protected override void UpdateInitialTransforms() - { - animDuration = Math.Min(150, repeatPoint.SpanDuration / 2); - - this.Animate( - d => d.FadeIn(animDuration), - d => d.ScaleTo(0.5f).ScaleTo(1f, animDuration * 4, Easing.OutElasticHalf) - ); - } - - protected override void UpdateStateTransforms(ArmedState state) - { - switch (state) - { - case ArmedState.Idle: - this.Delay(HitObject.TimePreempt).FadeOut(); - break; - - case ArmedState.Miss: - this.FadeOut(animDuration); - break; - - case ArmedState.Hit: - this.FadeOut(animDuration, Easing.OutQuint) - .ScaleTo(Scale * 1.5f, animDuration, Easing.Out); - break; - } - } - - private bool hasRotation; - - public void UpdateSnakingPosition(Vector2 start, Vector2 end) - { - bool isRepeatAtEnd = repeatPoint.RepeatIndex % 2 == 0; - List curve = drawableSlider.Body.CurrentCurve; - - Position = isRepeatAtEnd ? end : start; - - if (curve.Count < 2) - return; - - int searchStart = isRepeatAtEnd ? curve.Count - 1 : 0; - int direction = isRepeatAtEnd ? -1 : 1; - - Vector2 aimRotationVector = Vector2.Zero; - - // find the next vector2 in the curve which is not equal to our current position to infer a rotation. - for (int i = searchStart; i >= 0 && i < curve.Count; i += direction) - { - if (Precision.AlmostEquals(curve[i], Position)) - continue; - - aimRotationVector = curve[i]; - break; - } - - float aimRotation = MathHelper.RadiansToDegrees((float)Math.Atan2(aimRotationVector.Y - Position.Y, aimRotationVector.X - Position.X)); - while (Math.Abs(aimRotation - Rotation) > 180) - aimRotation += aimRotation < Rotation ? 360 : -360; - - if (!hasRotation) - { - Rotation = aimRotation; - hasRotation = true; - } - else - { - // If we're already snaking, interpolate to smooth out sharp curves (linear sliders, mainly). - Rotation = Interpolation.ValueAt(MathHelper.Clamp(Clock.ElapsedFrameTime, 0, 100), Rotation, aimRotation, 0, 50, Easing.OutQuint); - } - } - } -} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 4a6bd45007..3f91a31066 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -1,18 +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; +using System.Linq; +using JetBrains.Annotations; using osuTK; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; -using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Osu.Configuration; -using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Osu.Skinning; +using osu.Game.Rulesets.Osu.UI; using osuTK.Graphics; using osu.Game.Skinning; @@ -20,109 +21,179 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableSlider : DrawableOsuHitObject, IDrawableHitObjectWithProxiedApproach { - private readonly Slider slider; - private readonly List components = new List(); + public new Slider HitObject => (Slider)base.HitObject; - public readonly DrawableHitCircle HeadCircle; - public readonly DrawableSliderTail TailCircle; + public DrawableSliderHead HeadCircle => headContainer.Child; + public DrawableSliderTail TailCircle => tailContainer.Child; - public readonly SnakingSliderBody Body; - public readonly SliderBall Ball; + public SliderBall Ball { get; private set; } + public SkinnableDrawable Body { get; private set; } - private readonly IBindable positionBindable = new Bindable(); - private readonly IBindable scaleBindable = new Bindable(); - private readonly IBindable pathBindable = new Bindable(); + public override bool DisplayResult => false; - [Resolved(CanBeNull = true)] - private OsuRulesetConfigManager config { get; set; } + private PlaySliderBody sliderBody => Body.Drawable as PlaySliderBody; - public DrawableSlider(Slider s) + public IBindable PathVersion => pathVersion; + private readonly Bindable pathVersion = new Bindable(); + + private Container headContainer; + private Container tailContainer; + private Container tickContainer; + private Container repeatContainer; + + public DrawableSlider([CanBeNull] Slider s = null) : base(s) { - slider = s; - - Position = s.StackedPosition; - - Container ticks; - Container repeatPoints; - - InternalChildren = new Drawable[] - { - Body = new SnakingSliderBody(s) - { - PathRadius = s.Scale * OsuHitObject.OBJECT_RADIUS, - }, - ticks = new Container { RelativeSizeAxes = Axes.Both }, - repeatPoints = new Container { RelativeSizeAxes = Axes.Both }, - Ball = new SliderBall(s, this) - { - GetInitialHitAction = () => HeadCircle.HitAction, - BypassAutoSizeAxes = Axes.Both, - Scale = new Vector2(s.Scale), - AlwaysPresent = true, - Alpha = 0 - }, - HeadCircle = new DrawableSliderHead(s, s.HeadCircle) - { - OnShake = Shake - }, - TailCircle = new DrawableSliderTail(s, s.TailCircle) - }; - - components.Add(Body); - components.Add(Ball); - - AddNested(HeadCircle); - - AddNested(TailCircle); - components.Add(TailCircle); - - foreach (var tick in s.NestedHitObjects.OfType()) - { - var drawableTick = new DrawableSliderTick(tick) { Position = tick.Position - s.Position }; - - ticks.Add(drawableTick); - components.Add(drawableTick); - AddNested(drawableTick); - } - - foreach (var repeatPoint in s.NestedHitObjects.OfType()) - { - var drawableRepeatPoint = new DrawableRepeatPoint(repeatPoint, this) { Position = repeatPoint.Position - s.Position }; - - repeatPoints.Add(drawableRepeatPoint); - components.Add(drawableRepeatPoint); - AddNested(drawableRepeatPoint); - } } [BackgroundDependencyLoader] private void load() { - config?.BindWith(OsuRulesetSetting.SnakingInSliders, Body.SnakingIn); - config?.BindWith(OsuRulesetSetting.SnakingOutSliders, Body.SnakingOut); - - positionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); - scaleBindable.BindValueChanged(scale => + InternalChildren = new Drawable[] { - Body.PathRadius = scale.NewValue * 64; - Ball.Scale = new Vector2(scale.NewValue); - }); + Body = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderBody), _ => new DefaultSliderBody(), confineMode: ConfineMode.NoScaling), + tailContainer = new Container { RelativeSizeAxes = Axes.Both }, + tickContainer = new Container { RelativeSizeAxes = Axes.Both }, + repeatContainer = new Container { RelativeSizeAxes = Axes.Both }, + Ball = new SliderBall(this) + { + GetInitialHitAction = () => HeadCircle.HitAction, + BypassAutoSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0 + }, + headContainer = new Container { RelativeSizeAxes = Axes.Both }, + }; - positionBindable.BindTo(HitObject.PositionBindable); - scaleBindable.BindTo(HitObject.ScaleBindable); - pathBindable.BindTo(slider.PathBindable); - - pathBindable.BindValueChanged(_ => Body.Refresh()); + PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); + StackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); + ScaleBindable.BindValueChanged(scale => Ball.Scale = new Vector2(scale.NewValue)); AccentColour.BindValueChanged(colour => { - Body.AccentColour = colour.NewValue; - Ball.AccentColour = colour.NewValue; - foreach (var drawableHitObject in NestedHitObjects) drawableHitObject.AccentColour.Value = colour.NewValue; }, true); + + Tracking.BindValueChanged(updateSlidingSample); + } + + protected override void OnApply(HitObject hitObject) + { + base.OnApply(hitObject); + + // Ensure that the version will change after the upcoming BindTo(). + pathVersion.Value = int.MaxValue; + PathVersion.BindTo(HitObject.Path.Version); + } + + protected override void OnFree(HitObject hitObject) + { + base.OnFree(hitObject); + + PathVersion.UnbindFrom(HitObject.Path.Version); + } + + private PausableSkinnableSound slidingSample; + + protected override void LoadSamples() + { + base.LoadSamples(); + + slidingSample?.Expire(); + slidingSample = null; + + var firstSample = HitObject.Samples.FirstOrDefault(); + + if (firstSample != null) + { + var clone = HitObject.SampleControlPoint.ApplyTo(firstSample); + clone.Name = "sliderslide"; + + AddInternal(slidingSample = new PausableSkinnableSound(clone) + { + Looping = true + }); + } + } + + public override void StopAllSamples() + { + base.StopAllSamples(); + slidingSample?.Stop(); + } + + private void updateSlidingSample(ValueChangedEvent tracking) + { + if (tracking.NewValue) + slidingSample?.Play(); + else + slidingSample?.Stop(); + } + + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) + { + case DrawableSliderHead head: + headContainer.Child = head; + break; + + case DrawableSliderTail tail: + tailContainer.Child = tail; + break; + + case DrawableSliderTick tick: + tickContainer.Add(tick); + break; + + case DrawableSliderRepeat repeat: + repeatContainer.Add(repeat); + break; + } + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + + headContainer.Clear(); + tailContainer.Clear(); + repeatContainer.Clear(); + tickContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case SliderTailCircle tail: + return new DrawableSliderTail(tail); + + case SliderHeadCircle head: + return new DrawableSliderHead(HitObject, head) + { + OnShake = Shake, + CheckHittable = (d, t) => CheckHittable?.Invoke(d, t) ?? true + }; + + case SliderTick tick: + return new DrawableSliderTick(tick) { Position = tick.Position - HitObject.Position }; + + case SliderRepeat repeat: + return new DrawableSliderRepeat(repeat, this) { Position = repeat.Position - HitObject.Position }; + } + + return base.CreateNestedHitObject(hitObject); + } + + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); + + Body.FadeInFromZero(HitObject.TimeFadeIn); } public readonly Bindable Tracking = new Bindable(); @@ -133,14 +204,23 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Tracking.Value = Ball.Tracking; - double completionProgress = MathHelper.Clamp((Time.Current - slider.StartTime) / slider.Duration, 0, 1); + if (Tracking.Value && slidingSample != null) + // keep the sliding sample playing at the current tracking position + slidingSample.Balance.Value = CalculateSamplePlaybackBalance(Ball.X / OsuPlayfield.BASE_SIZE.X); - foreach (var c in components.OfType()) c.UpdateProgress(completionProgress); - foreach (var c in components.OfType()) c.UpdateSnakingPosition(slider.Path.PositionAt(Body.SnakedStart ?? 0), slider.Path.PositionAt(Body.SnakedEnd ?? 0)); - foreach (var t in components.OfType()) t.Tracking = Ball.Tracking; + double completionProgress = Math.Clamp((Time.Current - HitObject.StartTime) / HitObject.Duration, 0, 1); - Size = Body.Size; - OriginPosition = Body.PathOffset; + Ball.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 IRequireTracking t) t.Tracking = Ball.Tracking; + } + + Size = sliderBody?.Size ?? Vector2.Zero; + OriginPosition = sliderBody?.PathOffset ?? Vector2.Zero; if (DrawSize != Vector2.Zero) { @@ -154,69 +234,66 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public override void OnKilled() { base.OnKilled(); - Body.RecyclePath(); + sliderBody?.RecyclePath(); } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void ApplySkin(ISkinSource skin, bool allowFallback) { - base.SkinChanged(skin, allowFallback); + base.ApplySkin(skin, allowFallback); - Body.BorderSize = skin.GetValue(s => s.SliderBorderSize) ?? SliderBody.DEFAULT_BORDER_SIZE; - Body.AccentColour = skin.GetValue(s => s.CustomColours.ContainsKey("SliderTrackOverride") ? s.CustomColours["SliderTrackOverride"] : (Color4?)null) ?? AccentColour.Value; - Body.BorderColour = skin.GetValue(s => s.CustomColours.ContainsKey("SliderBorder") ? s.CustomColours["SliderBorder"] : (Color4?)null) ?? Color4.White; - Ball.AccentColour = skin.GetValue(s => s.CustomColours.ContainsKey("SliderBall") ? s.CustomColours["SliderBall"] : (Color4?)null) ?? AccentColour.Value; + bool allowBallTint = skin.GetConfig(OsuSkinConfiguration.AllowSliderBallTint)?.Value ?? false; + Ball.AccentColour = allowBallTint ? AccentColour.Value : Color4.White; } protected override void CheckForResult(bool userTriggered, double timeOffset) { - if (userTriggered || Time.Current < slider.EndTime) + if (userTriggered || Time.Current < HitObject.EndTime) return; - ApplyResult(r => - { - var judgementsCount = NestedHitObjects.Count(); - var judgementsHit = NestedHitObjects.Count(h => h.IsHit); - - var hitFraction = (double)judgementsHit / judgementsCount; - - if (hitFraction == 1 && HeadCircle.Result.Type == HitResult.Great) - r.Type = HitResult.Great; - else if (hitFraction >= 0.5 && HeadCircle.Result.Type >= HitResult.Good) - r.Type = HitResult.Good; - else if (hitFraction > 0) - r.Type = HitResult.Meh; - else - r.Type = HitResult.Miss; - }); + ApplyResult(r => r.Type = r.Judgement.MaxResult); } - protected override void UpdateStateTransforms(ArmedState state) + public override void PlaySamples() { + // 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) + base.PlaySamples(); + } + + protected override void UpdateStartTimeStateTransforms() + { + base.UpdateStartTimeStateTransforms(); + Ball.FadeIn(); Ball.ScaleTo(HitObject.Scale); - - using (BeginDelayedSequence(slider.Duration, true)) - { - const float fade_out_time = 450; - - // intentionally pile on an extra FadeOut to make it happen much faster. - Ball.FadeOut(fade_out_time / 4, Easing.Out); - - switch (state) - { - case ArmedState.Hit: - Ball.ScaleTo(HitObject.Scale * 1.4f, fade_out_time, Easing.Out); - break; - } - - this.FadeOut(fade_out_time, Easing.OutQuint).Expire(); - } - - Expire(true); } - public Drawable ProxiedLayer => HeadCircle.ApproachCircle; + protected override void UpdateHitStateTransforms(ArmedState state) + { + base.UpdateHitStateTransforms(state); - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Body.ReceivePositionalInputAt(screenSpacePos); + const float fade_out_time = 450; + + // intentionally pile on an extra FadeOut to make it happen much faster. + Ball.FadeOut(fade_out_time / 4, Easing.Out); + + switch (state) + { + case ArmedState.Hit: + Ball.ScaleTo(HitObject.Scale * 1.4f, fade_out_time, Easing.Out); + break; + } + + this.FadeOut(fade_out_time, Easing.OutQuint); + } + + public Drawable ProxiedLayer => HeadCircle.ProxiedLayer; + + 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/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index 66b6f0f9ac..49ed9f12e3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -4,20 +4,19 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; -using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableSliderHead : DrawableHitCircle { - private readonly IBindable positionBindable = new Bindable(); - private readonly IBindable pathBindable = new Bindable(); + private readonly IBindable pathVersion = new Bindable(); + + protected override OsuSkinComponents CirclePieceComponent => OsuSkinComponents.SliderHeadHitCircle; private readonly Slider slider; - public DrawableSliderHead(Slider slider, HitCircle h) + public DrawableSliderHead(Slider slider, SliderHeadCircle h) : base(h) { this.slider = slider; @@ -26,18 +25,17 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables [BackgroundDependencyLoader] private void load() { - positionBindable.BindTo(HitObject.PositionBindable); - pathBindable.BindTo(slider.PathBindable); + pathVersion.BindTo(slider.Path.Version); - positionBindable.BindValueChanged(_ => updatePosition()); - pathBindable.BindValueChanged(_ => updatePosition(), true); + PositionBindable.BindValueChanged(_ => updatePosition()); + pathVersion.BindValueChanged(_ => updatePosition(), true); } protected override void Update() { base.Update(); - double completionProgress = MathHelper.Clamp((Time.Current - slider.StartTime) / slider.Duration, 0, 1); + double completionProgress = Math.Clamp((Time.Current - slider.StartTime) / slider.Duration, 0, 1); //todo: we probably want to reconsider this before adding scoring, but it looks and feels nice. if (!IsHit) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs new file mode 100644 index 0000000000..9c382bd0a7 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -0,0 +1,142 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public class DrawableSliderRepeat : DrawableOsuHitObject, ITrackSnaking + { + private readonly SliderRepeat sliderRepeat; + private readonly DrawableSlider drawableSlider; + + private double animDuration; + + public Drawable CirclePiece { get; private set; } + private Drawable scaleContainer; + private ReverseArrowPiece arrow; + + public override bool DisplayResult => false; + + public DrawableSliderRepeat(SliderRepeat sliderRepeat, DrawableSlider drawableSlider) + : base(sliderRepeat) + { + this.sliderRepeat = sliderRepeat; + this.drawableSlider = drawableSlider; + } + + [BackgroundDependencyLoader] + private void load() + { + Origin = Anchor.Centre; + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + + InternalChild = scaleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new[] + { + // no default for this; only visible in legacy skins. + CirclePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()), + arrow = new ReverseArrowPiece(), + } + }; + + ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue), true); + } + + protected override void CheckForResult(bool userTriggered, double timeOffset) + { + if (sliderRepeat.StartTime <= Time.Current) + ApplyResult(r => r.Type = drawableSlider.Tracking.Value ? r.Judgement.MaxResult : r.Judgement.MinResult); + } + + protected override void UpdateInitialTransforms() + { + animDuration = Math.Min(300, sliderRepeat.SpanDuration); + + this.Animate( + d => d.FadeIn(animDuration), + d => d.ScaleTo(0.5f).ScaleTo(1f, animDuration * 2, Easing.OutElasticHalf) + ); + } + + protected override void UpdateHitStateTransforms(ArmedState state) + { + base.UpdateHitStateTransforms(state); + + switch (state) + { + case ArmedState.Idle: + this.Delay(HitObject.TimePreempt).FadeOut(); + break; + + case ArmedState.Miss: + this.FadeOut(animDuration); + break; + + case ArmedState.Hit: + this.FadeOut(animDuration, Easing.Out) + .ScaleTo(Scale * 1.5f, animDuration, Easing.Out); + break; + } + } + + private bool hasRotation; + + public void UpdateSnakingPosition(Vector2 start, Vector2 end) + { + // When the repeat is hit, the arrow should fade out on spot rather than following the slider + if (IsHit) return; + + bool isRepeatAtEnd = sliderRepeat.RepeatIndex % 2 == 0; + List curve = ((PlaySliderBody)drawableSlider.Body.Drawable).CurrentCurve; + + Position = isRepeatAtEnd ? end : start; + + if (curve.Count < 2) + return; + + int searchStart = isRepeatAtEnd ? curve.Count - 1 : 0; + int direction = isRepeatAtEnd ? -1 : 1; + + Vector2 aimRotationVector = Vector2.Zero; + + // find the next vector2 in the curve which is not equal to our current position to infer a rotation. + for (int i = searchStart; i >= 0 && i < curve.Count; i += direction) + { + if (Precision.AlmostEquals(curve[i], Position)) + continue; + + aimRotationVector = curve[i]; + break; + } + + 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; + + if (!hasRotation) + { + 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); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index 23c5494cf5..3be5983c57 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -1,17 +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.Bindables; +using System.Diagnostics; +using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking + public class DrawableSliderTail : DrawableOsuHitObject, IRequireTracking, ITrackSnaking { - private readonly Slider slider; + private readonly SliderTailCircle tailCircle; /// /// The judgement text is provided by the . @@ -20,34 +22,76 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public bool Tracking { get; set; } - private readonly IBindable positionBindable = new Bindable(); - private readonly IBindable pathBindable = new Bindable(); + private SkinnableDrawable circlePiece; + private Container scaleContainer; - public DrawableSliderTail(Slider slider, SliderTailCircle hitCircle) - : base(hitCircle) + public DrawableSliderTail(SliderTailCircle tailCircle) + : base(tailCircle) { - this.slider = slider; + this.tailCircle = tailCircle; + } + [BackgroundDependencyLoader] + private void load() + { Origin = Anchor.Centre; + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); - RelativeSizeAxes = Axes.Both; - FillMode = FillMode.Fit; + InternalChildren = new Drawable[] + { + scaleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Children = new Drawable[] + { + // no default for this; only visible in legacy skins. + circlePiece = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderTailHitCircle), _ => Empty()) + } + }, + }; - AlwaysPresent = true; + ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue), true); + } - positionBindable.BindTo(hitCircle.PositionBindable); - pathBindable.BindTo(slider.PathBindable); + protected override void UpdateInitialTransforms() + { + base.UpdateInitialTransforms(); - positionBindable.BindValueChanged(_ => updatePosition()); - pathBindable.BindValueChanged(_ => updatePosition(), true); + circlePiece.FadeInFromZero(HitObject.TimeFadeIn); + } + + protected override void UpdateHitStateTransforms(ArmedState state) + { + base.UpdateHitStateTransforms(state); + + Debug.Assert(HitObject.HitWindows != null); + + switch (state) + { + case ArmedState.Idle: + this.Delay(HitObject.TimePreempt).FadeOut(500); + break; + + case ArmedState.Miss: + this.FadeOut(100); + break; + + case ArmedState.Hit: + // todo: temporary / arbitrary + this.Delay(800).FadeOut(); + break; + } } protected override void CheckForResult(bool userTriggered, double timeOffset) { if (!userTriggered && timeOffset >= 0) - ApplyResult(r => r.Type = Tracking ? HitResult.Great : HitResult.Miss); + ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); } - private void updatePosition() => Position = HitObject.Position - slider.Position; + public void UpdateSnakingPosition(Vector2 start, Vector2 end) => + Position = tailCircle.RepeatIndex % 2 == 0 ? end : start; } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs index f5f92dd05d..2af51ea486 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTick.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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osu.Framework.Graphics.Containers; @@ -16,40 +16,51 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { public const double ANIM_DURATION = 150; + private const float default_tick_size = 16; + public bool Tracking { get; set; } public override bool DisplayResult => false; + private SkinnableDrawable scaleContainer; + public DrawableSliderTick(SliderTick sliderTick) : base(sliderTick) { - Size = new Vector2(16) * sliderTick.Scale; + } + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); Origin = Anchor.Centre; - InternalChildren = new Drawable[] + InternalChild = scaleContainer = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderScorePoint), _ => new CircularContainer { - new SkinnableDrawable("Play/osu/sliderscorepoint", _ => new Container + Masking = true, + Origin = Anchor.Centre, + Size = new Vector2(default_tick_size), + BorderThickness = default_tick_size / 4, + BorderColour = Color4.White, + Child = new Box { - Masking = true, RelativeSizeAxes = Axes.Both, - Origin = Anchor.Centre, - CornerRadius = Size.X / 2, - BorderThickness = 2, - BorderColour = Color4.White, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = AccentColour.Value, - Alpha = 0.3f, - } - }, restrictSize: false) + Colour = AccentColour.Value, + Alpha = 0.3f, + } + }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, }; + + ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue), true); } protected override void CheckForResult(bool userTriggered, double timeOffset) { if (timeOffset >= 0) - ApplyResult(r => r.Type = Tracking ? HitResult.Great : HitResult.Miss); + ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); } protected override void UpdateInitialTransforms() @@ -58,8 +69,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables this.ScaleTo(0.5f).ScaleTo(1f, ANIM_DURATION * 4, Easing.OutElasticHalf); } - protected override void UpdateStateTransforms(ArmedState state) + protected override void UpdateHitStateTransforms(ArmedState state) { + base.UpdateHitStateTransforms(state); + switch (state) { case ArmedState.Idle: diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index a0bd301fdb..eb125969b0 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -1,238 +1,277 @@ // 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.Audio; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; -using osuTK; -using osuTK.Graphics; -using osu.Game.Graphics; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics.Sprites; -using osu.Game.Screens.Ranking; +using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Ranking; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Objects.Drawables { public class DrawableSpinner : DrawableOsuHitObject { - protected readonly Spinner Spinner; + public new Spinner HitObject => (Spinner)base.HitObject; - public readonly SpinnerDisc Disc; - public readonly SpinnerTicks Ticks; - private readonly SpinnerSpmCounter spmCounter; + public SpinnerRotationTracker RotationTracker { get; private set; } + public SpinnerSpmCounter SpmCounter { get; private set; } - private readonly Container mainContainer; + private Container ticks; + private SpinnerBonusDisplay bonusDisplay; - public readonly SpinnerBackground Background; - private readonly Container circleContainer; - private readonly CirclePiece circle; - private readonly GlowPiece glow; + private Bindable isSpinning; + private bool spinnerFrequencyModulate; - private readonly SpriteIcon symbol; - - private readonly Color4 baseColour = OsuColour.FromHex(@"002c3c"); - private readonly Color4 fillColour = OsuColour.FromHex(@"005b7c"); - - private readonly IBindable positionBindable = new Bindable(); - - private Color4 normalColour; - private Color4 completeColour; - - public DrawableSpinner(Spinner s) + public DrawableSpinner([CanBeNull] Spinner s = null) : base(s) { - Origin = Anchor.Centre; - Position = s.Position; - - RelativeSizeAxes = Axes.Both; - - // we are slightly bigger than our parent, to clip the top and bottom of the circle - Height = 1.3f; - - Spinner = s; - - InternalChildren = new Drawable[] - { - circleContainer = new Container - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] - { - glow = new GlowPiece(), - circle = new CirclePiece - { - Position = Vector2.Zero, - Anchor = Anchor.Centre, - }, - new RingPiece(), - symbol = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(48), - Icon = FontAwesome.Solid.Asterisk, - Shadow = false, - }, - } - }, - mainContainer = new AspectContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, - Children = new[] - { - Background = new SpinnerBackground - { - Alpha = 0.6f, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - Disc = new SpinnerDisc(Spinner) - { - Scale = Vector2.Zero, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - circleContainer.CreateProxy(), - Ticks = new SpinnerTicks - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - } - }, - spmCounter = new SpinnerSpmCounter - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Y = 120, - Alpha = 0 - } - }; } [BackgroundDependencyLoader] private void load(OsuColour colours) { - normalColour = baseColour; + Origin = Anchor.Centre; + RelativeSizeAxes = Axes.Both; - Background.AccentColour = normalColour; + InternalChildren = new Drawable[] + { + ticks = new Container(), + new AspectContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Children = new Drawable[] + { + new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinnerDisc()), + 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, + } + }; - completeColour = colours.YellowLight.Opacity(0.75f); - - Disc.AccentColour = fillColour; - circle.Colour = colours.BlueDark; - glow.Colour = colours.BlueDark; - - positionBindable.BindValueChanged(pos => Position = pos.NewValue); - positionBindable.BindTo(HitObject.PositionBindable); + PositionBindable.BindValueChanged(pos => Position = pos.NewValue); } - public float Progress => MathHelper.Clamp(Disc.RotationAbsolute / 360 / Spinner.SpinsRequired, 0, 1); + protected override void LoadComplete() + { + base.LoadComplete(); + + isSpinning = RotationTracker.IsSpinning.GetBoundCopy(); + isSpinning.BindValueChanged(updateSpinningSample); + } + + private PausableSkinnableSound spinningSample; + private const float spinning_sample_initial_frequency = 1.0f; + private const float spinning_sample_modulated_base_frequency = 0.5f; + + protected override void LoadSamples() + { + base.LoadSamples(); + + spinningSample?.Expire(); + spinningSample = null; + + var firstSample = HitObject.Samples.FirstOrDefault(); + + if (firstSample != null) + { + var clone = HitObject.SampleControlPoint.ApplyTo(firstSample); + clone.Name = "spinnerspin"; + + AddInternal(spinningSample = new PausableSkinnableSound(clone) + { + Volume = { Value = 0 }, + Looping = true, + Frequency = { Value = spinning_sample_initial_frequency } + }); + } + } + + private void updateSpinningSample(ValueChangedEvent tracking) + { + if (tracking.NewValue) + { + spinningSample?.Play(); + spinningSample?.VolumeTo(1, 200); + } + else + { + spinningSample?.VolumeTo(0, 200).Finally(_ => spinningSample.Stop()); + } + } + + public override void StopAllSamples() + { + base.StopAllSamples(); + spinningSample?.Stop(); + } + + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) + { + case DrawableSpinnerTick tick: + ticks.Add(tick); + break; + } + } + + protected override void UpdateHitStateTransforms(ArmedState state) + { + base.UpdateHitStateTransforms(state); + + this.FadeOut(160); + + // skin change does a rewind of transforms, which will stop the spinning sound from playing if it's currently in playback. + isSpinning?.TriggerChange(); + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + ticks.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case SpinnerBonusTick bonusTick: + return new DrawableSpinnerBonusTick(bonusTick); + + case SpinnerTick tick: + return new DrawableSpinnerTick(tick); + } + + return base.CreateNestedHitObject(hitObject); + } + + protected override void ApplySkin(ISkinSource skin, bool allowFallback) + { + base.ApplySkin(skin, allowFallback); + spinnerFrequencyModulate = skin.GetConfig(OsuSkinConfiguration.SpinnerFrequencyModulate)?.Value ?? true; + } + + /// + /// The completion progress of this spinner from 0..1 (clamped). + /// + public float Progress + { + get + { + if (HitObject.SpinsRequired == 0) + // some spinners are so short they can't require an integer spin count. + // these become implicitly hit. + return 1; + + return Math.Clamp(RotationTracker.RateAdjustedRotation / 360 / HitObject.SpinsRequired, 0, 1); + } + } protected override void CheckForResult(bool userTriggered, double timeOffset) { if (Time.Current < HitObject.StartTime) return; - if (Progress >= 1 && !Disc.Complete) - { - Disc.Complete = true; + RotationTracker.Complete.Value = Progress >= 1; - const float duration = 200; - - Disc.FadeAccent(completeColour, duration); - - Background.FadeAccent(completeColour, duration); - Background.FadeOut(duration); - - circle.FadeColour(completeColour, duration); - glow.FadeColour(completeColour, duration); - } - - if (userTriggered || Time.Current < Spinner.EndTime) + if (userTriggered || Time.Current < HitObject.EndTime) return; + // Trigger a miss result for remaining ticks to avoid infinite gameplay. + foreach (var tick in ticks.Where(t => !t.Result.HasResult)) + tick.TriggerResult(false); + ApplyResult(r => { if (Progress >= 1) r.Type = HitResult.Great; else if (Progress > .9) - r.Type = HitResult.Good; + r.Type = HitResult.Ok; else if (Progress > .75) r.Type = HitResult.Meh; - else if (Time.Current >= Spinner.EndTime) - r.Type = HitResult.Miss; + else if (Time.Current >= HitObject.EndTime) + r.Type = r.Judgement.MinResult; }); } protected override void Update() { - Disc.Tracking = OsuActionInputManager?.PressedActions.Any(x => x == OsuAction.LeftButton || x == OsuAction.RightButton) ?? false; - if (!spmCounter.IsPresent && Disc.Tracking) - spmCounter.FadeIn(HitObject.TimeFadeIn); - base.Update(); + + if (HandleUserInput) + RotationTracker.Tracking = !Result.HasResult && (OsuActionInputManager?.PressedActions.Any(x => x == OsuAction.LeftButton || x == OsuAction.RightButton) ?? false); + + if (spinningSample != null && spinnerFrequencyModulate) + spinningSample.Frequency.Value = spinning_sample_modulated_base_frequency + Progress; } protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - circle.Rotation = Disc.Rotation; - Ticks.Rotation = Disc.Rotation; - spmCounter.SetRotation(Disc.RotationAbsolute); + if (!SpmCounter.IsPresent && RotationTracker.Tracking) + SpmCounter.FadeIn(HitObject.TimeFadeIn); + SpmCounter.SetRotation(RotationTracker.RateAdjustedRotation); - float relativeCircleScale = Spinner.Scale * circle.DrawHeight / mainContainer.DrawHeight; - Disc.ScaleTo(relativeCircleScale + (1 - relativeCircleScale) * Progress, 200, Easing.OutQuint); - - symbol.RotateTo(Disc.Rotation / 2, 500, Easing.OutQuint); + updateBonusScore(); } - protected override void UpdateInitialTransforms() + private int wholeSpins; + + private void updateBonusScore() { - base.UpdateInitialTransforms(); + if (ticks.Count == 0) + return; - circleContainer.ScaleTo(Spinner.Scale * 0.3f); - circleContainer.ScaleTo(Spinner.Scale, HitObject.TimePreempt / 1.4f, Easing.OutQuint); + int spins = (int)(RotationTracker.RateAdjustedRotation / 360); - Disc.RotateTo(-720); - symbol.RotateTo(-720); - - mainContainer - .ScaleTo(0) - .ScaleTo(Spinner.Scale * circle.DrawHeight / DrawHeight * 1.4f, HitObject.TimePreempt - 150, Easing.OutQuint) - .Then() - .ScaleTo(1, 500, Easing.OutQuint); - } - - protected override void UpdateStateTransforms(ArmedState state) - { - var sequence = this.Delay(Spinner.Duration).FadeOut(160); - - switch (state) + if (spins < wholeSpins) { - case ArmedState.Idle: - Expire(true); - break; - - case ArmedState.Hit: - sequence.ScaleTo(Scale * 1.2f, 320, Easing.Out); - break; - - case ArmedState.Miss: - sequence.ScaleTo(Scale * 0.8f, 320, Easing.In); - break; + // rewinding, silently handle + wholeSpins = spins; + return; } - Expire(); + while (wholeSpins != spins) + { + var tick = ticks.FirstOrDefault(t => !t.Result.HasResult); + + // tick may be null if we've hit the spin limit. + if (tick != null) + { + tick.TriggerResult(true); + if (tick is DrawableSpinnerBonusTick) + bonusDisplay.SetBonusCount(spins - HitObject.SpinsRequired); + } + + wholeSpins++; + } } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerBonusTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerBonusTick.cs new file mode 100644 index 0000000000..2e1c07c4c6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerBonusTick.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. + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public class DrawableSpinnerBonusTick : DrawableSpinnerTick + { + public DrawableSpinnerBonusTick(SpinnerBonusTick spinnerTick) + : base(spinnerTick) + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.cs new file mode 100644 index 0000000000..e9cede1398 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinnerTick.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.Rulesets.Osu.Objects.Drawables +{ + public class DrawableSpinnerTick : DrawableOsuHitObject + { + public override bool DisplayResult => false; + + public DrawableSpinnerTick(SpinnerTick spinnerTick) + : base(spinnerTick) + { + } + + /// + /// Apply a judgement result. + /// + /// Whether this tick was reached. + internal void TriggerResult(bool hit) => ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult); + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ApproachCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ApproachCircle.cs index 9981585f9e..1b474f265c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ApproachCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ApproachCircle.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { @@ -24,7 +25,26 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces [BackgroundDependencyLoader] private void load(TextureStore textures) { - Child = new SkinnableSprite("Play/osu/approachcircle"); + Child = new SkinnableApproachCircle(); + } + + private class SkinnableApproachCircle : SkinnableSprite + { + public SkinnableApproachCircle() + : base("Gameplay/osu/approachcircle") + { + } + + protected override Drawable CreateDefault(ISkinComponent component) + { + var drawable = base.CreateDefault(component); + + // account for the sprite being used for the default approach circle being taken from stable, + // when hitcircles have 5px padding on each size. this should be removed if we update the sprite. + drawable.Scale = new Vector2(128 / 118f); + + return drawable; + } } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs index dc0b149140..c455c66e8d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/CirclePiece.cs @@ -1,54 +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 System; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Bindings; -using osu.Game.Skinning; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets.Objects.Drawables; using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class CirclePiece : Container, IKeyBindingHandler + public class CirclePiece : CompositeDrawable { - // IsHovered is used - public override bool HandlePositionalInput => true; - - public Func Hit; - - public OsuAction? HitAction; - public CirclePiece() { Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); Masking = true; + CornerRadius = Size.X / 2; + CornerExponent = 2; Anchor = Anchor.Centre; Origin = Anchor.Centre; - - InternalChild = new SkinnableDrawable("Play/osu/hitcircle", _ => new DefaultCirclePiece()); } - public bool OnPressed(OsuAction action) + [BackgroundDependencyLoader] + private void load(TextureStore textures, DrawableHitObject drawableHitObject) { - switch (action) + InternalChildren = new Drawable[] { - case OsuAction.LeftButton: - case OsuAction.RightButton: - if (IsHovered && (Hit?.Invoke() ?? false)) - { - HitAction = action; - return true; - } - - break; - } - - return false; + new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = textures.Get(@"Gameplay/osu/disc"), + }, + new TrianglesPiece(drawableHitObject.GetHashCode()) + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Alpha = 0.5f, + } + }; } - - public bool OnReleased(OsuAction action) => false; } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultCirclePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultCirclePiece.cs deleted file mode 100644 index 047ff943ff..0000000000 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultCirclePiece.cs +++ /dev/null @@ -1,35 +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.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; - -namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces -{ - public class DefaultCirclePiece : Container - { - [BackgroundDependencyLoader] - private void load(TextureStore textures) - { - RelativeSizeAxes = Axes.Both; - Children = new Drawable[] - { - new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Texture = textures.Get(@"Play/osu/disc"), - }, - new TrianglesPiece - { - RelativeSizeAxes = Axes.Both, - Blending = BlendingMode.Additive, - Alpha = 0.5f, - } - }; - } - } -} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultSpinnerDisc.cs new file mode 100644 index 0000000000..731852c221 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DefaultSpinnerDisc.cs @@ -0,0 +1,214 @@ +// 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.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces +{ + public class DefaultSpinnerDisc : CompositeDrawable + { + private DrawableSpinner drawableSpinner; + + private const float initial_scale = 1.3f; + private const float idle_alpha = 0.2f; + private const float tracking_alpha = 0.4f; + + private Color4 normalColour; + private Color4 completeColour; + + private SpinnerTicks ticks; + + private int wholeRotationCount; + + private SpinnerFill fill; + private Container mainContainer; + private SpinnerCentreLayer centre; + private SpinnerBackgroundLayer background; + + 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] + private void load(OsuColour colours, DrawableHitObject drawableHitObject) + { + drawableSpinner = (DrawableSpinner)drawableHitObject; + + normalColour = colours.BlueDark; + completeColour = colours.YellowLight; + + InternalChildren = new Drawable[] + { + mainContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + background = new SpinnerBackgroundLayer(), + fill = new SpinnerFill + { + Alpha = idle_alpha, + AccentColour = normalColour + }, + ticks = new SpinnerTicks + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AccentColour = normalColour + }, + } + }, + centre = new SpinnerCentreLayer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + drawableSpinner.RotationTracker.Complete.BindValueChanged(complete => updateComplete(complete.NewValue, 200)); + drawableSpinner.ApplyCustomUpdateState += updateStateTransforms; + + updateStateTransforms(drawableSpinner, drawableSpinner.State.Value); + } + + protected override void Update() + { + base.Update(); + + if (drawableSpinner.RotationTracker.Complete.Value) + { + if (checkNewRotationCount) + { + fill.FinishTransforms(false, nameof(Alpha)); + fill + .FadeTo(tracking_alpha + 0.2f, 60, Easing.OutExpo) + .Then() + .FadeTo(tracking_alpha, 250, Easing.OutQuint); + } + } + else + { + fill.Alpha = (float)Interpolation.Damp(fill.Alpha, drawableSpinner.RotationTracker.Tracking ? tracking_alpha : idle_alpha, 0.98f, (float)Math.Abs(Clock.ElapsedFrameTime)); + } + + const float initial_fill_scale = 0.2f; + float targetScale = initial_fill_scale + (1 - initial_fill_scale) * drawableSpinner.Progress; + + fill.Scale = new Vector2((float)Interpolation.Lerp(fill.Scale.X, targetScale, Math.Clamp(Math.Abs(Time.Elapsed) / 100, 0, 1))); + mainContainer.Rotation = drawableSpinner.RotationTracker.Rotation; + } + + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + if (!(drawableHitObject is DrawableSpinner)) + return; + + Spinner spinner = drawableSpinner.HitObject; + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + { + this.ScaleTo(initial_scale); + this.RotateTo(0); + + using (BeginDelayedSequence(spinner.TimePreempt / 2, true)) + { + // constant ambient rotation to give the spinner "spinning" character. + this.RotateTo((float)(25 * spinner.Duration / 2000), spinner.TimePreempt + spinner.Duration); + } + + using (BeginDelayedSequence(spinner.TimePreempt + spinner.Duration + drawableHitObject.Result.TimeOffset, true)) + { + switch (state) + { + case ArmedState.Hit: + this.ScaleTo(initial_scale * 1.2f, 320, Easing.Out); + this.RotateTo(mainContainer.Rotation + 180, 320); + break; + + case ArmedState.Miss: + this.ScaleTo(initial_scale * 0.8f, 320, Easing.In); + break; + } + } + } + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + { + centre.ScaleTo(0); + mainContainer.ScaleTo(0); + + using (BeginDelayedSequence(spinner.TimePreempt / 2, true)) + { + centre.ScaleTo(0.3f, spinner.TimePreempt / 4, Easing.OutQuint); + mainContainer.ScaleTo(0.2f, spinner.TimePreempt / 4, Easing.OutQuint); + + using (BeginDelayedSequence(spinner.TimePreempt / 2, true)) + { + centre.ScaleTo(0.5f, spinner.TimePreempt / 2, Easing.OutQuint); + mainContainer.ScaleTo(1, spinner.TimePreempt / 2, Easing.OutQuint); + } + } + } + + // transforms we have from completing the spinner will be rolled back, so reapply immediately. + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + updateComplete(state == ArmedState.Hit, 0); + } + + private void updateComplete(bool complete, double duration) + { + var colour = complete ? completeColour : normalColour; + + ticks.FadeAccent(colour.Darken(1), duration); + fill.FadeAccent(colour.Darken(1), duration); + + background.FadeAccent(colour, duration); + centre.FadeAccent(colour, duration); + } + + private bool checkNewRotationCount + { + get + { + int rotations = (int)(drawableSpinner.RotationTracker.RateAdjustedRotation / 360); + + if (wholeRotationCount == rotations) return false; + + wholeRotationCount = rotations; + return true; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSpinner != null) + drawableSpinner.ApplyCustomUpdateState -= updateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DrawableSliderPath.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DrawableSliderPath.cs new file mode 100644 index 0000000000..c31d6beb01 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/DrawableSliderPath.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 osu.Framework.Graphics.Lines; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces +{ + public abstract class DrawableSliderPath : SmoothPath + { + protected const float BORDER_PORTION = 0.128f; + protected const float GRADIENT_PORTION = 1 - BORDER_PORTION; + + private const float border_max_size = 8f; + private const float border_min_size = 0f; + + private Color4 borderColour = Color4.White; + + public Color4 BorderColour + { + get => borderColour; + set + { + if (borderColour == value) + return; + + borderColour = value; + + InvalidateTexture(); + } + } + + private Color4 accentColour = Color4.White; + + public Color4 AccentColour + { + get => accentColour; + set + { + if (accentColour == value) + return; + + accentColour = value; + + InvalidateTexture(); + } + } + + private float borderSize = 1; + + public float BorderSize + { + get => borderSize; + set + { + if (borderSize == value) + return; + + if (value < border_min_size || value > border_max_size) + return; + + borderSize = value; + + InvalidateTexture(); + } + } + + protected float CalculatedBorderPortion => BorderSize * BORDER_PORTION; + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs index 8ff16f8b84..6381ddca69 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ExplodePiece.cs @@ -3,7 +3,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Skinning; using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces @@ -17,15 +16,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Anchor = Anchor.Centre; Origin = Anchor.Centre; - Blending = BlendingMode.Additive; + Blending = BlendingParameters.Additive; Alpha = 0; - Child = new SkinnableDrawable("Play/osu/hitcircle-explode", _ => new TrianglesPiece + Child = new TrianglesPiece { - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, RelativeSizeAxes = Axes.Both, Alpha = 0.2f, - }, s => s.GetTexture("Play/osu/hitcircle") == null); + }; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/FlashPiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/FlashPiece.cs index c22073f56c..038a2299e9 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/FlashPiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/FlashPiece.cs @@ -5,7 +5,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osuTK; using osu.Framework.Graphics.Shapes; -using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { @@ -18,10 +17,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Anchor = Anchor.Centre; Origin = Anchor.Centre; - Blending = BlendingMode.Additive; + Blending = BlendingParameters.Additive; Alpha = 0; - Child = new SkinnableDrawable("Play/osu/hitcircle-flash", name => new CircularContainer + Child = new CircularContainer { Masking = true, RelativeSizeAxes = Axes.Both, @@ -29,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { RelativeSizeAxes = Axes.Both } - }, s => s.GetTexture("Play/osu/hitcircle") == null); + }; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/GlowPiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/GlowPiece.cs index 917695c790..30937313fd 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/GlowPiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/GlowPiece.cs @@ -6,7 +6,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; -using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { @@ -22,14 +21,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces [BackgroundDependencyLoader] private void load(TextureStore textures) { - Child = new SkinnableDrawable("Play/osu/ring-glow", name => new Sprite + Child = new Sprite { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Texture = textures.Get(name), - Blending = BlendingMode.Additive, + Texture = textures.Get("Gameplay/osu/ring-glow"), + Blending = BlendingParameters.Additive, Alpha = 0.5f - }, s => s.GetTexture("Play/osu/hitcircle") == null); + }; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs new file mode 100644 index 0000000000..98432eb4fe --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces +{ + public class MainCirclePiece : CompositeDrawable + { + private readonly CirclePiece circle; + private readonly RingPiece ring; + private readonly FlashPiece flash; + private readonly ExplodePiece explode; + private readonly NumberPiece number; + private readonly GlowPiece glow; + + public MainCirclePiece() + { + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + glow = new GlowPiece(), + circle = new CirclePiece(), + number = new NumberPiece(), + ring = new RingPiece(), + flash = new FlashPiece(), + explode = new ExplodePiece(), + }; + } + + private readonly IBindable state = new Bindable(); + private readonly IBindable accentColour = new Bindable(); + private readonly IBindable indexInCurrentCombo = new Bindable(); + + [Resolved] + private DrawableHitObject drawableObject { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + var drawableOsuObject = (DrawableOsuHitObject)drawableObject; + + state.BindTo(drawableObject.State); + state.BindValueChanged(updateState, true); + + accentColour.BindTo(drawableObject.AccentColour); + accentColour.BindValueChanged(colour => + { + explode.Colour = colour.NewValue; + glow.Colour = colour.NewValue; + circle.Colour = colour.NewValue; + }, true); + + indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); + indexInCurrentCombo.BindValueChanged(index => number.Text = (index.NewValue + 1).ToString(), true); + } + + private void updateState(ValueChangedEvent state) + { + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime, true)) + { + glow.FadeOut(400); + + switch (state.NewValue) + { + case ArmedState.Hit: + const double flash_in = 40; + const double flash_out = 100; + + flash.FadeTo(0.8f, flash_in) + .Then() + .FadeOut(flash_out); + + explode.FadeIn(flash_in); + this.ScaleTo(1.5f, 400, Easing.OutQuad); + + using (BeginDelayedSequence(flash_in, true)) + { + // after the flash, we can hide some elements that were behind it + ring.FadeOut(); + circle.FadeOut(); + number.FadeOut(); + + this.FadeOut(800); + } + + break; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/NumberPiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/NumberPiece.cs index 84034d3ee9..7c94568835 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/NumberPiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/NumberPiece.cs @@ -7,7 +7,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Game.Graphics.Sprites; using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Skinning; @@ -30,23 +29,21 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Children = new Drawable[] { - new SkinnableDrawable("Play/osu/number-glow", name => new CircularContainer + new Container { Masking = true, - Origin = Anchor.Centre, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, Radius = 60, Colour = Color4.White.Opacity(0.5f), }, - Child = new Box() - }, s => s.GetTexture("Play/osu/hitcircle") == null), - number = new SkinnableSpriteText("Play/osu/number-text", _ => new OsuSpriteText + }, + number = new SkinnableSpriteText(new OsuSkinComponent(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText { Font = OsuFont.Numeric.With(size: 40), UseFullGlyphHeight = false, - }, restrictSize: false) + }, confineMode: ConfineMode.NoScaling) { Text = @"1" } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/PlaySliderBody.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/PlaySliderBody.cs new file mode 100644 index 0000000000..29dff53f54 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/PlaySliderBody.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Rulesets.Osu.Skinning; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces +{ + public abstract class PlaySliderBody : SnakingSliderBody + { + private IBindable scaleBindable; + private IBindable pathVersion; + private IBindable accentColour; + + [Resolved(CanBeNull = true)] + private OsuRulesetConfigManager config { get; set; } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, DrawableHitObject drawableObject) + { + var drawableSlider = (DrawableSlider)drawableObject; + + scaleBindable = drawableSlider.ScaleBindable.GetBoundCopy(); + scaleBindable.BindValueChanged(scale => PathRadius = OsuHitObject.OBJECT_RADIUS * scale.NewValue, true); + + pathVersion = drawableSlider.PathVersion.GetBoundCopy(); + pathVersion.BindValueChanged(_ => Refresh()); + + accentColour = drawableObject.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(accent => updateAccentColour(skin, accent.NewValue), true); + + config?.BindWith(OsuRulesetSetting.SnakingInSliders, SnakingIn); + config?.BindWith(OsuRulesetSetting.SnakingOutSliders, SnakingOut); + + BorderSize = skin.GetConfig(OsuSkinConfiguration.SliderBorderSize)?.Value ?? 1; + BorderColour = skin.GetConfig(OsuSkinColour.SliderBorder)?.Value ?? Color4.White; + } + + private void updateAccentColour(ISkinSource skin, Color4 defaultAccentColour) + => AccentColour = skin.GetConfig(OsuSkinColour.SliderTrackOverride)?.Value ?? defaultAccentColour; + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ReverseArrowPiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ReverseArrowPiece.cs new file mode 100644 index 0000000000..ae43006e76 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/ReverseArrowPiece.cs @@ -0,0 +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 osu.Framework.Audio.Track; +using osu.Framework.Graphics; +using osuTK; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Skinning; +using osu.Framework.Allocation; +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces +{ + public class ReverseArrowPiece : BeatSyncedContainer + { + [Resolved] + private DrawableHitObject drawableRepeat { get; set; } + + public ReverseArrowPiece() + { + Divisor = 2; + MinimumBeatLength = 200; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + + Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.ReverseArrow), _ => new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(0.35f) + }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!drawableRepeat.IsHit) + Child.ScaleTo(1.3f).ScaleTo(1f, timingPoint.BeatLength, Easing.Out); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/RingPiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/RingPiece.cs index 575f2c92c5..619fea73bc 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/RingPiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/RingPiece.cs @@ -6,11 +6,10 @@ using osu.Framework.Graphics.Containers; using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Game.Skinning; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class RingPiece : Container + public class RingPiece : CircularContainer { public RingPiece() { @@ -19,23 +18,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Anchor = Anchor.Centre; Origin = Anchor.Centre; - InternalChild = new SkinnableDrawable("Play/osu/hitcircleoverlay", _ => new Container + Masking = true; + BorderThickness = 9; // roughly matches slider borders and makes stacked circles distinctly visible from each other. + BorderColour = Color4.White; + + Child = new Box { - Masking = true, - CornerRadius = Size.X / 2, - BorderThickness = 10, - BorderColour = Color4.White, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - AlwaysPresent = true, - Alpha = 0, - RelativeSizeAxes = Axes.Both - } - } - }); + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + }; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs index 9ba8ad3474..c5bf790377 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs @@ -3,104 +3,60 @@ using System; 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.Framework.Input; using osu.Framework.Input.Events; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Skinning; using osuTK.Graphics; using osu.Game.Skinning; using osuTK; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class SliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition + public class SliderBall : CircularContainer, ISliderProgress, IRequireHighFrequencyMousePosition, IHasAccentColour { - private Color4 accentColour = Color4.Black; - public Func GetInitialHitAction; - /// - /// The colour that is used for the slider ball. - /// public Color4 AccentColour { - get => accentColour; - set - { - accentColour = value; - if (drawableBall != null) - drawableBall.Colour = value; - } + get => ball.Colour; + set => ball.Colour = value; } - private readonly Slider slider; - public readonly Drawable FollowCircle; - private Drawable drawableBall; + private readonly Drawable followCircle; private readonly DrawableSlider drawableSlider; + private readonly Drawable ball; - public SliderBall(Slider slider, DrawableSlider drawableSlider = null) + public SliderBall(DrawableSlider drawableSlider) { this.drawableSlider = drawableSlider; - this.slider = slider; - Masking = true; - AutoSizeAxes = Axes.Both; - Blending = BlendingMode.Additive; + Origin = Anchor.Centre; + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Children = new[] { - FollowCircle = new Container + followCircle = new FollowCircleContainer { Origin = Anchor.Centre, Anchor = Anchor.Centre, - Width = OsuHitObject.OBJECT_RADIUS * 2, - Height = OsuHitObject.OBJECT_RADIUS * 2, + RelativeSizeAxes = Axes.Both, Alpha = 0, - Child = new SkinnableDrawable("Play/osu/sliderfollowcircle", _ => new CircularContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = 5, - BorderColour = Color4.Orange, - Blending = BlendingMode.Additive, - Child = new Box - { - Colour = Color4.Orange, - RelativeSizeAxes = Axes.Both, - Alpha = 0.2f, - } - }), + Child = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderFollowCircle), _ => new DefaultFollowCircle()), }, - new CircularContainer + ball = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SliderBall), _ => new DefaultSliderBall()) { - Masking = true, - AutoSizeAxes = Axes.Both, - Origin = Anchor.Centre, Anchor = Anchor.Centre, - Alpha = 1, - Child = new Container - { - Width = OsuHitObject.OBJECT_RADIUS * 2, - Height = OsuHitObject.OBJECT_RADIUS * 2, - // TODO: support skin filename animation (sliderb0, sliderb1...) - Child = new SkinnableDrawable("Play/osu/sliderb", _ => new CircularContainer - { - Masking = true, - RelativeSizeAxes = Axes.Both, - BorderThickness = 10, - BorderColour = Color4.White, - Alpha = 1, - Child = drawableBall = new Box - { - Colour = AccentColour, - RelativeSizeAxes = Axes.Both, - Alpha = 0.4f, - } - }), - } - } + Origin = Anchor.Centre, + }, }; } @@ -137,8 +93,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces tracking = value; - FollowCircle.ScaleTo(tracking ? 2f : 1, 300, Easing.OutQuint); - FollowCircle.FadeTo(tracking ? 1f : 0, 300, Easing.OutQuint); + followCircle.ScaleTo(tracking ? 2.4f : 1f, 300, Easing.OutQuint); + followCircle.FadeTo(tracking ? 1f : 0, 300, Easing.OutQuint); } } @@ -175,7 +131,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces if (headCircleHitAction == null) timeToAcceptAnyKeyAfter = null; - var actions = drawableSlider?.OsuActionInputManager?.PressedActions; + var actions = drawableSlider.OsuActionInputManager?.PressedActions; // if the head circle was hit with a specific key, tracking should only occur while that key is pressed. if (headCircleHitAction != null && timeToAcceptAnyKeyAfter == null) @@ -189,9 +145,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Tracking = // in valid time range - Time.Current >= slider.StartTime && Time.Current < slider.EndTime && + Time.Current >= drawableSlider.HitObject.StartTime && Time.Current < drawableSlider.HitObject.EndTime && // in valid position range - lastScreenSpaceMousePosition.HasValue && base.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && + lastScreenSpaceMousePosition.HasValue && followCircle.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && // valid action (actions?.Any(isValidTrackingAction) ?? false); } @@ -210,9 +166,89 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces return action == OsuAction.LeftButton || action == OsuAction.RightButton; } + private Vector2? lastPosition; + public void UpdateProgress(double completionProgress) { - Position = slider.CurvePositionAt(completionProgress); + var newPos = drawableSlider.HitObject.CurvePositionAt(completionProgress); + + var diff = lastPosition.HasValue ? lastPosition.Value - newPos : newPos - drawableSlider.HitObject.CurvePositionAt(completionProgress + 0.01f); + if (diff == Vector2.Zero) + return; + + Position = newPos; + ball.Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI); + + lastPosition = newPos; + } + + private class FollowCircleContainer : CircularContainer + { + public override bool HandlePositionalInput => true; + } + + public class DefaultFollowCircle : CompositeDrawable + { + public DefaultFollowCircle() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = 5, + BorderColour = Color4.Orange, + Blending = BlendingParameters.Additive, + Child = new Box + { + Colour = Color4.Orange, + RelativeSizeAxes = Axes.Both, + Alpha = 0.2f, + } + }; + } + } + + public class DefaultSliderBall : CompositeDrawable + { + private Box box; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableObject, ISkinSource skin) + { + var slider = (DrawableSlider)drawableObject; + + RelativeSizeAxes = Axes.Both; + + float radius = skin.GetConfig(OsuSkinConfiguration.SliderPathRadius)?.Value ?? OsuHitObject.OBJECT_RADIUS; + + InternalChild = new CircularContainer + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(radius / OsuHitObject.OBJECT_RADIUS), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + BorderThickness = 10, + BorderColour = Color4.White, + Alpha = 1, + Child = box = new Box + { + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + AlwaysPresent = true, + Alpha = 0 + } + }; + + slider.Tracking.BindValueChanged(trackingChanged, true); + } + + private void trackingChanged(ValueChangedEvent tracking) => + box.FadeTo(tracking.NewValue ? 0.6f : 0.05f, 200, Easing.OutQuint); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBody.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBody.cs index 6bc19ee3b5..8758a4a066 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBody.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBody.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Lines; using osuTK; @@ -12,13 +13,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { public abstract class SliderBody : CompositeDrawable { - public const float DEFAULT_BORDER_SIZE = 1; - - private SliderPath path; + private DrawableSliderPath path; protected Path Path => path; - public float PathRadius + public virtual float PathRadius { get => path.PathRadius; set => path.PathRadius = value; @@ -80,19 +79,19 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces } /// - /// Initialises a new , releasing all resources retained by the old one. + /// Initialises a new , releasing all resources retained by the old one. /// public virtual void RecyclePath() { - InternalChild = path = new SliderPath + InternalChild = path = CreateSliderPath().With(p => { - Position = path?.Position ?? Vector2.Zero, - PathRadius = path?.PathRadius ?? 10, - AccentColour = path?.AccentColour ?? Color4.White, - BorderColour = path?.BorderColour ?? Color4.White, - BorderSize = path?.BorderSize ?? DEFAULT_BORDER_SIZE, - Vertices = path?.Vertices ?? Array.Empty() - }; + p.Position = path?.Position ?? Vector2.Zero; + p.PathRadius = path?.PathRadius ?? 10; + p.AccentColour = path?.AccentColour ?? Color4.White; + p.BorderColour = path?.BorderColour ?? Color4.White; + p.BorderSize = path?.BorderSize ?? 1; + p.Vertices = path?.Vertices ?? Array.Empty(); + }); } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => path.ReceivePositionalInputAt(screenSpacePos); @@ -103,77 +102,20 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces /// The vertices protected void SetVertices(IReadOnlyList vertices) => path.Vertices = vertices; - private class SliderPath : SmoothPath + protected virtual DrawableSliderPath CreateSliderPath() => new DefaultDrawableSliderPath(); + + private class DefaultDrawableSliderPath : DrawableSliderPath { - private const float border_max_size = 8f; - private const float border_min_size = 0f; - - private const float border_portion = 0.128f; - private const float gradient_portion = 1 - border_portion; - private const float opacity_at_centre = 0.3f; private const float opacity_at_edge = 0.8f; - private Color4 borderColour = Color4.White; - - public Color4 BorderColour - { - get => borderColour; - set - { - if (borderColour == value) - return; - - borderColour = value; - - InvalidateTexture(); - } - } - - private Color4 accentColour = Color4.White; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - InvalidateTexture(); - } - } - - private float borderSize = DEFAULT_BORDER_SIZE; - - public float BorderSize - { - get => borderSize; - set - { - if (borderSize == value) - return; - - if (value < border_min_size || value > border_max_size) - return; - - borderSize = value; - - InvalidateTexture(); - } - } - - private float calculatedBorderPortion => BorderSize * border_portion; - protected override Color4 ColourAt(float position) { - if (calculatedBorderPortion != 0f && position <= calculatedBorderPortion) + if (CalculatedBorderPortion != 0f && position <= CalculatedBorderPortion) return BorderColour; - position -= calculatedBorderPortion; - return new Color4(AccentColour.R, AccentColour.G, AccentColour.B, (opacity_at_edge - (opacity_at_edge - opacity_at_centre) * position / gradient_portion) * AccentColour.A); + position -= CalculatedBorderPortion; + return new Color4(AccentColour.R, AccentColour.G, AccentColour.B, (opacity_at_edge - (opacity_at_edge - opacity_at_centre) * position / GRADIENT_PORTION) * AccentColour.A); } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SnakingSliderBody.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SnakingSliderBody.cs index a3d3893c8b..8835a0d84a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SnakingSliderBody.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SnakingSliderBody.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -24,6 +25,20 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces public double? SnakedStart { get; private set; } public double? SnakedEnd { get; private set; } + public override float PathRadius + { + get => base.PathRadius; + set + { + if (base.PathRadius == value) + return; + + base.PathRadius = value; + + Refresh(); + } + } + public override Vector2 PathOffset => snakedPathOffset; /// @@ -36,26 +51,28 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces /// private Vector2 snakedPathOffset; - private readonly Slider slider; - - public SnakingSliderBody(Slider slider) - { - this.slider = slider; - } + private DrawableSlider drawableSlider; [BackgroundDependencyLoader] - private void load() + private void load(DrawableHitObject drawableObject) { + drawableSlider = (DrawableSlider)drawableObject; + Refresh(); } public void UpdateProgress(double completionProgress) { + if (drawableSlider?.HitObject == null) + return; + + Slider slider = drawableSlider.HitObject; + var span = slider.SpanAt(completionProgress); var spanProgress = slider.ProgressAt(completionProgress); double start = 0; - double end = SnakingIn.Value ? MathHelper.Clamp((Time.Current - (slider.StartTime - slider.TimePreempt)) / slider.TimeFadeIn, 0, 1) : 1; + double end = SnakingIn.Value ? Math.Clamp((Time.Current - (slider.StartTime - slider.TimePreempt)) / (slider.TimePreempt / 3), 0, 1) : 1; if (span >= slider.SpanCount() - 1) { @@ -75,8 +92,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces public void Refresh() { + if (drawableSlider?.HitObject == null) + return; + // Generate the entire curve - slider.Path.GetPathToProgress(CurrentCurve, 0, 1); + drawableSlider.HitObject.Path.GetPathToProgress(CurrentCurve, 0, 1); SetVertices(CurrentCurve); // Force the body to be the final path size to avoid excessive autosize computations @@ -113,14 +133,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces private void setRange(double p0, double p1) { if (p0 > p1) - MathHelper.Swap(ref p0, ref p1); + (p0, p1) = (p1, p0); if (SnakedStart == p0 && SnakedEnd == p1) return; SnakedStart = p0; SnakedEnd = p1; - slider.Path.GetPathToProgress(CurrentCurve, p0, p1); + drawableSlider.HitObject.Path.GetPathToProgress(CurrentCurve, p0, p1); SetVertices(CurrentCurve); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBonusDisplay.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBonusDisplay.cs new file mode 100644 index 0000000000..f483bb1b26 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBonusDisplay.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 osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces +{ + /// + /// 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/Objects/Drawables/Pieces/SpinnerDisc.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerDisc.cs deleted file mode 100644 index 448a2eada7..0000000000 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerDisc.cs +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces -{ - public class SpinnerDisc : CircularContainer, IHasAccentColour - { - private readonly Spinner spinner; - - public Color4 AccentColour - { - get => background.AccentColour; - set => background.AccentColour = value; - } - - private readonly SpinnerBackground background; - - private const float idle_alpha = 0.2f; - private const float tracking_alpha = 0.4f; - - public override bool IsPresent => true; // handle input when hidden - - public SpinnerDisc(Spinner s) - { - spinner = s; - - RelativeSizeAxes = Axes.Both; - - Children = new Drawable[] - { - background = new SpinnerBackground { Alpha = idle_alpha }, - }; - } - - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - - private bool tracking; - - public bool Tracking - { - get => tracking; - set - { - if (value == tracking) return; - - tracking = value; - - background.FadeTo(tracking ? tracking_alpha : idle_alpha, 100); - } - } - - private bool complete; - - public bool Complete - { - get => complete; - set - { - if (value == complete) return; - - complete = value; - - updateCompleteTick(); - } - } - - protected override bool OnMouseMove(MouseMoveEvent e) - { - mousePosition = Parent.ToLocalSpace(e.ScreenSpaceMousePosition); - return base.OnMouseMove(e); - } - - private Vector2 mousePosition; - - private float lastAngle; - private float currentRotation; - public float RotationAbsolute; - private int completeTick; - - private bool updateCompleteTick() => completeTick != (completeTick = (int)(RotationAbsolute / 360)); - - private bool rotationTransferred; - - protected override void Update() - { - base.Update(); - - var thisAngle = -(float)MathHelper.RadiansToDegrees(Math.Atan2(mousePosition.X - DrawSize.X / 2, mousePosition.Y - DrawSize.Y / 2)); - - bool validAndTracking = tracking && spinner.StartTime <= Time.Current && spinner.EndTime > Time.Current; - - if (validAndTracking) - { - if (!rotationTransferred) - { - currentRotation = Rotation * 2; - rotationTransferred = true; - } - - if (thisAngle - lastAngle > 180) - lastAngle += 360; - else if (lastAngle - thisAngle > 180) - lastAngle -= 360; - - currentRotation += thisAngle - lastAngle; - RotationAbsolute += Math.Abs(thisAngle - lastAngle); - } - - lastAngle = thisAngle; - - if (Complete && updateCompleteTick()) - { - background.FinishTransforms(false, nameof(Alpha)); - background - .FadeTo(tracking_alpha + 0.2f, 60, Easing.OutExpo) - .Then() - .FadeTo(tracking_alpha, 250, Easing.OutQuint); - } - - this.RotateTo(currentRotation / 2, validAndTracking ? 500 : 1500, Easing.OutExpo); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBackground.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerFill.cs similarity index 85% rename from osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBackground.cs rename to osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerFill.cs index 77228e28af..043bc5618c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerBackground.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerFill.cs @@ -1,18 +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 osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class SpinnerBackground : CircularContainer, IHasAccentColour + public class SpinnerFill : CircularContainer, IHasAccentColour { - protected Box Disc; + public readonly Box Disc; public Color4 AccentColour { @@ -31,11 +31,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces } } - public SpinnerBackground() + public SpinnerFill() { RelativeSizeAxes = Axes.Both; Masking = true; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Children = new Drawable[] { Disc = new Box diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerRotationTracker.cs new file mode 100644 index 0000000000..910899c307 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerRotationTracker.cs @@ -0,0 +1,137 @@ +// 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.Input.Events; +using osu.Framework.Utils; +using osu.Game.Screens.Play; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces +{ + public class SpinnerRotationTracker : CircularContainer + { + public override bool IsPresent => true; // handle input when hidden + + private readonly DrawableSpinner drawableSpinner; + + public SpinnerRotationTracker(DrawableSpinner drawableSpinner) + { + this.drawableSpinner = drawableSpinner; + + RelativeSizeAxes = Axes.Both; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + public bool Tracking { get; set; } + + public readonly BindableBool Complete = new BindableBool(); + + /// + /// The total rotation performed on the spinner disc, disregarding the spin direction, + /// adjusted for the track's playback rate. + /// + /// + /// + /// This value is always non-negative and is monotonically increasing with time + /// (i.e. will only increase if time is passing forward, but can decrease during rewind). + /// + /// + /// The rotation from each frame is multiplied by the clock's current playback rate. + /// The reason this is done is to ensure that spinners give the same score and require the same number of spins + /// regardless of whether speed-modifying mods are applied. + /// + /// + /// + /// Assuming no speed-modifying mods are active, + /// if the spinner is spun 360 degrees clockwise and then 360 degrees counter-clockwise, + /// this property will return the value of 720 (as opposed to 0 for ). + /// If Double Time is active instead (with a speed multiplier of 1.5x), + /// in the same scenario the property will return 720 * 1.5 = 1080. + /// + public float RateAdjustedRotation { get; private set; } + + /// + /// Whether the spinning is spinning at a reasonable speed to be considered visually spinning. + /// + public readonly BindableBool IsSpinning = new BindableBool(); + + /// + /// Whether currently in the correct time range to allow spinning. + /// + private bool isSpinnableTime => drawableSpinner.HitObject.StartTime <= Time.Current && drawableSpinner.HitObject.EndTime > Time.Current; + + protected override bool OnMouseMove(MouseMoveEvent e) + { + mousePosition = Parent.ToLocalSpace(e.ScreenSpaceMousePosition); + return base.OnMouseMove(e); + } + + private Vector2 mousePosition; + + private float lastAngle; + private float currentRotation; + + private bool rotationTransferred; + + [Resolved(canBeNull: true)] + private GameplayClock gameplayClock { get; set; } + + protected override void Update() + { + base.Update(); + var thisAngle = -MathUtils.RadiansToDegrees(MathF.Atan2(mousePosition.X - DrawSize.X / 2, mousePosition.Y - DrawSize.Y / 2)); + + var delta = thisAngle - lastAngle; + + if (Tracking) + AddRotation(delta); + + lastAngle = thisAngle; + + IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation / 2 - Rotation) > 5f; + + Rotation = (float)Interpolation.Damp(Rotation, currentRotation / 2, 0.99, Math.Abs(Time.Elapsed)); + } + + /// + /// Rotate the disc by the provided angle (in addition to any existing rotation). + /// + /// + /// Will be a no-op if not a valid time to spin. + /// + /// The delta angle. + public void AddRotation(float angle) + { + if (!isSpinnableTime) + return; + + if (!rotationTransferred) + { + currentRotation = Rotation * 2; + rotationTransferred = true; + } + + if (angle > 180) + { + lastAngle += 360; + angle -= 360; + } + else if (-angle > 180) + { + lastAngle -= 360; + angle += 360; + } + + currentRotation += angle; + // rate has to be applied each frame, because it's not guaranteed to be constant throughout playback + // (see: ModTimeRamp) + RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.TrueGameplayRate ?? Clock.Rate)); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerSpmCounter.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerSpmCounter.cs index b1d90c49f6..80ab03c45c 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerSpmCounter.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerSpmCounter.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -62,6 +63,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces public void SetRotation(float currentRotation) { + // Never calculate SPM by same time of record to avoid 0 / 0 = NaN or X / 0 = Infinity result. + if (Precision.AlmostEquals(0, Time.Elapsed)) + return; + // If we've gone back in time, it's fine to work with a fresh set of records for now if (records.Count > 0 && Time.Current < records.Last().Time) records.Clear(); @@ -71,6 +76,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces var record = records.Peek(); 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; } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs index 9219fab830..ba7e8eae6f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SpinnerTicks.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.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -9,10 +10,11 @@ using osu.Framework.Graphics.Effects; using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class SpinnerTicks : Container + public class SpinnerTicks : Container, IHasAccentColour { public SpinnerTicks() { @@ -20,30 +22,24 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Anchor = Anchor.Centre; RelativeSizeAxes = Axes.Both; - const int count = 18; + const float count = 8; - for (int i = 0; i < count; i++) + for (float i = 0; i < count; i++) { Add(new Container { - Colour = Color4.Black, Alpha = 0.4f, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Radius = 10, - Colour = Color4.Gray.Opacity(0.2f), - }, + Blending = BlendingParameters.Additive, RelativePositionAxes = Axes.Both, Masking = true, CornerRadius = 5, Size = new Vector2(60, 10), Origin = Anchor.Centre, Position = new Vector2( - 0.5f + (float)Math.Sin((float)i / count * 2 * MathHelper.Pi) / 2 * 0.86f, - 0.5f + (float)Math.Cos((float)i / count * 2 * MathHelper.Pi) / 2 * 0.86f + 0.5f + MathF.Sin(i / count * 2 * MathF.PI) / 2 * 0.83f, + 0.5f + MathF.Cos(i / count * 2 * MathF.PI) / 2 * 0.83f ), - Rotation = -(float)i / count * 360 + 90, + Rotation = -i / count * 360 + 90, Children = new[] { new Box @@ -54,5 +50,25 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces }); } } + + public Color4 AccentColour + { + get => Colour; + set + { + Colour = value; + + foreach (var c in Children.OfType()) + { + c.EdgeEffect = + new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 20, + Colour = value.Opacity(0.8f), + }; + } + } + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs index 0e29a1dcd8..6cdb0d3df3 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/TrianglesPiece.cs @@ -11,7 +11,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces protected override bool CreateNewTriangles => false; protected override float SpawnRatio => 0.5f; - public TrianglesPiece() + public TrianglesPiece(int? seed = null) + : base(seed) { TriangleScale = 1.2f; HideAlphaDiscrepancies = false; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs new file mode 100644 index 0000000000..02dc770285 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.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.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public class SkinnableLighting : SkinnableSprite + { + private DrawableHitObject targetObject; + private JudgementResult targetResult; + + public SkinnableLighting() + : base("lighting") + { + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + updateColour(); + } + + /// + /// Updates the lighting colour from a given hitobject and result. + /// + /// The that's been judged. + /// The that was judged with. + public void SetColourFrom(DrawableHitObject targetObject, JudgementResult targetResult) + { + this.targetObject = targetObject; + this.targetResult = targetResult; + + updateColour(); + } + + private void updateColour() + { + if (targetObject == null || targetResult == null) + Colour = Color4.White; + else + Colour = targetResult.IsHit ? targetObject.AccentColour.Value : Color4.Transparent; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerBackgroundLayer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerBackgroundLayer.cs new file mode 100644 index 0000000000..3cd2454706 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerBackgroundLayer.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.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public class SpinnerBackgroundLayer : SpinnerFill + { + [BackgroundDependencyLoader] + private void load(OsuColour colours, DrawableHitObject drawableHitObject) + { + Disc.Alpha = 0; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerCentreLayer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerCentreLayer.cs new file mode 100644 index 0000000000..b62ce822f0 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SpinnerCentreLayer.cs @@ -0,0 +1,71 @@ +// 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.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Objects.Drawables +{ + public class SpinnerCentreLayer : CompositeDrawable, IHasAccentColour + { + private DrawableSpinner spinner; + + private CirclePiece circle; + private GlowPiece glow; + private SpriteIcon symbol; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject) + { + spinner = (DrawableSpinner)drawableHitObject; + + InternalChildren = new Drawable[] + { + glow = new GlowPiece(), + circle = new CirclePiece + { + Position = Vector2.Zero, + Anchor = Anchor.Centre, + }, + new RingPiece(), + symbol = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(48), + Icon = FontAwesome.Solid.Asterisk, + Shadow = false, + }, + }; + } + + protected override void Update() + { + base.Update(); + symbol.Rotation = (float)Interpolation.Lerp(symbol.Rotation, spinner.RotationTracker.Rotation / 2, Math.Clamp(Math.Abs(Time.Elapsed) / 40, 0, 1)); + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + + circle.Colour = accentColour; + glow.Colour = accentColour; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index d1221fd2d3..15af141c99 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -1,19 +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 osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osuTK; using osu.Game.Rulesets.Objects.Types; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects { public abstract class OsuHitObject : HitObject, IHasComboInformation, IHasPosition { + /// + /// The radius of hit objects (ie. the radius of a ). + /// public const float OBJECT_RADIUS = 64; + /// + /// Scoring distance with a speed-adjusted beat length of 1 second (ie. the speed slider balls move through their track). + /// + internal const float BASE_SCORING_DISTANCE = 100; + public double TimePreempt = 600; public double TimeFadeIn = 400; @@ -46,7 +57,7 @@ namespace osu.Game.Rulesets.Osu.Objects public double Radius => OBJECT_RADIUS * Scale; - public readonly Bindable ScaleBindable = new Bindable(1); + public readonly Bindable ScaleBindable = new BindableFloat(1); public float Scale { @@ -56,20 +67,53 @@ namespace osu.Game.Rulesets.Osu.Objects public virtual bool NewCombo { get; set; } - public int ComboOffset { get; set; } + public readonly Bindable ComboOffsetBindable = new Bindable(); - public virtual int IndexInCurrentCombo { get; set; } + public int ComboOffset + { + get => ComboOffsetBindable.Value; + set => ComboOffsetBindable.Value = value; + } - public virtual int ComboIndex { get; set; } + public Bindable IndexInCurrentComboBindable { get; } = new Bindable(); - public bool LastInCombo { get; set; } + public virtual int IndexInCurrentCombo + { + get => IndexInCurrentComboBindable.Value; + set => IndexInCurrentComboBindable.Value = value; + } + + public Bindable ComboIndexBindable { get; } = new Bindable(); + + public virtual int ComboIndex + { + get => ComboIndexBindable.Value; + set => ComboIndexBindable.Value = value; + } + + public Bindable LastInComboBindable { get; } = new Bindable(); + + public bool LastInCombo + { + get => LastInComboBindable.Value; + set => LastInComboBindable.Value = value; + } + + protected OsuHitObject() + { + StackHeightBindable.BindValueChanged(height => + { + foreach (var nested in NestedHitObjects.OfType()) + nested.StackHeight = height.NewValue; + }); + } protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450); - TimeFadeIn = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1200, 800, 300); + TimeFadeIn = 400; // as per osu-stable Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2; } diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitWindows.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitWindows.cs deleted file mode 100644 index add8fd53c7..0000000000 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitWindows.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 System.Collections.Generic; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Osu.Objects -{ - public class OsuHitWindows : HitWindows - { - private static readonly IReadOnlyDictionary base_ranges = new Dictionary - { - { HitResult.Great, (160, 100, 40) }, - { HitResult.Good, (280, 200, 120) }, - { HitResult.Meh, (400, 300, 200) }, - { HitResult.Miss, (400, 400, 400) }, - }; - - public override void SetDifficulty(double difficulty) - { - Great = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Great]); - Good = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Good]); - Meh = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Meh]); - Miss = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Miss]); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Objects/RepeatPoint.cs b/osu.Game.Rulesets.Osu/Objects/RepeatPoint.cs deleted file mode 100644 index 63713541b4..0000000000 --- a/osu.Game.Rulesets.Osu/Objects/RepeatPoint.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 System; -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Osu.Judgements; - -namespace osu.Game.Rulesets.Osu.Objects -{ - public class RepeatPoint : OsuHitObject - { - public int RepeatIndex { get; set; } - public double SpanDuration { get; set; } - - protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) - { - base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - - // Out preempt should be one span early to give the user ample warning. - TimePreempt += SpanDuration; - - // We want to show the first RepeatPoint as the TimePreempt dictates but on short (and possibly fast) sliders - // we may need to cut down this time on following RepeatPoints to only show up to two RepeatPoints at any given time. - if (RepeatIndex > 0) - TimePreempt = Math.Min(SpanDuration * 2, TimePreempt); - } - - public override Judgement CreateJudgement() => new OsuJudgement(); - } -} diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index a4638c31f2..755ce0866a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -6,63 +6,50 @@ using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; using osu.Game.Rulesets.Objects; using System.Linq; -using osu.Framework.Bindables; +using System.Threading; +using Newtonsoft.Json; using osu.Framework.Caching; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects { - public class Slider : OsuHitObject, IHasCurve + public class Slider : OsuHitObject, IHasPathWithRepeats { - /// - /// Scoring distance with a speed-adjusted beat length of 1 second. - /// - private const float base_scoring_distance = 100; - public double EndTime => StartTime + this.SpanCount() * Path.Distance / Velocity; - public double Duration => EndTime - StartTime; - private Cached endPositionCache; + [JsonIgnore] + public double Duration + { + get => EndTime - StartTime; + set => throw new System.NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed. + } + + private readonly Cached endPositionCache = new Cached(); public override Vector2 EndPosition => endPositionCache.IsValid ? endPositionCache.Value : endPositionCache.Value = Position + this.CurvePositionAt(1); public Vector2 StackedPositionAt(double t) => StackedPosition + this.CurvePositionAt(t); - public override int ComboIndex - { - get => base.ComboIndex; - set - { - base.ComboIndex = value; - foreach (var n in NestedHitObjects.OfType()) - n.ComboIndex = value; - } - } - - public override int IndexInCurrentCombo - { - get => base.IndexInCurrentCombo; - set - { - base.IndexInCurrentCombo = value; - foreach (var n in NestedHitObjects.OfType()) - n.IndexInCurrentCombo = value; - } - } - - public readonly Bindable PathBindable = new Bindable(); + private readonly SliderPath path = new SliderPath(); public SliderPath Path { - get => PathBindable.Value; + get => path; set { - PathBindable.Value = value; - endPositionCache.Invalidate(); + path.ControlPoints.Clear(); + path.ExpectedDistance.Value = null; + + if (value != null) + { + path.ControlPoints.AddRange(value.ControlPoints.Select(c => new PathControlPoint(c.Position.Value, c.Type.Value))); + path.ExpectedDistance.Value = value.ExpectedDistance.Value; + } } } @@ -74,14 +61,7 @@ namespace osu.Game.Rulesets.Osu.Objects set { base.Position = value; - - if (HeadCircle != null) - HeadCircle.Position = value; - - if (TailCircle != null) - TailCircle.Position = EndPosition; - - endPositionCache.Invalidate(); + updateNestedPositions(); } } @@ -99,7 +79,7 @@ namespace osu.Game.Rulesets.Osu.Objects /// internal float LazyTravelDistance; - public List> NodeSamples { get; set; } = new List>(); + public List> NodeSamples { get; set; } = new List>(); private int repeatCount; @@ -109,7 +89,7 @@ namespace osu.Game.Rulesets.Osu.Objects set { repeatCount = value; - endPositionCache.Invalidate(); + updateNestedPositions(); } } @@ -134,8 +114,17 @@ namespace osu.Game.Rulesets.Osu.Objects /// public double TickDistanceMultiplier = 1; - public HitCircle HeadCircle; - public SliderTailCircle TailCircle; + [JsonIgnore] + public HitCircle HeadCircle { get; protected set; } + + [JsonIgnore] + public SliderTailCircle TailCircle { get; protected set; } + + public Slider() + { + SamplesBindable.CollectionChanged += (_, __) => updateNestedSamples(); + Path.Version.ValueChanged += _ => updateNestedPositions(); + } protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) { @@ -144,31 +133,23 @@ namespace osu.Game.Rulesets.Osu.Objects TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); DifficultyControlPoint difficultyPoint = controlPointInfo.DifficultyPointAt(StartTime); - double scoringDistance = base_scoring_distance * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; + double scoringDistance = BASE_SCORING_DISTANCE * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; 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. + Samples = this.GetNodeSamples(repeatCount + 1); } - protected override void CreateNestedHitObjects() + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - base.CreateNestedHitObjects(); + base.CreateNestedHitObjects(cancellationToken); foreach (var e in - SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset)) + SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken)) { - var firstSample = Samples.Find(s => s.Name == HitSampleInfo.HIT_NORMAL) - ?? Samples.FirstOrDefault(); // TODO: remove this when guaranteed sort is present for samples (https://github.com/ppy/osu/issues/1933) - var sampleList = new List(); - - if (firstSample != null) - sampleList.Add(new HitSampleInfo - { - Bank = firstSample.Bank, - Volume = firstSample.Volume, - Name = @"slidertick", - }); - switch (e.Type) { case SliderEventType.Tick: @@ -180,19 +161,16 @@ namespace osu.Game.Rulesets.Osu.Objects Position = Position + Path.PositionAt(e.PathProgress), StackHeight = StackHeight, Scale = Scale, - Samples = sampleList }); break; case SliderEventType.Head: - AddNested(HeadCircle = new SliderCircle + AddNested(HeadCircle = new SliderHeadCircle { StartTime = e.Time, Position = Position, - Samples = getNodeSamples(0), + StackHeight = StackHeight, SampleControlPoint = SampleControlPoint, - IndexInCurrentCombo = IndexInCurrentCombo, - ComboIndex = ComboIndex, }); break; @@ -202,32 +180,68 @@ namespace osu.Game.Rulesets.Osu.Objects // if this is to change, we should revisit this. AddNested(TailCircle = new SliderTailCircle(this) { + RepeatIndex = e.SpanIndex, StartTime = e.Time, Position = EndPosition, - IndexInCurrentCombo = IndexInCurrentCombo, - ComboIndex = ComboIndex, + StackHeight = StackHeight }); break; case SliderEventType.Repeat: - AddNested(new RepeatPoint + AddNested(new SliderRepeat(this) { RepeatIndex = e.SpanIndex, - SpanDuration = SpanDuration, StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration, Position = Position + Path.PositionAt(e.PathProgress), StackHeight = StackHeight, Scale = Scale, - Samples = getNodeSamples(e.SpanIndex + 1) }); break; } } + + updateNestedSamples(); } - private List getNodeSamples(int nodeIndex) => - nodeIndex < NodeSamples.Count ? NodeSamples[nodeIndex] : Samples; + private void updateNestedPositions() + { + endPositionCache.Invalidate(); - public override Judgement CreateJudgement() => new OsuJudgement(); + if (HeadCircle != null) + HeadCircle.Position = Position; + + if (TailCircle != null) + TailCircle.Position = EndPosition; + } + + private void updateNestedSamples() + { + var firstSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) + ?? Samples.FirstOrDefault(); // TODO: remove this when guaranteed sort is present for samples (https://github.com/ppy/osu/issues/1933) + var sampleList = new List(); + + if (firstSample != null) + { + sampleList.Add(new HitSampleInfo + { + Bank = firstSample.Bank, + Volume = firstSample.Volume, + Name = @"slidertick", + }); + } + + foreach (var tick in NestedHitObjects.OfType()) + tick.Samples = sampleList; + + foreach (var repeat in NestedHitObjects.OfType()) + repeat.Samples = this.GetNodeSamples(repeat.RepeatIndex + 1); + + if (HeadCircle != null) + HeadCircle.Samples = this.GetNodeSamples(0); + } + + public override Judgement CreateJudgement() => new OsuIgnoreJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs new file mode 100644 index 0000000000..a6aed2c00e --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs @@ -0,0 +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 osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Objects +{ + /// + /// A hit circle which is at the end of a slider path (either repeat or final tail). + /// + public abstract class SliderEndCircle : HitCircle + { + private readonly Slider slider; + + protected SliderEndCircle(Slider slider) + { + this.slider = slider; + } + + public int RepeatIndex { get; set; } + + public double SpanDuration => slider.SpanDuration; + + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + { + base.ApplyDefaultsToSelf(controlPointInfo, difficulty); + + if (RepeatIndex > 0) + { + // Repeat points after the first span should appear behind the still-visible one. + TimeFadeIn = 0; + + // The next end circle should appear exactly after the previous circle (on the same end) is hit. + TimePreempt = SpanDuration * 2; + } + else + { + // taken from osu-stable + const float first_end_circle_preempt_adjust = 2 / 3f; + + // The first end circle should fade in with the slider. + TimePreempt = (StartTime - slider.StartTime) + slider.TimePreempt * first_end_circle_preempt_adjust; + } + } + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.cs new file mode 100644 index 0000000000..f6d46aeef5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/SliderHeadCircle.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.Osu.Objects +{ + public class SliderHeadCircle : HitCircle + { + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.cs new file mode 100644 index 0000000000..cca86361c2 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/SliderRepeat.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.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Objects +{ + public class SliderRepeat : SliderEndCircle + { + public SliderRepeat(Slider slider) + : base(slider) + { + } + + public override Judgement CreateJudgement() => new SliderRepeatJudgement(); + + public class SliderRepeatJudgement : OsuJudgement + { + public override HitResult MaxResult => HitResult.LargeTickHit; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs index 4f2af64161..f9450062f4 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.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 osu.Framework.Bindables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects { @@ -12,16 +12,18 @@ namespace osu.Game.Rulesets.Osu.Objects /// Note that this should not be used for timing correctness. /// See usage in for more information. /// - public class SliderTailCircle : SliderCircle + public class SliderTailCircle : SliderEndCircle { - private readonly IBindable pathBindable = new Bindable(); - public SliderTailCircle(Slider slider) + : base(slider) { - pathBindable.BindTo(slider.PathBindable); - pathBindable.BindValueChanged(_ => Position = slider.EndPosition); } - public override Judgement CreateJudgement() => new OsuSliderTailJudgement(); + public override Judgement CreateJudgement() => new SliderTailJudgement(); + + public class SliderTailJudgement : OsuJudgement + { + public override HitResult MaxResult => HitResult.SmallTickHit; + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs index 85439699dd..a427ee1955 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTick.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTick.cs @@ -5,6 +5,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects { @@ -29,6 +30,13 @@ namespace osu.Game.Rulesets.Osu.Objects TimePreempt = (StartTime - SpanStartTime) / 2 + offset; } - public override Judgement CreateJudgement() => new OsuJudgement(); + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public override Judgement CreateJudgement() => new SliderTickJudgement(); + + public class SliderTickJudgement : OsuJudgement + { + public override HitResult MaxResult => HitResult.LargeTickHit; + } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index 8a2fd3b7aa..194aa640f9 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -1,35 +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.Threading; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects { - public class Spinner : OsuHitObject, IHasEndTime + public class Spinner : OsuHitObject, IHasDuration { - public double EndTime { get; set; } - public double Duration => EndTime - StartTime; + public double EndTime + { + get => StartTime + Duration; + set => Duration = value - StartTime; + } + + public double Duration { get; set; } /// /// Number of spins required to finish the spinner without miss. /// public int SpinsRequired { get; protected set; } = 1; + /// + /// Number of spins available to give bonus, beyond . + /// + public int MaximumBonusSpins { get; protected set; } = 1; + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); - SpinsRequired = (int)(Duration / 1000 * BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 3, 5, 7.5)); - // spinning doesn't match 1:1 with stable, so let's fudge them easier for the time being. - SpinsRequired = (int)Math.Max(1, SpinsRequired * 0.6); + const double stable_matching_fudge = 0.6; + + // close to 477rpm + const double maximum_rotations_per_second = 8; + + double secondsDuration = Duration / 1000; + + double minimumRotationsPerSecond = stable_matching_fudge * BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 3, 5, 7.5); + + SpinsRequired = (int)(secondsDuration * minimumRotationsPerSecond); + MaximumBonusSpins = (int)((maximum_rotations_per_second - minimumRotationsPerSecond) * secondsDuration); + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + base.CreateNestedHitObjects(cancellationToken); + + int totalSpins = MaximumBonusSpins + SpinsRequired; + + for (int i = 0; i < totalSpins; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + double startTime = StartTime + (float)(i + 1) / totalSpins * Duration; + + AddNested(i < SpinsRequired + ? new SpinnerTick { StartTime = startTime } + : new SpinnerBonusTick { StartTime = startTime }); + } } public override Judgement CreateJudgement() => new OsuJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs b/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.cs new file mode 100644 index 0000000000..235dc8710a --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/SpinnerBonusTick.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.Game.Audio; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Objects +{ + public class SpinnerBonusTick : SpinnerTick + { + public SpinnerBonusTick() + { + Samples.Add(new HitSampleInfo { Name = "spinnerbonus" }); + } + + public override Judgement CreateJudgement() => new OsuSpinnerBonusTickJudgement(); + + public class OsuSpinnerBonusTickJudgement : OsuSpinnerTickJudgement + { + public override HitResult MaxResult => HitResult.LargeBonus; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/SpinnerTick.cs b/osu.Game.Rulesets.Osu/Objects/SpinnerTick.cs new file mode 100644 index 0000000000..d715b9a428 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Objects/SpinnerTick.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.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Objects +{ + public class SpinnerTick : OsuHitObject + { + public override Judgement CreateJudgement() => new OsuSpinnerTickJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + public class OsuSpinnerTickJudgement : OsuJudgement + { + public override HitResult MaxResult => HitResult.SmallBonus; + } + } +} diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index b9e083d35b..c8fe4f41ca 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -18,7 +18,13 @@ namespace osu.Game.Rulesets.Osu set => ((OsuKeyBindingContainer)KeyBindingContainer).AllowUserPresses = value; } - protected override RulesetKeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + /// + /// Whether the user's cursor movement events should be accepted. + /// Can be used to block only movement while still accepting button input. + /// + public bool AllowUserCursorMovement { get; set; } = true; + + protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new OsuKeyBindingContainer(ruleset, variant, unique); public OsuInputManager(RulesetInfo ruleset) @@ -26,6 +32,13 @@ namespace osu.Game.Rulesets.Osu { } + protected override bool Handle(UIEvent e) + { + if (e is MouseMoveEvent && !AllowUserCursorMovement) return false; + + return base.Handle(e); + } + private class OsuKeyBindingContainer : RulesetKeyBindingContainer { public bool AllowUserPresses = true; diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 8df0f77629..678fb8aba6 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -14,7 +14,6 @@ using osu.Game.Overlays.Settings; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Replays.Types; using osu.Game.Beatmaps.Legacy; @@ -24,16 +23,31 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Difficulty; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Osu.Skinning; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Skinning; +using System; +using System.Linq; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Statistics; +using osu.Game.Screens.Ranking.Statistics; namespace osu.Game.Rulesets.Osu { - public class OsuRuleset : Ruleset + public class OsuRuleset : Ruleset, ILegacyRuleset { - public override DrawableRuleset CreateDrawableRulesetWith(WorkingBeatmap beatmap, IReadOnlyList mods) => new DrawableOsuRuleset(this, beatmap, mods); - public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new OsuBeatmapConverter(beatmap); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableOsuRuleset(this, beatmap, mods); + + public override ScoreProcessor CreateScoreProcessor() => new OsuScoreProcessor(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new OsuBeatmapConverter(beatmap, this); + public override IBeatmapProcessor CreateBeatmapProcessor(IBeatmap beatmap) => new OsuBeatmapProcessor(beatmap); + public const string SHORT_NAME = "osu"; + public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] { new KeyBinding(InputKey.Z, OsuAction.LeftButton), @@ -42,17 +56,24 @@ namespace osu.Game.Rulesets.Osu new KeyBinding(InputKey.MouseRight, OsuAction.RightButton), }; - public override IEnumerable ConvertLegacyMods(LegacyMods mods) + public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { if (mods.HasFlag(LegacyMods.Nightcore)) yield return new OsuModNightcore(); else if (mods.HasFlag(LegacyMods.DoubleTime)) yield return new OsuModDoubleTime(); + if (mods.HasFlag(LegacyMods.Perfect)) + yield return new OsuModPerfect(); + else if (mods.HasFlag(LegacyMods.SuddenDeath)) + yield return new OsuModSuddenDeath(); + if (mods.HasFlag(LegacyMods.Autopilot)) yield return new OsuModAutopilot(); - if (mods.HasFlag(LegacyMods.Autoplay)) + if (mods.HasFlag(LegacyMods.Cinema)) + yield return new OsuModCinema(); + else if (mods.HasFlag(LegacyMods.Autoplay)) yield return new OsuModAutoplay(); if (mods.HasFlag(LegacyMods.Easy)) @@ -73,18 +94,12 @@ namespace osu.Game.Rulesets.Osu if (mods.HasFlag(LegacyMods.NoFail)) yield return new OsuModNoFail(); - if (mods.HasFlag(LegacyMods.Perfect)) - yield return new OsuModPerfect(); - if (mods.HasFlag(LegacyMods.Relax)) yield return new OsuModRelax(); if (mods.HasFlag(LegacyMods.SpunOut)) yield return new OsuModSpunOut(); - if (mods.HasFlag(LegacyMods.SuddenDeath)) - yield return new OsuModSuddenDeath(); - if (mods.HasFlag(LegacyMods.Target)) yield return new OsuModTarget(); @@ -102,7 +117,6 @@ namespace osu.Game.Rulesets.Osu new OsuModEasy(), new OsuModNoFail(), new MultiMod(new OsuModHalfTime(), new OsuModDaycore()), - new OsuModSpunOut(), }; case ModType.DifficultyIncrease: @@ -119,14 +133,16 @@ namespace osu.Game.Rulesets.Osu return new Mod[] { new OsuModTarget(), + new OsuModDifficultyAdjust(), }; case ModType.Automation: return new Mod[] { - new MultiMod(new OsuModAutoplay(), new ModCinema()), + new MultiMod(new OsuModAutoplay(), new OsuModCinema()), new OsuModRelax(), new OsuModAutopilot(), + new OsuModSpunOut(), }; case ModType.Fun: @@ -134,8 +150,10 @@ namespace osu.Game.Rulesets.Osu { new OsuModTransform(), new OsuModWiggle(), + new OsuModSpinIn(), new MultiMod(new OsuModGrow(), new OsuModDeflate()), - new MultiMod(new ModWindUp(), new ModWindDown()), + new MultiMod(new ModWindUp(), new ModWindDown()), + new OsuModTraceable(), }; case ModType.System: @@ -145,7 +163,7 @@ namespace osu.Game.Rulesets.Osu }; default: - return new Mod[] { }; + return Array.Empty(); } } @@ -153,25 +171,101 @@ namespace osu.Game.Rulesets.Osu public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new OsuDifficultyCalculator(this, beatmap); - public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new OsuPerformanceCalculator(this, beatmap, score); + public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => new OsuPerformanceCalculator(this, attributes, score); public override HitObjectComposer CreateHitObjectComposer() => new OsuHitObjectComposer(this); public override string Description => "osu!"; - public override string ShortName => "osu"; + public override string ShortName => SHORT_NAME; + + public override string PlayingVerb => "Clicking circles"; public override RulesetSettingsSubsection CreateSettings() => new OsuSettingsSubsection(this); - public override int? LegacyID => 0; + public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new OsuLegacySkinTransformer(source); + + public int LegacyID => 0; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new OsuReplayFrame(); public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new OsuRulesetConfigManager(settings, RulesetInfo); - public OsuRuleset(RulesetInfo rulesetInfo = null) - : base(rulesetInfo) + protected override IEnumerable GetValidHitResults() { + return new[] + { + HitResult.Great, + HitResult.Ok, + HitResult.Meh, + + HitResult.LargeTickHit, + HitResult.SmallTickHit, + HitResult.SmallBonus, + HitResult.LargeBonus, + }; + } + + public override string GetDisplayNameForHitResult(HitResult result) + { + switch (result) + { + case HitResult.LargeTickHit: + return "slider tick"; + + case HitResult.SmallTickHit: + return "slider end"; + + case HitResult.SmallBonus: + return "spinner spin"; + + case HitResult.LargeBonus: + return "spinner bonus"; + } + + return base.GetDisplayNameForHitResult(result); + } + + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + { + var timedHitEvents = score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)).ToList(); + + return new[] + { + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Timing Distribution", + new HitEventTimingDistributionGraph(timedHitEvents) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + }, + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Accuracy Heatmap", new AccuracyHeatmap(score, playableBeatmap) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + }, + new StatisticRow + { + Columns = new[] + { + new StatisticItem(string.Empty, new SimpleStatisticTable(3, new SimpleStatisticItem[] + { + new UnstableRate(timedHitEvents) + })) + } + } + }; } } } diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponent.cs b/osu.Game.Rulesets.Osu/OsuSkinComponent.cs new file mode 100644 index 0000000000..1d223f231b --- /dev/null +++ b/osu.Game.Rulesets.Osu/OsuSkinComponent.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.Skinning; + +namespace osu.Game.Rulesets.Osu +{ + public class OsuSkinComponent : GameplaySkinComponent + { + public OsuSkinComponent(OsuSkinComponents component) + : base(component) + { + } + + protected override string RulesetPrefix => OsuRuleset.SHORT_NAME; + + protected override string ComponentName => Component.ToString().ToLower(); + } +} diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs new file mode 100644 index 0000000000..2883f0c187 --- /dev/null +++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.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. + +namespace osu.Game.Rulesets.Osu +{ + public enum OsuSkinComponents + { + HitCircle, + FollowPoint, + Cursor, + CursorTrail, + SliderScorePoint, + ApproachCircle, + ReverseArrow, + HitCircleText, + SliderHeadHitCircle, + SliderTailHitCircle, + SliderFollowCircle, + SliderBall, + SliderBody, + SpinnerBody + } +} diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index 690263c6a0..954a217473 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -2,15 +2,17 @@ // See the LICENCE file in the repository root for full licence text. using osuTK; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using System; +using System.Diagnostics; using System.Linq; using osu.Framework.Graphics; using osu.Game.Replays; -using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Replays @@ -36,6 +38,8 @@ namespace osu.Game.Rulesets.Osu.Replays /// private readonly double reactionTime; + private readonly HitWindows defaultHitWindows; + /// /// What easing to use when moving between hitobjects /// @@ -50,6 +54,9 @@ namespace osu.Game.Rulesets.Osu.Replays { // Already superhuman, but still somewhat realistic reactionTime = ApplyModsToRate(100); + + defaultHitWindows = new OsuHitWindows(); + defaultHitWindows.SetDifficulty(Beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); } #endregion @@ -65,6 +72,9 @@ namespace osu.Game.Rulesets.Osu.Replays public override Replay Generate() { + if (Beatmap.HitObjects.Count == 0) + return Replay; + buttonIndex = 0; AddFrameToReplay(new OsuReplayFrame(-100000, new Vector2(256, 500))); @@ -89,23 +99,51 @@ namespace osu.Game.Rulesets.Osu.Replays private void addDelayedMovements(OsuHitObject h, OsuHitObject prev) { - double endTime = (prev as IHasEndTime)?.EndTime ?? prev.StartTime; + double endTime = prev.GetEndTime(); + + HitWindows hitWindows = null; + + switch (h) + { + case HitCircle hitCircle: + hitWindows = hitCircle.HitWindows; + break; + + case Slider slider: + hitWindows = slider.TailCircle.HitWindows; + break; + + case Spinner _: + hitWindows = defaultHitWindows; + break; + } + + Debug.Assert(hitWindows != null); // Make the cursor stay at a hitObject as long as possible (mainly for autopilot). - if (h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Miss) > endTime + h.HitWindows.HalfWindowFor(HitResult.Meh) + 50) + if (h.StartTime - hitWindows.WindowFor(HitResult.Miss) > endTime + hitWindows.WindowFor(HitResult.Meh) + 50) { - if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new OsuReplayFrame(endTime + h.HitWindows.HalfWindowFor(HitResult.Meh), new Vector2(prev.StackedEndPosition.X, prev.StackedEndPosition.Y))); - if (!(h is Spinner)) AddFrameToReplay(new OsuReplayFrame(h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Miss), new Vector2(h.StackedPosition.X, h.StackedPosition.Y))); + if (!(prev is Spinner) && h.StartTime - endTime < 1000) + AddFrameToReplay(new OsuReplayFrame(endTime + hitWindows.WindowFor(HitResult.Meh), new Vector2(prev.StackedEndPosition.X, prev.StackedEndPosition.Y))); + + if (!(h is Spinner)) + AddFrameToReplay(new OsuReplayFrame(h.StartTime - hitWindows.WindowFor(HitResult.Miss), new Vector2(h.StackedPosition.X, h.StackedPosition.Y))); } - else if (h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Meh) > endTime + h.HitWindows.HalfWindowFor(HitResult.Meh) + 50) + else if (h.StartTime - hitWindows.WindowFor(HitResult.Meh) > endTime + hitWindows.WindowFor(HitResult.Meh) + 50) { - if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new OsuReplayFrame(endTime + h.HitWindows.HalfWindowFor(HitResult.Meh), new Vector2(prev.StackedEndPosition.X, prev.StackedEndPosition.Y))); - if (!(h is Spinner)) AddFrameToReplay(new OsuReplayFrame(h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Meh), new Vector2(h.StackedPosition.X, h.StackedPosition.Y))); + if (!(prev is Spinner) && h.StartTime - endTime < 1000) + AddFrameToReplay(new OsuReplayFrame(endTime + hitWindows.WindowFor(HitResult.Meh), new Vector2(prev.StackedEndPosition.X, prev.StackedEndPosition.Y))); + + if (!(h is Spinner)) + AddFrameToReplay(new OsuReplayFrame(h.StartTime - hitWindows.WindowFor(HitResult.Meh), new Vector2(h.StackedPosition.X, h.StackedPosition.Y))); } - else if (h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Good) > endTime + h.HitWindows.HalfWindowFor(HitResult.Good) + 50) + else if (h.StartTime - hitWindows.WindowFor(HitResult.Ok) > endTime + hitWindows.WindowFor(HitResult.Ok) + 50) { - if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new OsuReplayFrame(endTime + h.HitWindows.HalfWindowFor(HitResult.Good), new Vector2(prev.StackedEndPosition.X, prev.StackedEndPosition.Y))); - if (!(h is Spinner)) AddFrameToReplay(new OsuReplayFrame(h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Good), new Vector2(h.StackedPosition.X, h.StackedPosition.Y))); + if (!(prev is Spinner) && h.StartTime - endTime < 1000) + AddFrameToReplay(new OsuReplayFrame(endTime + hitWindows.WindowFor(HitResult.Ok), new Vector2(prev.StackedEndPosition.X, prev.StackedEndPosition.Y))); + + if (!(h is Spinner)) + AddFrameToReplay(new OsuReplayFrame(h.StartTime - hitWindows.WindowFor(HitResult.Ok), new Vector2(h.StackedPosition.X, h.StackedPosition.Y))); } } @@ -119,11 +157,15 @@ namespace osu.Game.Rulesets.Osu.Replays // The startPosition for the slider should not be its .Position, but the point on the circle whose tangent crosses the current cursor position // We also modify spinnerDirection so it spins in the direction it enters the spin circle, to make a smooth transition. // TODO: Shouldn't the spinner always spin in the same direction? - if (h is Spinner) + if (h is Spinner spinner) { - calcSpinnerStartPosAndDirection(((OsuReplayFrame)Frames[Frames.Count - 1]).Position, out startPosition, out spinnerDirection); + // spinners with 0 spins required will auto-complete - don't bother + if (spinner.SpinsRequired == 0) + return; - Vector2 spinCentreOffset = SPINNER_CENTRE - ((OsuReplayFrame)Frames[Frames.Count - 1]).Position; + calcSpinnerStartPosAndDirection(((OsuReplayFrame)Frames[^1]).Position, out startPosition, out spinnerDirection); + + Vector2 spinCentreOffset = SPINNER_CENTRE - ((OsuReplayFrame)Frames[^1]).Position; if (spinCentreOffset.Length > SPIN_RADIUS) { @@ -150,14 +192,14 @@ namespace osu.Game.Rulesets.Osu.Replays { Vector2 spinCentreOffset = SPINNER_CENTRE - prevPos; float distFromCentre = spinCentreOffset.Length; - float distToTangentPoint = (float)Math.Sqrt(distFromCentre * distFromCentre - SPIN_RADIUS * SPIN_RADIUS); + float distToTangentPoint = MathF.Sqrt(distFromCentre * distFromCentre - SPIN_RADIUS * SPIN_RADIUS); if (distFromCentre > SPIN_RADIUS) { // Previous cursor position was outside spin circle, set startPosition to the tangent point. // Angle between centre offset and tangent point offset. - float angle = (float)Math.Asin(SPIN_RADIUS / distFromCentre); + float angle = MathF.Asin(SPIN_RADIUS / distFromCentre); if (angle > 0) { @@ -169,8 +211,8 @@ namespace osu.Game.Rulesets.Osu.Replays } // Rotate by angle so it's parallel to tangent line - spinCentreOffset.X = spinCentreOffset.X * (float)Math.Cos(angle) - spinCentreOffset.Y * (float)Math.Sin(angle); - spinCentreOffset.Y = spinCentreOffset.X * (float)Math.Sin(angle) + spinCentreOffset.Y * (float)Math.Cos(angle); + spinCentreOffset.X = spinCentreOffset.X * MathF.Cos(angle) - spinCentreOffset.Y * MathF.Sin(angle); + spinCentreOffset.Y = spinCentreOffset.X * MathF.Sin(angle) + spinCentreOffset.Y * MathF.Cos(angle); // Set length to distToTangentPoint spinCentreOffset.Normalize(); @@ -195,7 +237,7 @@ namespace osu.Game.Rulesets.Osu.Replays private void moveToHitObject(OsuHitObject h, Vector2 targetPos, Easing easing) { - OsuReplayFrame lastFrame = (OsuReplayFrame)Frames[Frames.Count - 1]; + OsuReplayFrame lastFrame = (OsuReplayFrame)Frames[^1]; // Wait until Auto could "see and react" to the next note. double waitTime = h.StartTime - Math.Max(0.0, h.TimePreempt - reactionTime); @@ -240,7 +282,7 @@ namespace osu.Game.Rulesets.Osu.Replays var startFrame = new OsuReplayFrame(h.StartTime, new Vector2(startPosition.X, startPosition.Y), action); // TODO: Why do we delay 1 ms if the object is a spinner? There already is KEY_UP_DELAY from hEndTime. - double hEndTime = ((h as IHasEndTime)?.EndTime ?? h.StartTime) + KEY_UP_DELAY; + double hEndTime = h.GetEndTime() + KEY_UP_DELAY; int endDelay = h is Spinner ? 1 : 0; var endFrame = new OsuReplayFrame(hEndTime + endDelay, new Vector2(h.StackedEndPosition.X, h.StackedEndPosition.Y)); @@ -296,7 +338,7 @@ namespace osu.Game.Rulesets.Osu.Replays Vector2 difference = startPosition - SPINNER_CENTRE; float radius = difference.Length; - float angle = radius == 0 ? 0 : (float)Math.Atan2(difference.Y, difference.X); + float angle = radius == 0 ? 0 : MathF.Atan2(difference.Y, difference.X); double t; @@ -328,7 +370,7 @@ namespace osu.Game.Rulesets.Osu.Replays } // We only want to let go of our button if we are at the end of the current replay. Otherwise something is still going on after us so we need to keep the button pressed! - if (Frames[Frames.Count - 1].Time <= endFrame.Time) + if (Frames[^1].Time <= endFrame.Time) AddFrameToReplay(endFrame); } diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs index 9ab358ee12..3356a0fbe0 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Replays /// protected static readonly Vector2 SPINNER_CENTRE = OsuPlayfield.BASE_SIZE / 2; - protected const float SPIN_RADIUS = 50; + public const float SPIN_RADIUS = 50; /// /// The time in ms between each ReplayFrame. diff --git a/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs b/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs index c6ac1dd346..cf48dc053f 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuFramedReplayInputHandler.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Input.StateChanges; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Replays; using osu.Game.Rulesets.Replays; using osuTK; @@ -36,19 +36,10 @@ namespace osu.Game.Rulesets.Osu.Replays } } - public override List GetPendingInputs() + public override void CollectPendingInputs(List inputs) { - return new List - { - new MousePositionAbsoluteInput - { - Position = GamefieldToScreenSpace(Position ?? Vector2.Zero) - }, - new ReplayState - { - PressedActions = CurrentFrame?.Actions ?? new List() - } - }; + inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(Position ?? Vector2.Zero) }); + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); } } } diff --git a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs index 4d90fcadd5..3db81d70da 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuReplayFrame.cs @@ -26,11 +26,23 @@ namespace osu.Game.Rulesets.Osu.Replays Actions.AddRange(actions); } - public void ConvertFrom(LegacyReplayFrame legacyFrame, IBeatmap beatmap) + public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) { - Position = legacyFrame.Position; - if (legacyFrame.MouseLeft) Actions.Add(OsuAction.LeftButton); - if (legacyFrame.MouseRight) Actions.Add(OsuAction.RightButton); + Position = currentFrame.Position; + if (currentFrame.MouseLeft) Actions.Add(OsuAction.LeftButton); + if (currentFrame.MouseRight) Actions.Add(OsuAction.RightButton); + } + + public LegacyReplayFrame ToLegacy(IBeatmap beatmap) + { + ReplayButtonState state = ReplayButtonState.None; + + if (Actions.Contains(OsuAction.LeftButton)) + state |= ReplayButtonState.Left1; + if (Actions.Contains(OsuAction.RightButton)) + state |= ReplayButtonState.Right1; + + return new LegacyReplayFrame(Time, Position.X, Position.Y, state); } } } diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/old-stacking-expected-conversion.json b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/old-stacking-expected-conversion.json new file mode 100644 index 0000000000..004e7940d1 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/old-stacking-expected-conversion.json @@ -0,0 +1,278 @@ +{ + "Mappings": [{ + "StartTime": 32165, + "Objects": [{ + "StartTime": 32165, + "EndTime": 32165, + "X": 32, + "Y": 320 + }] + }, + { + "StartTime": 32517, + "Objects": [{ + "StartTime": 32517, + "EndTime": 32517, + "X": 246.396057, + "Y": 182.396057 + }] + }, + { + "StartTime": 32605, + "Objects": [{ + "StartTime": 32605, + "EndTime": 32605, + "X": 249.597382, + "Y": 185.597382 + }] + }, + { + "StartTime": 32693, + "Objects": [{ + "StartTime": 32693, + "EndTime": 32693, + "X": 252.798691, + "Y": 188.798691 + }] + }, + { + "StartTime": 32781, + "Objects": [{ + "StartTime": 32781, + "EndTime": 32781, + "X": 256, + "Y": 192 + }] + }, + { + "StartTime": 33248, + "Objects": [{ + "StartTime": 33248, + "EndTime": 33248, + "X": 39.3960648, + "Y": 76.3960648 + }] + }, + { + "StartTime": 33307, + "Objects": [{ + "StartTime": 33307, + "EndTime": 33307, + "X": 42.5973778, + "Y": 79.597374 + }] + }, + { + "StartTime": 33383, + "Objects": [{ + "StartTime": 33383, + "EndTime": 33383, + "X": 45.798687, + "Y": 82.79869 + }] + }, + { + "StartTime": 33459, + "Objects": [{ + "StartTime": 33459, + "EndTime": 33459, + "X": 49, + "Y": 86 + }, + { + "StartTime": 33635, + "EndTime": 33635, + "X": 123.847847, + "Y": 85.7988 + }, + { + "StartTime": 33811, + "EndTime": 33811, + "X": 198.6957, + "Y": 85.5975952 + }, + { + "StartTime": 33988, + "EndTime": 33988, + "X": 273.9688, + "Y": 85.39525 + }, + { + "StartTime": 34164, + "EndTime": 34164, + "X": 348.816681, + "Y": 85.19404 + }, + { + "StartTime": 34246, + "EndTime": 34246, + "X": 398.998718, + "Y": 85.05914 + } + ] + }, + { + "StartTime": 34341, + "Objects": [{ + "StartTime": 34341, + "EndTime": 34341, + "X": 401.201324, + "Y": 88.20131 + }] + }, + { + "StartTime": 34400, + "Objects": [{ + "StartTime": 34400, + "EndTime": 34400, + "X": 404.402618, + "Y": 91.402626 + }] + }, + { + "StartTime": 34459, + "Objects": [{ + "StartTime": 34459, + "EndTime": 34459, + "X": 407.603943, + "Y": 94.6039352 + }] + }, + { + "StartTime": 34989, + "Objects": [{ + "StartTime": 34989, + "EndTime": 34989, + "X": 156.597382, + "Y": 131.597382 + }, + { + "StartTime": 35018, + "EndTime": 35018, + "X": 181.597382, + "Y": 131.597382 + } + ] + }, + { + "StartTime": 35106, + "Objects": [{ + "StartTime": 35106, + "EndTime": 35106, + "X": 159.798691, + "Y": 134.798691 + }, + { + "StartTime": 35135, + "EndTime": 35135, + "X": 184.798691, + "Y": 134.798691 + } + ] + }, + { + "StartTime": 35224, + "Objects": [{ + "StartTime": 35224, + "EndTime": 35224, + "X": 163, + "Y": 138 + }, + { + "StartTime": 35253, + "EndTime": 35253, + "X": 188, + "Y": 138 + } + ] + }, + { + "StartTime": 35695, + "Objects": [{ + "StartTime": 35695, + "EndTime": 35695, + "X": 162.798691, + "Y": 72.79869 + }, + { + "StartTime": 35871, + "EndTime": 35871, + "X": 237.797241, + "Y": 72.33286 + }, + { + "StartTime": 36011, + "EndTime": 36011, + "X": 312.795776, + "Y": 71.8670349 + } + ] + }, + { + "StartTime": 36106, + "Objects": [{ + "StartTime": 36106, + "EndTime": 36106, + "X": 315, + "Y": 75 + }, + { + "StartTime": 36282, + "EndTime": 36282, + "X": 240.001526, + "Y": 75.47769 + }, + { + "StartTime": 36422, + "EndTime": 36422, + "X": 165.003052, + "Y": 75.95539 + } + ] + }, + { + "StartTime": 36518, + "Objects": [{ + "StartTime": 36518, + "EndTime": 36518, + "X": 169.201309, + "Y": 79.20131 + }, + { + "StartTime": 36694, + "EndTime": 36694, + "X": 244.19986, + "Y": 78.73548 + }, + { + "StartTime": 36834, + "EndTime": 36834, + "X": 319.198425, + "Y": 78.26965 + } + ] + }, + { + "StartTime": 36929, + "Objects": [{ + "StartTime": 36929, + "EndTime": 36929, + "X": 324.603943, + "Y": 84.6039352 + }, + { + "StartTime": 37105, + "EndTime": 37105, + "X": 249.605469, + "Y": 85.08163 + }, + { + "StartTime": 37245, + "EndTime": 37245, + "X": 174.607, + "Y": 85.5593262 + } + ] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/old-stacking.osu b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/old-stacking.osu new file mode 100644 index 0000000000..4bc9226d67 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/old-stacking.osu @@ -0,0 +1,40 @@ +osu file format v3 + +[Difficulty] +HPDrainRate:3 +CircleSize:5 +OverallDifficulty:8 +ApproachRate:8 +SliderMultiplier:1.5 +SliderTickRate:2 + +[TimingPoints] +48,352.941176470588,4,1,1,100,1,0 + +[HitObjects] +// Hit circles +32,320,32165,5,2,0:0:0:0: +256,192,32517,5,0,0:0:0:0: +256,192,32605,1,0,0:0:0:0: +256,192,32693,1,0,0:0:0:0: +256,192,32781,1,0,0:0:0:0: + +// Hit circles on slider endpoints +49,86,33248,1,0,0:0:0:0: +49,86,33307,1,0,0:0:0:0: +49,86,33383,1,0,0:0:0:0: +49,86,33459,2,0,L|421:85,1,350 +398,85,34341,1,0,0:0:0:0: +398,85,34400,1,0,0:0:0:0: +398,85,34459,1,0,0:0:0:0: + +// Sliders +163,138,34989,2,0,L|196:138,1,25 +163,138,35106,2,0,L|196:138,1,25 +163,138,35224,2,0,L|196:138,1,25 + +// Reversed sliders +166,76,35695,2,0,L|327:75,1,150 +315,75,36106,2,0,L|158:76,1,150 +166,76,36518,2,0,L|327:75,1,150 +315,75,36929,2,0,L|158:76,1,150 diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/repeat-slider-expected-conversion.json b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/repeat-slider-expected-conversion.json new file mode 100644 index 0000000000..fb4050963f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/repeat-slider-expected-conversion.json @@ -0,0 +1,222 @@ +{ + "Mappings": [{ + "StartTime": 369, + "Objects": [{ + "StartTime": 369, + "EndTime": 369, + "X": 177, + "Y": 191 + }, + { + "StartTime": 450, + "EndTime": 450, + "X": 216.539276, + "Y": 191.192871 + }, + { + "StartTime": 532, + "EndTime": 532, + "X": 256.5667, + "Y": 191.388138 + }, + { + "StartTime": 614, + "EndTime": 614, + "X": 296.594116, + "Y": 191.583389 + }, + { + "StartTime": 696, + "EndTime": 696, + "X": 336.621521, + "Y": 191.778641 + }, + { + "StartTime": 778, + "EndTime": 778, + "X": 376.648926, + "Y": 191.9739 + }, + { + "StartTime": 860, + "EndTime": 860, + "X": 337.318878, + "Y": 191.782043 + }, + { + "StartTime": 942, + "EndTime": 942, + "X": 297.291443, + "Y": 191.586792 + }, + { + "StartTime": 1024, + "EndTime": 1024, + "X": 257.264038, + "Y": 191.391541 + }, + { + "StartTime": 1106, + "EndTime": 1106, + "X": 217.2366, + "Y": 191.196274 + }, + { + "StartTime": 1188, + "EndTime": 1188, + "X": 177.209213, + "Y": 191.001022 + }, + { + "StartTime": 1270, + "EndTime": 1270, + "X": 216.818192, + "Y": 191.194229 + }, + { + "StartTime": 1352, + "EndTime": 1352, + "X": 256.8456, + "Y": 191.3895 + }, + { + "StartTime": 1434, + "EndTime": 1434, + "X": 296.873047, + "Y": 191.584747 + }, + { + "StartTime": 1516, + "EndTime": 1516, + "X": 336.900452, + "Y": 191.78 + }, + { + "StartTime": 1598, + "EndTime": 1598, + "X": 376.927917, + "Y": 191.975266 + }, + { + "StartTime": 1680, + "EndTime": 1680, + "X": 337.039948, + "Y": 191.780685 + }, + { + "StartTime": 1762, + "EndTime": 1762, + "X": 297.0125, + "Y": 191.585434 + }, + { + "StartTime": 1844, + "EndTime": 1844, + "X": 256.9851, + "Y": 191.390167 + }, + { + "StartTime": 1926, + "EndTime": 1926, + "X": 216.957672, + "Y": 191.194916 + }, + { + "StartTime": 2008, + "EndTime": 2008, + "X": 177.069717, + "Y": 191.000336 + }, + { + "StartTime": 2090, + "EndTime": 2090, + "X": 217.097137, + "Y": 191.1956 + }, + { + "StartTime": 2172, + "EndTime": 2172, + "X": 257.124573, + "Y": 191.390854 + }, + { + "StartTime": 2254, + "EndTime": 2254, + "X": 297.152, + "Y": 191.5861 + }, + { + "StartTime": 2336, + "EndTime": 2336, + "X": 337.179443, + "Y": 191.781372 + }, + { + "StartTime": 2418, + "EndTime": 2418, + "X": 376.7884, + "Y": 191.974579 + }, + { + "StartTime": 2500, + "EndTime": 2500, + "X": 336.760956, + "Y": 191.779327 + }, + { + "StartTime": 2582, + "EndTime": 2582, + "X": 296.733643, + "Y": 191.584076 + }, + { + "StartTime": 2664, + "EndTime": 2664, + "X": 256.7062, + "Y": 191.388809 + }, + { + "StartTime": 2746, + "EndTime": 2746, + "X": 216.678772, + "Y": 191.193558 + }, + { + "StartTime": 2828, + "EndTime": 2828, + "X": 177.348663, + "Y": 191.0017 + }, + { + "StartTime": 2909, + "EndTime": 2909, + "X": 216.887909, + "Y": 191.19458 + }, + { + "StartTime": 2991, + "EndTime": 2991, + "X": 256.915344, + "Y": 191.389832 + }, + { + "StartTime": 3073, + "EndTime": 3073, + "X": 296.942749, + "Y": 191.585083 + }, + { + "StartTime": 3155, + "EndTime": 3155, + "X": 336.970184, + "Y": 191.78035 + }, + { + "StartTime": 3201, + "EndTime": 3201, + "X": 376.99762, + "Y": 191.9756 + } + ] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/repeat-slider.osu b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/repeat-slider.osu new file mode 100644 index 0000000000..624d905384 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/repeat-slider.osu @@ -0,0 +1,18 @@ +osu file format v14 + +[General] +StackLeniency: 0.4 +Mode: 0 + +[Difficulty] +CircleSize:4 +OverallDifficulty:7 +ApproachRate:8 +SliderMultiplier:1.6 +SliderTickRate:4 + +[TimingPoints] +369,327.868852459016,4,2,2,32,1,0 + +[HitObjects] +177,191,369,6,0,L|382:192,7,200 \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/uneven-repeat-slider-expected-conversion.json b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/uneven-repeat-slider-expected-conversion.json new file mode 100644 index 0000000000..12d1645c04 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/uneven-repeat-slider-expected-conversion.json @@ -0,0 +1,348 @@ +{ + "Mappings": [{ + "StartTime": 369, + "Objects": [{ + "StartTime": 369, + "EndTime": 369, + "X": 127, + "Y": 194 + }, + { + "StartTime": 450, + "EndTime": 450, + "X": 166.53389, + "Y": 193.8691 + }, + { + "StartTime": 532, + "EndTime": 532, + "X": 206.555847, + "Y": 193.736572 + }, + { + "StartTime": 614, + "EndTime": 614, + "X": 246.57782, + "Y": 193.60405 + }, + { + "StartTime": 696, + "EndTime": 696, + "X": 286.5998, + "Y": 193.471527 + }, + { + "StartTime": 778, + "EndTime": 778, + "X": 326.621765, + "Y": 193.339 + }, + { + "StartTime": 860, + "EndTime": 860, + "X": 366.6437, + "Y": 193.206482 + }, + { + "StartTime": 942, + "EndTime": 942, + "X": 406.66568, + "Y": 193.073959 + }, + { + "StartTime": 970, + "EndTime": 970, + "X": 420.331726, + "Y": 193.0287 + }, + { + "StartTime": 997, + "EndTime": 997, + "X": 407.153748, + "Y": 193.072342 + }, + { + "StartTime": 1079, + "EndTime": 1079, + "X": 367.131775, + "Y": 193.204865 + }, + { + "StartTime": 1161, + "EndTime": 1161, + "X": 327.1098, + "Y": 193.337387 + }, + { + "StartTime": 1243, + "EndTime": 1243, + "X": 287.08783, + "Y": 193.46991 + }, + { + "StartTime": 1325, + "EndTime": 1325, + "X": 247.0659, + "Y": 193.602432 + }, + { + "StartTime": 1407, + "EndTime": 1407, + "X": 207.043915, + "Y": 193.734955 + }, + { + "StartTime": 1489, + "EndTime": 1489, + "X": 167.021988, + "Y": 193.867477 + }, + { + "StartTime": 1571, + "EndTime": 1571, + "X": 127, + "Y": 194 + }, + { + "StartTime": 1653, + "EndTime": 1653, + "X": 167.021988, + "Y": 193.867477 + }, + { + "StartTime": 1735, + "EndTime": 1735, + "X": 207.043976, + "Y": 193.734955 + }, + { + "StartTime": 1817, + "EndTime": 1817, + "X": 247.065887, + "Y": 193.602432 + }, + { + "StartTime": 1899, + "EndTime": 1899, + "X": 287.08783, + "Y": 193.46991 + }, + { + "StartTime": 1981, + "EndTime": 1981, + "X": 327.1098, + "Y": 193.337387 + }, + { + "StartTime": 2062, + "EndTime": 2062, + "X": 366.643738, + "Y": 193.206482 + }, + { + "StartTime": 2144, + "EndTime": 2144, + "X": 406.665649, + "Y": 193.073959 + }, + { + "StartTime": 2172, + "EndTime": 2172, + "X": 420.331726, + "Y": 193.0287 + }, + { + "StartTime": 2199, + "EndTime": 2199, + "X": 407.153748, + "Y": 193.072342 + }, + { + "StartTime": 2281, + "EndTime": 2281, + "X": 367.1318, + "Y": 193.204865 + }, + { + "StartTime": 2363, + "EndTime": 2363, + "X": 327.1098, + "Y": 193.337387 + }, + { + "StartTime": 2445, + "EndTime": 2445, + "X": 287.08783, + "Y": 193.46991 + }, + { + "StartTime": 2527, + "EndTime": 2527, + "X": 247.065887, + "Y": 193.602432 + }, + { + "StartTime": 2609, + "EndTime": 2609, + "X": 207.043976, + "Y": 193.734955 + }, + { + "StartTime": 2691, + "EndTime": 2691, + "X": 167.021988, + "Y": 193.867477 + }, + { + "StartTime": 2773, + "EndTime": 2773, + "X": 127, + "Y": 194 + }, + { + "StartTime": 2855, + "EndTime": 2855, + "X": 167.021988, + "Y": 193.867477 + }, + { + "StartTime": 2937, + "EndTime": 2937, + "X": 207.043976, + "Y": 193.734955 + }, + { + "StartTime": 3019, + "EndTime": 3019, + "X": 247.065948, + "Y": 193.602432 + }, + { + "StartTime": 3101, + "EndTime": 3101, + "X": 287.087952, + "Y": 193.46991 + }, + { + "StartTime": 3183, + "EndTime": 3183, + "X": 327.109772, + "Y": 193.337387 + }, + { + "StartTime": 3265, + "EndTime": 3265, + "X": 367.131775, + "Y": 193.204865 + }, + { + "StartTime": 3347, + "EndTime": 3347, + "X": 407.153748, + "Y": 193.072342 + }, + { + "StartTime": 3374, + "EndTime": 3374, + "X": 420.331726, + "Y": 193.0287 + }, + { + "StartTime": 3401, + "EndTime": 3401, + "X": 407.153748, + "Y": 193.072342 + }, + { + "StartTime": 3483, + "EndTime": 3483, + "X": 367.131775, + "Y": 193.204865 + }, + { + "StartTime": 3565, + "EndTime": 3565, + "X": 327.109772, + "Y": 193.337387 + }, + { + "StartTime": 3647, + "EndTime": 3647, + "X": 287.087952, + "Y": 193.46991 + }, + { + "StartTime": 3729, + "EndTime": 3729, + "X": 247.065948, + "Y": 193.602432 + }, + { + "StartTime": 3811, + "EndTime": 3811, + "X": 207.043976, + "Y": 193.734955 + }, + { + "StartTime": 3893, + "EndTime": 3893, + "X": 167.021988, + "Y": 193.867477 + }, + { + "StartTime": 3975, + "EndTime": 3975, + "X": 127, + "Y": 194 + }, + { + "StartTime": 4057, + "EndTime": 4057, + "X": 167.021988, + "Y": 193.867477 + }, + { + "StartTime": 4139, + "EndTime": 4139, + "X": 207.043976, + "Y": 193.734955 + }, + { + "StartTime": 4221, + "EndTime": 4221, + "X": 247.065948, + "Y": 193.602432 + }, + { + "StartTime": 4303, + "EndTime": 4303, + "X": 287.087952, + "Y": 193.46991 + }, + { + "StartTime": 4385, + "EndTime": 4385, + "X": 327.109772, + "Y": 193.337387 + }, + { + "StartTime": 4467, + "EndTime": 4467, + "X": 367.131775, + "Y": 193.204865 + }, + { + "StartTime": 4540, + "EndTime": 4540, + "X": 420.331726, + "Y": 193.0287 + }, + { + "StartTime": 4549, + "EndTime": 4549, + "X": 407.153748, + "Y": 193.072342 + } + ] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/uneven-repeat-slider.osu b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/uneven-repeat-slider.osu new file mode 100644 index 0000000000..64aeeb0c07 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/uneven-repeat-slider.osu @@ -0,0 +1,19 @@ +osu file format v14 + +[General] +StackLeniency: 0.4 +Mode: 0 + +[Difficulty] +CircleSize:4 +OverallDifficulty:7 +ApproachRate:8 +SliderMultiplier:1.6 +SliderTickRate:4 + +[TimingPoints] +369,327.868852459016,4,2,2,32,1,0 + +[HitObjects] +// A slider with an un-even amount of ticks +127,194,369,6,0,L|429:193,7,293.333333333333 diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs new file mode 100644 index 0000000000..dafe63a6d1 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.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.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Scoring +{ + public class OsuHitWindows : HitWindows + { + private static readonly DifficultyRange[] osu_ranges = + { + new DifficultyRange(HitResult.Great, 80, 50, 20), + new DifficultyRange(HitResult.Ok, 140, 100, 60), + new DifficultyRange(HitResult.Meh, 200, 150, 100), + new DifficultyRange(HitResult.Miss, 400, 400, 400), + }; + + public override bool IsHitResultAllowed(HitResult result) + { + switch (result) + { + case HitResult.Great: + case HitResult.Ok: + case HitResult.Meh: + case HitResult.Miss: + return true; + } + + return false; + } + + protected override DifficultyRange[] GetRanges() => osu_ranges; + } +} diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs index cf0565c6da..44118227d9 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs @@ -1,78 +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 osu.Framework.Extensions; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; -using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Scoring { - internal class OsuScoreProcessor : ScoreProcessor + public class OsuScoreProcessor : ScoreProcessor { - public OsuScoreProcessor(DrawableRuleset drawableRuleset) - : base(drawableRuleset) + protected override HitEvent CreateHitEvent(JudgementResult result) + => base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit); + + protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement) { - } - - private float hpDrainRate; - - private readonly Dictionary comboResultCounts = new Dictionary(); - - protected override void ApplyBeatmap(Beatmap beatmap) - { - base.ApplyBeatmap(beatmap); - - hpDrainRate = beatmap.BeatmapInfo.BaseDifficulty.DrainRate; - } - - protected override void Reset(bool storeResults) - { - base.Reset(storeResults); - comboResultCounts.Clear(); - } - - protected override void ApplyResult(JudgementResult result) - { - base.ApplyResult(result); - - var osuResult = (OsuJudgementResult)result; - - if (result.Type != HitResult.None) - comboResultCounts[osuResult.ComboType] = comboResultCounts.GetOrDefault(osuResult.ComboType) + 1; - } - - protected override double HealthAdjustmentFactorFor(JudgementResult result) - { - switch (result.Type) + switch (hitObject) { - case HitResult.Great: - return 10.2 - hpDrainRate; - - case HitResult.Good: - return 8 - hpDrainRate; - - case HitResult.Meh: - return 4 - hpDrainRate; - - // case HitResult.SliderTick: - // return Math.Max(7 - hpDrainRate, 0) * 0.01; - - case HitResult.Miss: - return hpDrainRate; + case HitCircle _: + return new OsuHitCircleJudgementResult(hitObject, judgement); default: - return 0; + return new OsuJudgementResult(hitObject, judgement); } } - - protected override JudgementResult CreateResult(Judgement judgement) => new OsuJudgementResult(judgement); - - public override HitWindows CreateHitWindows() => new OsuHitWindows(); } } diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyCursor.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyCursor.cs new file mode 100644 index 0000000000..e96bd29ad5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyCursor.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Skinning; +using osu.Game.Rulesets.Osu.UI.Cursor; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public class LegacyCursor : OsuCursorSprite + { + private bool spin; + + public LegacyCursor() + { + Size = new Vector2(50); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + spin = skin.GetConfig(OsuSkinConfiguration.CursorRotate)?.Value ?? true; + + InternalChildren = new[] + { + ExpandTarget = new NonPlayfieldSprite + { + Texture = skin.GetTexture("cursor"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new NonPlayfieldSprite + { + Texture = skin.GetTexture("cursormiddle"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + protected override void LoadComplete() + { + if (spin) + ExpandTarget.Spin(10000, RotationDirection.Clockwise); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyCursorTrail.cs new file mode 100644 index 0000000000..e6cd7bc59d --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyCursorTrail.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; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Rulesets.Osu.UI.Cursor; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public class LegacyCursorTrail : CursorTrail + { + private const double disjoint_trail_time_separation = 1000 / 60.0; + + private bool disjointTrail; + private double lastTrailTime; + private IBindable cursorSize; + + public LegacyCursorTrail() + { + Blending = BlendingParameters.Additive; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, OsuConfigManager config) + { + Texture = skin.GetTexture("cursortrail"); + disjointTrail = skin.GetTexture("cursormiddle") == null; + + if (Texture != null) + { + // stable "magic ratio". see OsuPlayfieldAdjustmentContainer for full explanation. + Texture.ScaleAdjust *= 1.6f; + } + + cursorSize = config.GetBindable(OsuSetting.GameplayCursorSize).GetBoundCopy(); + } + + protected override double FadeDuration => disjointTrail ? 150 : 500; + + protected override bool InterpolateMovements => !disjointTrail; + + protected override float IntervalMultiplier => 1 / Math.Max(cursorSize.Value, 1); + + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (!disjointTrail) + return base.OnMouseMove(e); + + if (Time.Current - lastTrailTime >= disjoint_trail_time_separation) + { + lastTrailTime = Time.Current; + return base.OnMouseMove(e); + } + + return false; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs new file mode 100644 index 0000000000..1551d1c149 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs @@ -0,0 +1,178 @@ +// 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.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; +using static osu.Game.Skinning.LegacySkinConfiguration; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public class LegacyMainCirclePiece : CompositeDrawable + { + private readonly string priorityLookup; + private readonly bool hasNumber; + + public LegacyMainCirclePiece(string priorityLookup = null, bool hasNumber = true) + { + this.priorityLookup = priorityLookup; + this.hasNumber = hasNumber; + + Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + } + + private Container circleSprites; + private Sprite hitCircleSprite; + private Sprite hitCircleOverlay; + + private SkinnableSpriteText hitCircleText; + + private readonly IBindable state = new Bindable(); + private readonly Bindable accentColour = new Bindable(); + private readonly IBindable indexInCurrentCombo = new Bindable(); + + [Resolved] + private DrawableHitObject drawableObject { get; set; } + + [Resolved] + private ISkinSource skin { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + var drawableOsuObject = (DrawableOsuHitObject)drawableObject; + + bool allowFallback = false; + + // attempt lookup using priority specification + Texture baseTexture = getTextureWithFallback(string.Empty); + + // if the base texture was not found without a fallback, switch on fallback mode and re-perform the lookup. + if (baseTexture == null) + { + allowFallback = true; + baseTexture = getTextureWithFallback(string.Empty); + } + + // at this point, any further texture fetches should be correctly using the priority source if the base texture was retrieved using it. + // the flow above handles the case where a sliderendcircle.png is retrieved from the skin, but sliderendcircleoverlay.png doesn't exist. + // expected behaviour in this scenario is not showing the overlay, rather than using hitcircleoverlay.png (potentially from the default/fall-through skin). + Texture overlayTexture = getTextureWithFallback("overlay"); + + InternalChildren = new Drawable[] + { + circleSprites = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new[] + { + hitCircleSprite = new Sprite + { + Texture = baseTexture, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + hitCircleOverlay = new Sprite + { + Texture = overlayTexture, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }, + }; + + if (hasNumber) + { + AddInternal(hitCircleText = new SkinnableSpriteText(new OsuSkinComponent(OsuSkinComponents.HitCircleText), _ => new OsuSpriteText + { + Font = OsuFont.Numeric.With(size: 40), + UseFullGlyphHeight = false, + }, confineMode: ConfineMode.NoScaling) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + bool overlayAboveNumber = skin.GetConfig(OsuSkinConfiguration.HitCircleOverlayAboveNumber)?.Value ?? true; + + if (overlayAboveNumber) + AddInternal(hitCircleOverlay.CreateProxy()); + + state.BindTo(drawableObject.State); + accentColour.BindTo(drawableObject.AccentColour); + indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); + + Texture getTextureWithFallback(string name) + { + Texture tex = null; + + if (!string.IsNullOrEmpty(priorityLookup)) + { + tex = skin.GetTexture($"{priorityLookup}{name}"); + + if (!allowFallback) + return tex; + } + + return tex ?? skin.GetTexture($"hitcircle{name}"); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + state.BindValueChanged(updateState, true); + accentColour.BindValueChanged(colour => hitCircleSprite.Colour = LegacyColourCompatibility.DisallowZeroAlpha(colour.NewValue), true); + if (hasNumber) + indexInCurrentCombo.BindValueChanged(index => hitCircleText.Text = (index.NewValue + 1).ToString(), true); + } + + private void updateState(ValueChangedEvent state) + { + const double legacy_fade_duration = 240; + + using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime, true)) + { + switch (state.NewValue) + { + case ArmedState.Hit: + circleSprites.FadeOut(legacy_fade_duration, Easing.Out); + circleSprites.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); + + if (hasNumber) + { + var legacyVersion = skin.GetConfig(LegacySetting.Version)?.Value; + + if (legacyVersion >= 2.0m) + // legacy skins of version 2.0 and newer only apply very short fade out to the number piece. + hitCircleText.FadeOut(legacy_fade_duration / 4, Easing.Out); + else + { + // old skins scale and fade it normally along other pieces. + hitCircleText.FadeOut(legacy_fade_duration, Easing.Out); + hitCircleText.ScaleTo(1.4f, legacy_fade_duration, Easing.Out); + } + } + + break; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyNewStyleSpinner.cs new file mode 100644 index 0000000000..05f4c8e307 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyNewStyleSpinner.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + /// + /// Legacy skinned spinner with two main spinning layers, one fixed overlay and one final spinning overlay. + /// No background layer. + /// + public class LegacyNewStyleSpinner : LegacySpinner + { + private Sprite glow; + private Sprite discBottom; + private Sprite discTop; + private Sprite spinningMiddle; + private Sprite fixedMiddle; + + private readonly Color4 glowColour = new Color4(3, 151, 255, 255); + + private Container scaleContainer; + + [BackgroundDependencyLoader] + private void load(ISkinSource source) + { + AddInternal(scaleContainer = new Container + { + Scale = new Vector2(SPRITE_SCALE), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + glow = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-glow"), + Blending = BlendingParameters.Additive, + Colour = glowColour, + }, + discBottom = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-bottom") + }, + discTop = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-top") + }, + fixedMiddle = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-middle") + }, + spinningMiddle = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-middle2") + } + } + }); + } + + protected override void UpdateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + base.UpdateStateTransforms(drawableHitObject, state); + + switch (drawableHitObject) + { + case DrawableSpinner d: + Spinner spinner = d.HitObject; + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + this.FadeOut(); + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn / 2, true)) + this.FadeInFromZero(spinner.TimeFadeIn / 2); + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + { + fixedMiddle.FadeColour(Color4.White); + + using (BeginDelayedSequence(spinner.TimePreempt, true)) + fixedMiddle.FadeColour(Color4.Red, spinner.Duration); + } + + if (state == ArmedState.Hit) + { + using (BeginAbsoluteSequence(d.HitStateUpdateTime)) + glow.FadeOut(300); + } + + break; + + case DrawableSpinnerBonusTick _: + if (state == ArmedState.Hit) + glow.FlashColour(Color4.White, 200); + + break; + } + } + + protected override void Update() + { + base.Update(); + spinningMiddle.Rotation = discTop.Rotation = DrawableSpinner.RotationTracker.Rotation; + discBottom.Rotation = discTop.Rotation / 3; + + glow.Alpha = DrawableSpinner.Progress; + + scaleContainer.Scale = new Vector2(SPRITE_SCALE * (0.8f + (float)Interpolation.ApplyEasing(Easing.Out, DrawableSpinner.Progress) * 0.2f)); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyOldStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyOldStyleSpinner.cs new file mode 100644 index 0000000000..fba802f085 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyOldStyleSpinner.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + /// + /// Legacy skinned spinner with one main spinning layer and a background layer. + /// + public class LegacyOldStyleSpinner : LegacySpinner + { + private Sprite disc; + private Sprite metreSprite; + private Container metre; + + private bool spinnerBlink; + + private const float final_metre_height = 692 * SPRITE_SCALE; + + [BackgroundDependencyLoader] + private void load(ISkinSource source) + { + spinnerBlink = source.GetConfig(OsuSkinConfiguration.SpinnerNoBlink)?.Value != true; + + AddInternal(new Container + { + // 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 + { + 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. + 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) + } + } + } + }); + } + + protected override void UpdateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + base.UpdateStateTransforms(drawableHitObject, state); + + if (!(drawableHitObject is DrawableSpinner d)) + return; + + Spinner spinner = d.HitObject; + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + this.FadeOut(); + + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn / 2, true)) + this.FadeInFromZero(spinner.TimeFadeIn / 2); + } + + protected override void Update() + { + base.Update(); + disc.Rotation = DrawableSpinner.RotationTracker.Rotation; + + // careful: need to call this exactly once for all calculations in a frame + // as the function has a random factor in it + var metreHeight = getMetreHeight(DrawableSpinner.Progress); + + // hack to make the metre blink up from below than down from above. + // move down the container to be able to apply masking for the metre, + // and then move the sprite back up the same amount to keep its position absolute. + metre.Y = final_metre_height - metreHeight; + metreSprite.Y = -metre.Y; + } + + private const int total_bars = 10; + + private float getMetreHeight(float progress) + { + progress *= 100; + + // the spinner should still blink at 100% progress. + if (spinnerBlink) + progress = Math.Min(99, progress); + + int barCount = (int)progress / 10; + + if (spinnerBlink && RNG.NextBool(((int)progress % 10) / 10f)) + barCount++; + + return (float)barCount / total_bars * final_metre_height; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.cs new file mode 100644 index 0000000000..836069013d --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/LegacySliderBall.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public class LegacySliderBall : CompositeDrawable + { + private readonly Drawable animationContent; + + private Sprite layerNd; + private Sprite layerSpec; + + public LegacySliderBall(Drawable animationContent) + { + this.animationContent = animationContent; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + var ballColour = skin.GetConfig(OsuSkinColour.SliderBall)?.Value ?? Color4.White; + + InternalChildren = new[] + { + layerNd = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = skin.GetTexture("sliderb-nd"), + Colour = new Color4(5, 5, 5, 255), + }, + LegacyColourCompatibility.ApplyWithDoubledAlpha(animationContent.With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }), ballColour), + layerSpec = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = skin.GetTexture("sliderb-spec"), + Blending = BlendingParameters.Additive, + }, + }; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + //undo rotation on layers which should not be rotated. + float appliedRotation = Parent.Rotation; + + layerNd.Rotation = -appliedRotation; + layerSpec.Rotation = -appliedRotation; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacySliderBody.cs b/osu.Game.Rulesets.Osu/Skinning/LegacySliderBody.cs new file mode 100644 index 0000000000..aad8b189d9 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/LegacySliderBody.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.Framework.Extensions.Color4Extensions; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables.Pieces; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public class LegacySliderBody : PlaySliderBody + { + protected override DrawableSliderPath CreateSliderPath() => new LegacyDrawableSliderPath(); + + private class LegacyDrawableSliderPath : DrawableSliderPath + { + private const float shadow_portion = 1 - (OsuLegacySkinTransformer.LEGACY_CIRCLE_RADIUS / OsuHitObject.OBJECT_RADIUS); + + protected new float CalculatedBorderPortion + // Roughly matches osu!stable's slider border portions. + => base.CalculatedBorderPortion * 0.77f; + + public new Color4 AccentColour => new Color4(base.AccentColour.R, base.AccentColour.G, base.AccentColour.B, base.AccentColour.A * 0.70f); + + protected override Color4 ColourAt(float position) + { + float realBorderPortion = shadow_portion + CalculatedBorderPortion; + float realGradientPortion = 1 - realBorderPortion; + + if (position <= shadow_portion) + return new Color4(0f, 0f, 0f, 0.25f * position / shadow_portion); + + if (position <= realBorderPortion) + return BorderColour; + + position -= realBorderPortion; + + Color4 outerColour = AccentColour.Darken(0.1f); + Color4 innerColour = lighten(AccentColour, 0.5f); + + return Interpolation.ValueAt(position / realGradientPortion, outerColour, innerColour, 0, 1); + } + + /// + /// Lightens a colour in a way more friendly to dark or strong colours. + /// + private static Color4 lighten(Color4 color, float amount) + { + amount *= 0.5f; + return new Color4( + Math.Min(1, color.R * (1 + 0.5f * amount) + 1 * amount), + Math.Min(1, color.G * (1 + 0.5f * amount) + 1 * amount), + Math.Min(1, color.B * (1 + 0.5f * amount) + 1 * amount), + color.A); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/LegacySpinner.cs new file mode 100644 index 0000000000..eb9fa85fde --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/LegacySpinner.cs @@ -0,0 +1,126 @@ +// 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.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public abstract class LegacySpinner : CompositeDrawable + { + protected const float SPRITE_SCALE = 0.625f; + + protected DrawableSpinner DrawableSpinner { get; private set; } + + private Sprite spin; + private Sprite clear; + + [BackgroundDependencyLoader] + private void load(DrawableHitObject drawableHitObject, ISkinSource source) + { + RelativeSizeAxes = Axes.Both; + + DrawableSpinner = (DrawableSpinner)drawableHitObject; + + AddRangeInternal(new[] + { + spin = new Sprite + { + 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 + }, + }); + } + + private readonly Bindable completed = new Bindable(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + completed.BindTo(DrawableSpinner.RotationTracker.Complete); + completed.BindValueChanged(onCompletedChanged, true); + + DrawableSpinner.ApplyCustomUpdateState += UpdateStateTransforms; + UpdateStateTransforms(DrawableSpinner, DrawableSpinner.State.Value); + } + + private void onCompletedChanged(ValueChangedEvent completed) + { + if (completed.NewValue) + { + double startTime = Math.Min(Time.Current, DrawableSpinner.HitStateUpdateTime - 400); + + using (BeginAbsoluteSequence(startTime, true)) + { + clear.FadeInFromZero(400, Easing.Out); + + clear.ScaleTo(SPRITE_SCALE * 2) + .Then().ScaleTo(SPRITE_SCALE * 0.8f, 240, Easing.Out) + .Then().ScaleTo(SPRITE_SCALE, 160); + } + + const double fade_out_duration = 50; + using (BeginAbsoluteSequence(DrawableSpinner.HitStateUpdateTime - fade_out_duration, true)) + clear.FadeOut(fade_out_duration); + } + else + { + clear.ClearTransforms(); + clear.Alpha = 0; + } + } + + protected virtual void UpdateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) + { + switch (drawableHitObject) + { + case DrawableSpinner d: + double fadeOutLength = Math.Min(400, d.HitObject.Duration); + + using (BeginAbsoluteSequence(drawableHitObject.HitStateUpdateTime - fadeOutLength, true)) + spin.FadeOutFromOne(fadeOutLength); + break; + + case DrawableSpinnerTick d: + if (state == ArmedState.Hit) + { + using (BeginAbsoluteSequence(d.HitStateUpdateTime, true)) + spin.FadeOut(300); + } + + break; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (DrawableSpinner != null) + DrawableSpinner.ApplyCustomUpdateState -= UpdateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/NonPlayfieldSprite.cs b/osu.Game.Rulesets.Osu/Skinning/NonPlayfieldSprite.cs new file mode 100644 index 0000000000..55257106e2 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/NonPlayfieldSprite.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 osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + /// + /// A sprite which is displayed within the playfield, but historically was not considered part of the playfield. + /// Performs scale adjustment to undo the scale applied by (osu! ruleset specifically). + /// + public class NonPlayfieldSprite : Sprite + { + public override Texture Texture + { + get => base.Texture; + set + { + if (value != null) + // stable "magic ratio". see OsuPlayfieldAdjustmentContainer for full explanation. + value.ScaleAdjust *= 1.6f; + base.Texture = value; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs new file mode 100644 index 0000000000..78bc26eff7 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs @@ -0,0 +1,155 @@ +// 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; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public class OsuLegacySkinTransformer : LegacySkinTransformer + { + private Lazy hasHitCircle; + + /// + /// On osu-stable, hitcircles have 5 pixels of transparent padding on each side to allow for shadows etc. + /// Their hittable area is 128px, but the actual circle portion is 118px. + /// We must account for some gameplay elements such as slider bodies, where this padding is not present. + /// + public const float LEGACY_CIRCLE_RADIUS = 64 - 5; + + public OsuLegacySkinTransformer(ISkinSource source) + : base(source) + { + Source.SourceChanged += sourceChanged; + sourceChanged(); + } + + private void sourceChanged() + { + hasHitCircle = new Lazy(() => Source.GetTexture("hitcircle") != null); + } + + public override Drawable GetDrawableComponent(ISkinComponent component) + { + if (!(component is OsuSkinComponent osuComponent)) + return null; + + 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.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); + + if (sliderBallContent != null) + return new LegacySliderBall(sliderBallContent); + + return null; + + case OsuSkinComponents.SliderBody: + if (hasHitCircle.Value) + return new LegacySliderBody(); + + return null; + + case OsuSkinComponents.SliderTailHitCircle: + if (hasHitCircle.Value) + return new LegacyMainCirclePiece("sliderendcircle", false); + + return null; + + case OsuSkinComponents.SliderHeadHitCircle: + if (hasHitCircle.Value) + return new LegacyMainCirclePiece("sliderstartcircle"); + + return null; + + case OsuSkinComponents.HitCircle: + if (hasHitCircle.Value) + return new LegacyMainCirclePiece(); + + return null; + + case OsuSkinComponents.Cursor: + if (Source.GetTexture("cursor") != null) + return new LegacyCursor(); + + return null; + + case OsuSkinComponents.CursorTrail: + if (Source.GetTexture("cursortrail") != null) + return new LegacyCursorTrail(); + + return null; + + case OsuSkinComponents.HitCircleText: + var font = GetConfig(OsuSkinConfiguration.HitCirclePrefix)?.Value ?? "default"; + var overlap = GetConfig(OsuSkinConfiguration.HitCircleOverlap)?.Value ?? -2; + + return !this.HasFont(font) + ? null + : new LegacySpriteText(Source, font) + { + // 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; + + if (Source.GetTexture("spinner-top") != null && !hasBackground) + return new LegacyNewStyleSpinner(); + else if (hasBackground) + return new LegacyOldStyleSpinner(); + + return null; + } + + return null; + } + + public override IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case OsuSkinColour colour: + return Source.GetConfig(new SkinCustomColourLookup(colour)); + + case OsuSkinConfiguration osuLookup: + switch (osuLookup) + { + case OsuSkinConfiguration.SliderPathRadius: + if (hasHitCircle.Value) + return SkinUtils.As(new BindableFloat(LEGACY_CIRCLE_RADIUS)); + + break; + + case OsuSkinConfiguration.HitCircleOverlayAboveNumber: + // See https://osu.ppy.sh/help/wiki/Skinning/skin.ini#%5Bgeneral%5D + // HitCircleOverlayAboveNumer (with typo) should still be supported for now. + return Source.GetConfig(OsuSkinConfiguration.HitCircleOverlayAboveNumber) ?? + Source.GetConfig(OsuSkinConfiguration.HitCircleOverlayAboveNumer); + } + + break; + } + + return Source.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.cs new file mode 100644 index 0000000000..4e6d3ef0e4 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.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. + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public enum OsuSkinColour + { + SliderTrackOverride, + SliderBorder, + SliderBall + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs new file mode 100644 index 0000000000..63c9b53278 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.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. + +namespace osu.Game.Rulesets.Osu.Skinning +{ + public enum OsuSkinConfiguration + { + HitCirclePrefix, + HitCircleOverlap, + SliderBorderSize, + SliderPathRadius, + AllowSliderBallTint, + CursorExpand, + CursorRotate, + HitCircleOverlayAboveNumber, + HitCircleOverlayAboveNumer, // Some old skins will have this typo + SpinnerFrequencyModulate, + SpinnerNoBlink + } +} diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs new file mode 100644 index 0000000000..88c855d768 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs @@ -0,0 +1,297 @@ +// 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.Shapes; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Statistics +{ + public class AccuracyHeatmap : CompositeDrawable + { + /// + /// Size of the inner circle containing the "hit" points, relative to the size of this . + /// All other points outside of the inner circle are "miss" points. + /// + private const float inner_portion = 0.8f; + + /// + /// Number of rows/columns of points. + /// ~4px per point @ 128x128 size (the contents of the are always square). 1089 total points. + /// + private const int points_per_dimension = 33; + + private const float rotation = 45; + + private BufferedContainer bufferedGrid; + private GridContainer pointGrid; + + private readonly ScoreInfo score; + private readonly IBeatmap playableBeatmap; + + private const float line_thickness = 2; + + /// + /// The highest count of any point currently being displayed. + /// + protected float PeakValue { get; private set; } + + public AccuracyHeatmap(ScoreInfo score, IBeatmap playableBeatmap) + { + this.score = score; + this.playableBeatmap = playableBeatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Children = new Drawable[] + { + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(inner_portion), + Masking = true, + BorderThickness = line_thickness, + BorderColour = Color4.White, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#202624") + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(1), + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + EdgeSmoothness = new Vector2(1), + RelativeSizeAxes = Axes.Y, + Height = 2, // We're rotating along a diagonal - we don't really care how big this is. + Width = line_thickness / 2, + Rotation = -rotation, + Alpha = 0.3f, + }, + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + EdgeSmoothness = new Vector2(1), + RelativeSizeAxes = Axes.Y, + Height = 2, // We're rotating along a diagonal - we don't really care how big this is. + Width = line_thickness / 2, // adjust for edgesmoothness + Rotation = rotation + }, + } + }, + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Width = 10, + EdgeSmoothness = new Vector2(1), + Height = line_thickness / 2, // adjust for edgesmoothness + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + EdgeSmoothness = new Vector2(1), + Width = line_thickness / 2, // adjust for edgesmoothness + Height = 10, + } + } + }, + bufferedGrid = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + CacheDrawnFrameBuffer = true, + BackgroundColour = Color4Extensions.FromHex("#202624").Opacity(0), + Child = pointGrid = new GridContainer + { + RelativeSizeAxes = Axes.Both + } + }, + } + }; + + Vector2 centre = new Vector2(points_per_dimension) / 2; + float innerRadius = centre.X * inner_portion; + + Drawable[][] points = new Drawable[points_per_dimension][]; + + for (int r = 0; r < points_per_dimension; r++) + { + points[r] = new Drawable[points_per_dimension]; + + for (int c = 0; c < points_per_dimension; c++) + { + HitPointType pointType = Vector2.Distance(new Vector2(c, r), centre) <= innerRadius + ? HitPointType.Hit + : HitPointType.Miss; + + var point = new HitPoint(pointType, this) + { + Colour = pointType == HitPointType.Hit ? new Color4(102, 255, 204, 255) : new Color4(255, 102, 102, 255) + }; + + points[r][c] = point; + } + } + + pointGrid.Content = points; + + if (score.HitEvents == null || score.HitEvents.Count == 0) + return; + + // Todo: This should probably not be done like this. + float radius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (playableBeatmap.BeatmapInfo.BaseDifficulty.CircleSize - 5) / 5) / 2; + + foreach (var e in score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle))) + { + if (e.LastHitObject == null || e.Position == null) + continue; + + AddPoint(((OsuHitObject)e.LastHitObject).StackedEndPosition, ((OsuHitObject)e.HitObject).StackedEndPosition, e.Position.Value, radius); + } + } + + protected void AddPoint(Vector2 start, Vector2 end, Vector2 hitPoint, float radius) + { + if (pointGrid.Content.Count == 0) + return; + + double angle1 = Math.Atan2(end.Y - hitPoint.Y, hitPoint.X - end.X); // Angle between the end point and the hit point. + double angle2 = Math.Atan2(end.Y - start.Y, start.X - end.X); // Angle between the end point and the start point. + double finalAngle = angle2 - angle1; // Angle between start, end, and hit points. + float normalisedDistance = Vector2.Distance(hitPoint, end) / radius; + + // Consider two objects placed horizontally, with the start on the left and the end on the right. + // The above calculated the angle between {end, start}, and the angle between {end, hitPoint}, in the form: + // +pi | 0 + // O --------- O -----> Note: Math.Atan2 has a range (-pi <= theta <= +pi) + // -pi | 0 + // E.g. If the hit point was directly above end, it would have an angle pi/2. + // + // It also calculated the angle separating hitPoint from the line joining {start, end}, that is anti-clockwise in the form: + // 0 | pi + // O --------- O -----> + // 2pi | pi + // + // However keep in mind that cos(0)=1 and cos(2pi)=1, whereas we actually want these values to appear on the left, so the x-coordinate needs to be inverted. + // Likewise sin(pi/2)=1 and sin(3pi/2)=-1, whereas we actually want these values to appear on the bottom/top respectively, so the y-coordinate also needs to be inverted. + // + // We also need to apply the anti-clockwise rotation. + var rotatedAngle = finalAngle - MathUtils.DegreesToRadians(rotation); + var rotatedCoordinate = -1 * new Vector2((float)Math.Cos(rotatedAngle), (float)Math.Sin(rotatedAngle)); + + Vector2 localCentre = new Vector2(points_per_dimension - 1) / 2; + float localRadius = localCentre.X * inner_portion * normalisedDistance; // The radius inside the inner portion which of the heatmap which the closest point lies. + Vector2 localPoint = localCentre + localRadius * rotatedCoordinate; + + // Find the most relevant hit point. + int r = Math.Clamp((int)Math.Round(localPoint.Y), 0, points_per_dimension - 1); + int c = Math.Clamp((int)Math.Round(localPoint.X), 0, points_per_dimension - 1); + + PeakValue = Math.Max(PeakValue, ((HitPoint)pointGrid.Content[r][c]).Increment()); + + bufferedGrid.ForceRedraw(); + } + + private class HitPoint : Circle + { + private readonly HitPointType pointType; + private readonly AccuracyHeatmap heatmap; + + public override bool IsPresent => count > 0; + + public HitPoint(HitPointType pointType, AccuracyHeatmap heatmap) + { + this.pointType = pointType; + this.heatmap = heatmap; + + RelativeSizeAxes = Axes.Both; + Alpha = 1; + } + + private int count; + + /// + /// Increment the value of this point by one. + /// + /// The value after incrementing. + public int Increment() + { + return ++count; + } + + protected override void Update() + { + base.Update(); + + // the point at which alpha is saturated and we begin to adjust colour lightness. + const float lighten_cutoff = 0.95f; + + // the amount of lightness to attribute regardless of relative value to peak point. + const float non_relative_portion = 0.2f; + + float amount = 0; + + // give some amount of alpha regardless of relative count + amount += non_relative_portion * Math.Min(1, count / 10f); + + // add relative portion + amount += (1 - non_relative_portion) * (count / heatmap.PeakValue); + + // apply easing + amount = (float)Interpolation.ApplyEasing(Easing.OutQuint, Math.Min(1, amount)); + + Debug.Assert(amount <= 1); + + Alpha = Math.Min(amount / lighten_cutoff, 1); + if (pointType == HitPointType.Hit) + Colour = ((Color4)Colour).Lighten(Math.Max(0, amount - lighten_cutoff)); + } + } + + private enum HitPointType + { + Hit, + Miss + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs index 05eb0ffdbf..0b30c28b8d 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.Input.Events; +using osu.Framework.Layout; using osu.Framework.Timing; using osuTK; using osuTK.Graphics; @@ -20,30 +21,15 @@ using osuTK.Graphics.ES30; namespace osu.Game.Rulesets.Osu.UI.Cursor { - internal class CursorTrail : Drawable, IRequireHighFrequencyMousePosition + public class CursorTrail : Drawable, IRequireHighFrequencyMousePosition { - private int currentIndex; - - private IShader shader; - private Texture texture; - - private Vector2 size => texture.Size * Scale; - - private double timeOffset; - - private float time; - - public override bool IsPresent => true; - private const int max_sprites = 2048; private readonly TrailPart[] parts = new TrailPart[max_sprites]; - - private Vector2? lastPosition; - - private readonly InputResampler resampler = new InputResampler(); - - protected override DrawNode CreateDrawNode() => new TrailDrawNode(this); + private int currentIndex; + private IShader shader; + private double timeOffset; + private float time; public CursorTrail() { @@ -54,20 +40,17 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor for (int i = 0; i < max_sprites; i++) { - // InvalidationID 1 forces an update of each part of the cursor trail the first time ApplyState is run on the draw node - // This is to prevent garbage data from being sent to the vertex shader, resulting in visual issues on some platforms - parts[i].InvalidationID = 1; + // -1 signals that the part is unusable, and should not be drawn + parts[i].InvalidationID = -1; } + + AddLayout(partSizeCache); } - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - [BackgroundDependencyLoader] - private void load(ShaderManager shaders, TextureStore textures) + private void load(ShaderManager shaders) { shader = shaders.Load(@"CursorTrail", FragmentShaderDescriptor.TEXTURE); - texture = textures.Get(@"Cursor/cursortrail"); - Scale = new Vector2(1 / texture.ScaleAdjust); } protected override void LoadComplete() @@ -76,15 +59,43 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor resetTime(); } + private Texture texture = Texture.WhitePixel; + + public Texture Texture + { + get => texture; + set + { + if (texture == value) + return; + + texture = value; + Invalidate(Invalidation.DrawNode); + } + } + + private readonly LayoutValue partSizeCache = new LayoutValue(Invalidation.DrawInfo | Invalidation.RequiredParentSizeToFit | Invalidation.Presence); + + private Vector2 partSize => partSizeCache.IsValid + ? partSizeCache.Value + : (partSizeCache.Value = new Vector2(Texture.DisplayWidth, Texture.DisplayHeight) * DrawInfo.Matrix.ExtractScale().Xy); + + /// + /// The amount of time to fade the cursor trail pieces. + /// + protected virtual double FadeDuration => 300; + + public override bool IsPresent => true; + protected override void Update() { base.Update(); - Invalidate(Invalidation.DrawNode, shallPropagate: false); + Invalidate(Invalidation.DrawNode); const int fade_clock_reset_threshold = 1000000; - time = (float)(Time.Current - timeOffset) / 300f; + time = (float)((Time.Current - timeOffset) / FadeDuration); if (time > fade_clock_reset_threshold) resetTime(); } @@ -94,13 +105,27 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor for (int i = 0; i < parts.Length; ++i) { parts[i].Time -= time; - ++parts[i].InvalidationID; + + if (parts[i].InvalidationID != -1) + ++parts[i].InvalidationID; } time = 0; timeOffset = Time.Current; } + /// + /// Whether to interpolate mouse movements and add trail pieces at intermediate points. + /// + protected virtual bool InterpolateMovements => true; + + protected virtual float IntervalMultiplier => 1.0f; + + private Vector2? lastPosition; + private readonly InputResampler resampler = new InputResampler(); + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + protected override bool OnMouseMove(MouseMoveEvent e) { Vector2 pos = e.ScreenSpaceMousePosition; @@ -116,33 +141,43 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { Trace.Assert(lastPosition.HasValue); - // ReSharper disable once PossibleInvalidOperationException - Vector2 pos1 = lastPosition.Value; - Vector2 diff = pos2 - pos1; - float distance = diff.Length; - Vector2 direction = diff / distance; - - float interval = size.X / 2 * 0.9f; - - for (float d = interval; d < distance; d += interval) + if (InterpolateMovements) { - lastPosition = pos1 + direction * d; - addPosition(lastPosition.Value); + // ReSharper disable once PossibleInvalidOperationException + Vector2 pos1 = lastPosition.Value; + Vector2 diff = pos2 - pos1; + float distance = diff.Length; + Vector2 direction = diff / distance; + + float interval = partSize.X / 2.5f * IntervalMultiplier; + + for (float d = interval; d < distance; d += interval) + { + lastPosition = pos1 + direction * d; + addPart(lastPosition.Value); + } + } + else + { + lastPosition = pos2; + addPart(lastPosition.Value); } } return base.OnMouseMove(e); } - private void addPosition(Vector2 pos) + private void addPart(Vector2 screenSpacePosition) { - parts[currentIndex].Position = pos; - parts[currentIndex].Time = time; + parts[currentIndex].Position = screenSpacePosition; + parts[currentIndex].Time = time + 1; ++parts[currentIndex].InvalidationID; currentIndex = (currentIndex + 1) % max_sprites; } + protected override DrawNode CreateDrawNode() => new TrailDrawNode(this); + private struct TrailPart { public Vector2 Position; @@ -162,13 +197,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private readonly TrailPart[] parts = new TrailPart[max_sprites]; private Vector2 size; - private readonly TrailBatch vertexBatch = new TrailBatch(max_sprites, 1); + private readonly QuadBatch vertexBatch = new QuadBatch(max_sprites, 1); public TrailDrawNode(CursorTrail source) : base(source) { - for (int i = 0; i < max_sprites; i++) - parts[i].InvalidationID = 0; } public override void ApplyState() @@ -177,14 +210,10 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor shader = Source.shader; texture = Source.texture; - size = Source.size; + size = Source.partSize; time = Source.time; - for (int i = 0; i < Source.parts.Length; ++i) - { - if (Source.parts[i].InvalidationID > parts[i].InvalidationID) - parts[i] = Source.parts[i]; - } + Source.parts.CopyTo(parts, 0); } public override void Draw(Action vertexAction) @@ -194,20 +223,56 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor shader.Bind(); shader.GetUniform("g_FadeClock").UpdateValue(ref time); - for (int i = 0; i < parts.Length; ++i) + texture.TextureGL.Bind(); + + RectangleF textureRect = texture.GetTextureRect(); + + foreach (var part in parts) { - vertexBatch.DrawTime = parts[i].Time; + if (part.InvalidationID == -1) + continue; - Vector2 pos = parts[i].Position; + if (time - part.Time >= 1) + continue; - DrawQuad( - texture, - new Quad(pos.X - size.X / 2, pos.Y - size.Y / 2, size.X, size.Y), - DrawColourInfo.Colour, - null, - vertexBatch.AddAction); + vertexBatch.Add(new TexturedTrailVertex + { + Position = new Vector2(part.Position.X - size.X / 2, part.Position.Y + size.Y / 2), + TexturePosition = textureRect.BottomLeft, + TextureRect = new Vector4(0, 0, 1, 1), + Colour = DrawColourInfo.Colour.BottomLeft.Linear, + Time = part.Time + }); + + vertexBatch.Add(new TexturedTrailVertex + { + Position = new Vector2(part.Position.X + size.X / 2, part.Position.Y + size.Y / 2), + TexturePosition = textureRect.BottomRight, + TextureRect = new Vector4(0, 0, 1, 1), + Colour = DrawColourInfo.Colour.BottomRight.Linear, + Time = part.Time + }); + + vertexBatch.Add(new TexturedTrailVertex + { + Position = new Vector2(part.Position.X + size.X / 2, part.Position.Y - size.Y / 2), + TexturePosition = textureRect.TopRight, + TextureRect = new Vector4(0, 0, 1, 1), + Colour = DrawColourInfo.Colour.TopRight.Linear, + Time = part.Time + }); + + vertexBatch.Add(new TexturedTrailVertex + { + Position = new Vector2(part.Position.X - size.X / 2, part.Position.Y - size.Y / 2), + TexturePosition = textureRect.TopLeft, + TextureRect = new Vector4(0, 0, 1, 1), + Colour = DrawColourInfo.Colour.TopLeft.Linear, + Time = part.Time + }); } + vertexBatch.Draw(); shader.Unbind(); } @@ -217,25 +282,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor vertexBatch.Dispose(); } - - // Todo: This shouldn't exist, but is currently used to reduce allocations by caching variable-capturing closures. - private class TrailBatch : QuadBatch - { - public new readonly Action AddAction; - public float DrawTime; - - public TrailBatch(int size, int maxBuffers) - : base(size, maxBuffers) - { - AddAction = v => Add(new TexturedTrailVertex - { - Position = v.Position, - TexturePosition = v.TexturePosition, - Time = DrawTime + 1, - Colour = v.Colour, - }); - } - } } [StructLayout(LayoutKind.Sequential)] @@ -250,6 +296,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor [VertexMember(2, VertexAttribPointerType.Float)] public Vector2 TexturePosition; + [VertexMember(4, VertexAttribPointerType.Float)] + public Vector4 TextureRect; + [VertexMember(1, VertexAttribPointerType.Float)] public float Time; diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs index 27546fa424..eea45c6c80 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs @@ -2,14 +2,12 @@ // 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.Effects; using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps; -using osu.Game.Configuration; +using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -18,120 +16,40 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { public class OsuCursor : SkinReloadableDrawable { + private const float size = 28; + private bool cursorExpand; - private Bindable cursorScale; - private Bindable autoCursorScale; - private readonly IBindable beatmap = new Bindable(); + private SkinnableDrawable cursorSprite; - private Container expandTarget; - private Drawable scaleTarget; + private Drawable expandTarget => (cursorSprite.Drawable as OsuCursorSprite)?.ExpandTarget ?? cursorSprite; public OsuCursor() { Origin = Anchor.Centre; - Size = new Vector2(28); + + Size = new Vector2(size); } protected override void SkinChanged(ISkinSource skin, bool allowFallback) { - cursorExpand = skin.GetValue(s => s.CursorExpand ?? true); + cursorExpand = skin.GetConfig(OsuSkinConfiguration.CursorExpand)?.Value ?? true; } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, IBindable beatmap) + private void load() { - InternalChild = expandTarget = new Container + InternalChild = new Container { RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, Anchor = Anchor.Centre, - Child = scaleTarget = new SkinnableDrawable("cursor", _ => new CircularContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = Size.X / 6, - BorderColour = Color4.White, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Pink.Opacity(0.5f), - Radius = 5, - }, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true, - }, - new CircularContainer - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = Size.X / 3, - BorderColour = Color4.White.Opacity(0.5f), - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true, - }, - }, - }, - new CircularContainer - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Scale = new Vector2(0.1f), - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - }, - }, - } - }, restrictSize: false) + Child = cursorSprite = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.Cursor), _ => new DefaultCursor(), confineMode: ConfineMode.NoScaling) { Origin = Anchor.Centre, Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.Both, } }; - - this.beatmap.BindTo(beatmap); - this.beatmap.ValueChanged += _ => calculateScale(); - - cursorScale = config.GetBindable(OsuSetting.GameplayCursorSize); - cursorScale.ValueChanged += _ => calculateScale(); - - autoCursorScale = config.GetBindable(OsuSetting.AutoCursorSize); - autoCursorScale.ValueChanged += _ => calculateScale(); - - calculateScale(); - } - - private void calculateScale() - { - float scale = (float)cursorScale.Value; - - if (autoCursorScale.Value && beatmap.Value != null) - { - // if we have a beatmap available, let's get its circle size to figure out an automatic cursor scale modifier. - scale *= (float)(1 - 0.7 * (1 + beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize - BeatmapDifficulty.DEFAULT_DIFFICULTY) / BeatmapDifficulty.DEFAULT_DIFFICULTY); - } - - scaleTarget.Scale = new Vector2(scale); } private const float pressed_scale = 1.2f; @@ -141,9 +59,80 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor { if (!cursorExpand) return; - expandTarget.ScaleTo(released_scale).ScaleTo(pressed_scale, 100, Easing.OutQuad); + expandTarget.ScaleTo(released_scale).ScaleTo(pressed_scale, 400, Easing.OutElasticHalf); } - public void Contract() => expandTarget.ScaleTo(released_scale, 100, Easing.OutQuad); + public void Contract() => expandTarget.ScaleTo(released_scale, 400, Easing.OutQuad); + + private class DefaultCursor : OsuCursorSprite + { + public DefaultCursor() + { + RelativeSizeAxes = Axes.Both; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChildren = new[] + { + ExpandTarget = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = size / 6, + BorderColour = Color4.White, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Pink.Opacity(0.5f), + Radius = 5, + }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + new CircularContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderThickness = size / 3, + BorderColour = Color4.White.Opacity(0.5f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + }, + }, + }, + }, + new Circle + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Scale = new Vector2(0.14f), + Colour = new Color4(34, 93, 204, 255), + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Radius = 8, + Colour = Color4.White, + }, + }, + }; + } + } } } diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs index 893c7875fa..5812e8cf75 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs @@ -6,9 +6,15 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Osu.UI.Cursor { @@ -22,26 +28,81 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor private readonly Bindable showTrail = new Bindable(true); - private readonly CursorTrail cursorTrail; + private readonly Drawable cursorTrail; + + public IBindable CursorScale => cursorScale; + + private readonly Bindable cursorScale = new BindableFloat(1); + + private Bindable userCursorScale; + private Bindable autoCursorScale; public OsuCursorContainer() { InternalChild = fadeContainer = new Container { RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - cursorTrail = new CursorTrail { Depth = 1 } - } + Child = cursorTrail = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.CursorTrail), _ => new DefaultCursorTrail(), confineMode: ConfineMode.NoScaling) }; } + [Resolved(canBeNull: true)] + private GameplayBeatmap beatmap { get; set; } + + [Resolved] + private OsuConfigManager config { get; set; } + [BackgroundDependencyLoader(true)] - private void load(OsuRulesetConfigManager config) + private void load(OsuConfigManager config, OsuRulesetConfigManager rulesetConfig) { - config?.BindWith(OsuRulesetSetting.ShowCursorTrail, showTrail); + rulesetConfig?.BindWith(OsuRulesetSetting.ShowCursorTrail, showTrail); + } + + protected override void LoadComplete() + { + base.LoadComplete(); showTrail.BindValueChanged(v => cursorTrail.FadeTo(v.NewValue ? 1 : 0, 200), true); + + userCursorScale = config.GetBindable(OsuSetting.GameplayCursorSize); + userCursorScale.ValueChanged += _ => calculateScale(); + + autoCursorScale = config.GetBindable(OsuSetting.AutoCursorSize); + autoCursorScale.ValueChanged += _ => calculateScale(); + + CursorScale.BindValueChanged(e => + { + var newScale = new Vector2(e.NewValue); + + ActiveCursor.Scale = newScale; + cursorTrail.Scale = newScale; + }, true); + + calculateScale(); + } + + /// + /// Get the scale applicable to the ActiveCursor based on a beatmap's circle size. + /// + public static float GetScaleForCircleSize(float circleSize) => + 1f - 0.7f * (1f + circleSize - BeatmapDifficulty.DEFAULT_DIFFICULTY) / BeatmapDifficulty.DEFAULT_DIFFICULTY; + + private void calculateScale() + { + float scale = userCursorScale.Value; + + if (autoCursorScale.Value && beatmap != null) + { + // if we have a beatmap available, let's get its circle size to figure out an automatic cursor scale modifier. + scale *= GetScaleForCircleSize(beatmap.BeatmapInfo.BaseDifficulty.CircleSize); + } + + cursorScale.Value = scale; + + var newScale = new Vector2(scale); + + ActiveCursor.ScaleTo(newScale, 400, Easing.OutQuint); + cursorTrail.Scale = newScale; } private int downCount; @@ -68,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor return false; } - public bool OnReleased(OsuAction action) + public void OnReleased(OsuAction action) { switch (action) { @@ -81,8 +142,6 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor updateExpandedState(); break; } - - return false; } public override bool HandlePositionalInput => true; // OverlayContainer will set this false when we go hidden, but we always want to receive input. @@ -90,13 +149,23 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor protected override void PopIn() { fadeContainer.FadeTo(1, 300, Easing.OutQuint); - ActiveCursor.ScaleTo(1, 400, Easing.OutQuint); + ActiveCursor.ScaleTo(CursorScale.Value, 400, Easing.OutQuint); } protected override void PopOut() { fadeContainer.FadeTo(0.05f, 450, Easing.OutQuint); - ActiveCursor.ScaleTo(0.8f, 450, Easing.OutQuint); + ActiveCursor.ScaleTo(CursorScale.Value * 0.8f, 450, Easing.OutQuint); + } + + private class DefaultCursorTrail : CursorTrail + { + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Texture = textures.Get(@"Cursor/cursortrail"); + Scale = new Vector2(1 / Texture.ScaleAdjust); + } } } } diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorSprite.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorSprite.cs new file mode 100644 index 0000000000..573c408a78 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorSprite.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.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Rulesets.Osu.UI.Cursor +{ + public abstract class OsuCursorSprite : CompositeDrawable + { + /// + /// The an optional piece of the cursor to expand when in a clicked state. + /// If null, the whole cursor will be affected by expansion. + /// + public Drawable ExpandTarget { get; protected set; } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs index d185d7d4c9..b2299398e1 100644 --- a/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs @@ -14,10 +14,9 @@ using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Replays; -using osu.Game.Rulesets.Osu.Scoring; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; +using osuTK; namespace osu.Game.Rulesets.Osu.UI { @@ -25,18 +24,18 @@ namespace osu.Game.Rulesets.Osu.UI { protected new OsuRulesetConfigManager Config => (OsuRulesetConfigManager)base.Config; - public DrawableOsuRuleset(Ruleset ruleset, WorkingBeatmap beatmap, IReadOnlyList mods) + public DrawableOsuRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) { } - public override ScoreProcessor CreateScoreProcessor() => new OsuScoreProcessor(this); + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; // always show the gameplay cursor protected override Playfield CreatePlayfield() => new OsuPlayfield(); protected override PassThroughInputManager CreateInputManager() => new OsuInputManager(Ruleset.RulesetInfo); - public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer(); + public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new OsuPlayfieldAdjustmentContainer { AlignWithStoryboard = true }; protected override ResumeOverlay CreateResumeOverlay() => new OsuResumeOverlay(); @@ -59,6 +58,8 @@ namespace osu.Game.Rulesets.Osu.UI protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new OsuFramedReplayInputHandler(replay); + protected override ReplayRecorder CreateReplayRecorder(Replay replay) => new OsuReplayRecorder(replay); + public override double GameplayStartTime { get diff --git a/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs new file mode 100644 index 0000000000..8e4f81347d --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.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 System; +using System.Collections.Generic; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.UI +{ + /// + /// Ensures that s are hit in-order. Affectionately known as "note lock". + /// If a is hit out of order: + /// + /// The hit is blocked if it occurred earlier than the previous 's start time. + /// The hit causes all previous s to missed otherwise. + /// + /// + public class OrderedHitPolicy + { + private readonly HitObjectContainer hitObjectContainer; + + public OrderedHitPolicy(HitObjectContainer hitObjectContainer) + { + this.hitObjectContainer = hitObjectContainer; + } + + /// + /// Determines whether a can be hit at a point in time. + /// + /// The to check. + /// The time to check. + /// Whether can be hit at the given . + public bool IsHittable(DrawableHitObject hitObject, double time) + { + DrawableHitObject blockingObject = null; + + foreach (var obj in enumerateHitObjectsUpTo(hitObject.HitObject.StartTime)) + { + if (hitObjectCanBlockFutureHits(obj)) + blockingObject = obj; + } + + // If there is no previous hitobject, allow the hit. + if (blockingObject == null) + return true; + + // A hit is allowed if: + // 1. The last blocking hitobject has been judged. + // 2. The current time is after the last hitobject's start time. + // Hits at exactly the same time as the blocking hitobject are allowed for maps that contain simultaneous hitobjects (e.g. /b/372245). + return blockingObject.Judged || time >= blockingObject.HitObject.StartTime; + } + + /// + /// Handles a being hit to potentially miss all earlier s. + /// + /// The that was hit. + public void HandleHit(DrawableHitObject hitObject) + { + // Hitobjects which themselves don't block future hitobjects don't cause misses (e.g. slider ticks, spinners). + if (!hitObjectCanBlockFutureHits(hitObject)) + return; + + if (!IsHittable(hitObject, hitObject.HitObject.StartTime + hitObject.Result.TimeOffset)) + throw new InvalidOperationException($"A {hitObject} was hit before it became hittable!"); + + foreach (var obj in enumerateHitObjectsUpTo(hitObject.HitObject.StartTime)) + { + if (obj.Judged) + continue; + + if (hitObjectCanBlockFutureHits(obj)) + ((DrawableOsuHitObject)obj).MissForcefully(); + } + } + + /// + /// Whether a blocks hits on future s until its start time is reached. + /// + /// The to test. + private static bool hitObjectCanBlockFutureHits(DrawableHitObject hitObject) + => hitObject is DrawableHitCircle; + + private IEnumerable enumerateHitObjectsUpTo(double targetTime) + { + foreach (var obj in hitObjectContainer.AliveObjects) + { + if (obj.HitObject.StartTime >= targetTime) + yield break; + + yield return obj; + + foreach (var nestedObj in obj.NestedHitObjects) + { + if (nestedObj.HitObject.StartTime >= targetTime) + break; + + yield return nestedObj; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 9037faf606..321eeeab65 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -1,36 +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 osuTK; +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.Graphics.Pooling; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables.Connections; -using osu.Game.Rulesets.UI; -using System.Linq; -using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.UI.Cursor; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Osu.UI { public class OsuPlayfield : Playfield { - private readonly ApproachCircleProxyContainer approachCircles; + private readonly PlayfieldBorder playfieldBorder; + private readonly ProxyContainer approachCircles; + private readonly ProxyContainer spinnerProxies; private readonly JudgementContainer judgementLayer; - private readonly ConnectionRenderer connectionLayer; + private readonly FollowPointRenderer followPoints; + private readonly OrderedHitPolicy hitPolicy; public static readonly Vector2 BASE_SIZE = new Vector2(512, 384); protected override GameplayCursorContainer CreateCursor() => new OsuCursorContainer(); + private readonly Bindable playfieldBorderStyle = new BindableBool(); + + private readonly IDictionary> poolDictionary = new Dictionary>(); + public OsuPlayfield() { InternalChildren = new Drawable[] { - connectionLayer = new FollowPointRenderer + playfieldBorder = new PlayfieldBorder + { + RelativeSizeAxes = Axes.Both, + Depth = 3 + }, + spinnerProxies = new ProxyContainer + { + RelativeSizeAxes = Axes.Both + }, + followPoints = new FollowPointRenderer { RelativeSizeAxes = Axes.Both, Depth = 2, @@ -42,68 +66,105 @@ namespace osu.Game.Rulesets.Osu.UI }, // Todo: This should not exist, but currently helps to reduce LOH allocations due to unbinding skin source events on judgement disposal // Todo: Remove when hitobjects are properly pooled - new LocalSkinOverrideContainer(null) + new SkinProvidingContainer(null) { - RelativeSizeAxes = Axes.Both, Child = HitObjectContainer, }, - approachCircles = new ApproachCircleProxyContainer + approachCircles = new ProxyContainer { RelativeSizeAxes = Axes.Both, Depth = -1, }, }; + + hitPolicy = new OrderedHitPolicy(HitObjectContainer); + + 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)); + + AddRangeInternal(poolDictionary.Values); + } + + [BackgroundDependencyLoader(true)] + private void load(OsuRulesetConfigManager config) + { + config?.BindWith(OsuRulesetSetting.PlayfieldBorderStyle, playfieldBorder.PlayfieldBorderStyle); } public override void Add(DrawableHitObject h) { + DrawableOsuHitObject osuHitObject = (DrawableOsuHitObject)h; + h.OnNewResult += onNewResult; - - if (h is IDrawableHitObjectWithProxiedApproach c) + h.OnLoadComplete += d => { - var original = c.ProxiedLayer; + if (d is DrawableSpinner) + spinnerProxies.Add(d.CreateProxy()); - // Hitobjects only have lifetimes set on LoadComplete. For nested hitobjects (e.g. SliderHeads), this only happens when the parenting slider becomes visible. - // This delegation is required to make sure that the approach circles for those not-yet-loaded objects aren't added prematurely. - original.OnLoadComplete += addApproachCircleProxy; - } + if (d is IDrawableHitObjectWithProxiedApproach c) + approachCircles.Add(c.ProxiedLayer.CreateProxy()); + }; base.Add(h); + + osuHitObject.CheckHittable = hitPolicy.IsHittable; + + followPoints.AddFollowPoints(osuHitObject.HitObject); } - private void addApproachCircleProxy(Drawable d) + public override bool Remove(DrawableHitObject h) { - var proxy = d.CreateProxy(); - proxy.LifetimeStart = d.LifetimeStart; - proxy.LifetimeEnd = d.LifetimeEnd; - approachCircles.Add(proxy); - } + DrawableOsuHitObject osuHitObject = (DrawableOsuHitObject)h; - public override void PostProcess() - { - connectionLayer.HitObjects = HitObjectContainer.Objects.Select(d => d.HitObject).OfType(); + bool result = base.Remove(h); + + if (result) + followPoints.RemoveFollowPoints(osuHitObject.HitObject); + + return result; } private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) { + // Hitobjects that block future hits should miss previous hitobjects if they're hit out-of-order. + hitPolicy.HandleHit(judgedObject); + if (!judgedObject.DisplayResult || !DisplayJudgements.Value) return; - DrawableOsuJudgement explosion = new DrawableOsuJudgement(result, judgedObject) - { - Origin = Anchor.Centre, - Position = ((OsuHitObject)judgedObject.HitObject).StackedEndPosition, - Scale = new Vector2(((OsuHitObject)judgedObject.HitObject).Scale * 1.65f) - }; + DrawableOsuJudgement explosion = poolDictionary[result.Type].Get(doj => doj.Apply(result, judgedObject)); judgementLayer.Add(explosion); } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => HitObjectContainer.ReceivePositionalInputAt(screenSpacePos); - private class ApproachCircleProxyContainer : LifetimeManagementContainer + private class ProxyContainer : LifetimeManagementContainer { - public void Add(Drawable approachCircleProxy) => AddInternal(approachCircleProxy); + public void Add(Drawable proxy) => AddInternal(proxy); + } + + private class DrawableJudgementPool : DrawablePool + { + private readonly HitResult result; + + public DrawableJudgementPool(HitResult result) + : base(10) + { + this.result = result; + } + + protected override DrawableOsuJudgement CreateNewDrawable() + { + var judgement = base.CreateNewDrawable(); + + // just a placeholder to initialise the correct drawable hierarchy for this pool. + judgement.Apply(new JudgementResult(new HitObject(), new Judgement()) { Type = result }, null); + + return judgement; + } } } } diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs index e28ff5f460..0d1a5a8304 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfieldAdjustmentContainer.cs @@ -11,14 +11,26 @@ namespace osu.Game.Rulesets.Osu.UI public class OsuPlayfieldAdjustmentContainer : PlayfieldAdjustmentContainer { protected override Container Content => content; - private readonly Container content; + private readonly ScalingContainer content; + + private const float playfield_size_adjust = 0.8f; + + /// + /// When true, an offset is applied to allow alignment with historical storyboards displayed in the same parent space. + /// This will shift the playfield downwards slightly. + /// + public bool AlignWithStoryboard + { + set => content.PlayfieldShift = value; + } public OsuPlayfieldAdjustmentContainer() { Anchor = Anchor.Centre; Origin = Anchor.Centre; - Size = new Vector2(0.75f); + // Calculated from osu!stable as 512 (default gamefield size) / 640 (default window size) + Size = new Vector2(playfield_size_adjust); InternalChild = new Container { @@ -36,11 +48,26 @@ namespace osu.Game.Rulesets.Osu.UI /// private class ScalingContainer : Container { + internal bool PlayfieldShift { get; set; } + protected override void Update() { base.Update(); + // The following calculation results in a constant of 1.6 when OsuPlayfieldAdjustmentContainer + // is consuming the full game_size. This matches the osu-stable "magic ratio". + // + // game_size = DrawSizePreservingFillContainer.TargetSize = new Vector2(1024, 768) + // + // Parent is a 4:3 aspect enforced, using height as the constricting dimension + // Parent.ChildSize.X = min(game_size.X, game_size.Y * (4 / 3)) * playfield_size_adjust + // Parent.ChildSize.X = 819.2 + // + // Scale = 819.2 / 512 + // Scale = 1.6 Scale = new Vector2(Parent.ChildSize.X / OsuPlayfield.BASE_SIZE.X); + Position = new Vector2(0, (PlayfieldShift ? 8f : 0f) * Scale.X); + // Size = 0.625 Size = Vector2.Divide(Vector2.One, Scale); } } diff --git a/osu.Game.Rulesets.Osu/UI/OsuReplayRecorder.cs b/osu.Game.Rulesets.Osu/UI/OsuReplayRecorder.cs new file mode 100644 index 0000000000..b68ea136d5 --- /dev/null +++ b/osu.Game.Rulesets.Osu/UI/OsuReplayRecorder.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.Collections.Generic; +using osu.Game.Replays; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.UI +{ + public class OsuReplayRecorder : ReplayRecorder + { + public OsuReplayRecorder(Replay replay) + : base(replay) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) + => new OsuReplayFrame(Time.Current, mousePosition, actions.ToArray()); + } +} diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index 9e5df0d6b1..ec7751d2b4 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -1,15 +1,15 @@ -// 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 osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Rulesets.Osu.UI.Cursor; -using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osuTK; using osuTK.Graphics; @@ -18,9 +18,11 @@ namespace osu.Game.Rulesets.Osu.UI { public class OsuResumeOverlay : ResumeOverlay { + private Container cursorScaleContainer; private OsuClickToResumeCursor clickToResumeCursor; - private GameplayCursorContainer localCursorContainer; + private OsuCursorContainer localCursorContainer; + private IBindable localCursorScale; public override CursorContainer LocalCursor => State.Value == Visibility.Visible ? localCursorContainer : null; @@ -29,24 +31,38 @@ namespace osu.Game.Rulesets.Osu.UI [BackgroundDependencyLoader] private void load() { - Add(clickToResumeCursor = new OsuClickToResumeCursor { ResumeRequested = Resume }); + Add(cursorScaleContainer = new Container + { + RelativePositionAxes = Axes.Both, + Child = clickToResumeCursor = new OsuClickToResumeCursor { ResumeRequested = Resume } + }); } - public override void Show() + protected override void PopIn() { - base.Show(); - clickToResumeCursor.ShowAt(GameplayCursor.ActiveCursor.Position); + base.PopIn(); + + GameplayCursor.ActiveCursor.Hide(); + cursorScaleContainer.MoveTo(GameplayCursor.ActiveCursor.Position); + clickToResumeCursor.Appear(); if (localCursorContainer == null) + { Add(localCursorContainer = new OsuCursorContainer()); + + localCursorScale = new BindableFloat(); + localCursorScale.BindTo(localCursorContainer.CursorScale); + localCursorScale.BindValueChanged(scale => cursorScaleContainer.Scale = new Vector2(scale.NewValue), true); + } } - public override void Hide() + protected override void PopOut() { + base.PopOut(); + localCursorContainer?.Expire(); localCursorContainer = null; - - base.Hide(); + GameplayCursor?.ActiveCursor?.Show(); } protected override bool OnHover(HoverEvent e) => true; @@ -82,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.UI case OsuAction.RightButton: if (!IsHovered) return false; - this.ScaleTo(new Vector2(2), TRANSITION_TIME, Easing.OutQuint); + this.ScaleTo(2, TRANSITION_TIME, Easing.OutQuint); ResumeRequested?.Invoke(); return true; @@ -91,13 +107,14 @@ namespace osu.Game.Rulesets.Osu.UI return false; } - public bool OnReleased(OsuAction action) => false; + public void OnReleased(OsuAction action) + { + } - public void ShowAt(Vector2 activeCursorPosition) => Schedule(() => + public void Appear() => Schedule(() => { updateColour(); - this.MoveTo(activeCursorPosition); - this.ScaleTo(new Vector2(4)).Then().ScaleTo(Vector2.One, 1000, Easing.OutQuint); + this.ScaleTo(4).Then().ScaleTo(1, 1000, Easing.OutQuint); }); private void updateColour() diff --git a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs index 88adf72551..705ba3e929 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Osu.Configuration; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.UI { @@ -27,17 +28,22 @@ namespace osu.Game.Rulesets.Osu.UI new SettingsCheckbox { LabelText = "Snaking in sliders", - Bindable = config.GetBindable(OsuRulesetSetting.SnakingInSliders) + Current = config.GetBindable(OsuRulesetSetting.SnakingInSliders) }, new SettingsCheckbox { LabelText = "Snaking out sliders", - Bindable = config.GetBindable(OsuRulesetSetting.SnakingOutSliders) + Current = config.GetBindable(OsuRulesetSetting.SnakingOutSliders) }, new SettingsCheckbox { LabelText = "Cursor trail", - Bindable = config.GetBindable(OsuRulesetSetting.ShowCursorTrail) + Current = config.GetBindable(OsuRulesetSetting.ShowCursorTrail) + }, + new SettingsEnumDropdown + { + LabelText = "Playfield border style", + Current = config.GetBindable(OsuRulesetSetting.PlayfieldBorderStyle), }, }; } diff --git a/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj b/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj index b0ca314551..bffeaabb55 100644 --- a/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj +++ b/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj @@ -1,9 +1,7 @@  - - netstandard2.0 + netstandard2.1 Library - AnyCPU true click the circles. to the beat. diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/Properties/AndroidManifest.xml b/osu.Game.Rulesets.Taiko.Tests.Android/Properties/AndroidManifest.xml index cd4b74aa16..d9de0fde4e 100644 --- a/osu.Game.Rulesets.Taiko.Tests.Android/Properties/AndroidManifest.xml +++ b/osu.Game.Rulesets.Taiko.Tests.Android/Properties/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/Application.cs b/osu.Game.Rulesets.Taiko.Tests.iOS/Application.cs index 330cb42901..73faf16d9f 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/Application.cs +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/Application.cs @@ -5,7 +5,7 @@ using UIKit; namespace osu.Game.Rulesets.Taiko.Tests.iOS { - public class Application + public static class Application { public static void Main(string[] args) { diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj b/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj index d2817b743c..8ee640cd99 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/osu.Game.Rulesets.Taiko.Tests.iOS.csproj @@ -1,6 +1,5 @@ - + - Debug iPhoneSimulator @@ -13,14 +12,6 @@ - - libbass.a - PreserveNewest - - - libbass_fx.a - PreserveNewest - Linker.xml @@ -41,5 +32,4 @@ - \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko.Tests/.vscode/launch.json b/osu.Game.Rulesets.Taiko.Tests/.vscode/launch.json index 3ef78bf6c8..5b02ecfc91 100644 --- a/osu.Game.Rulesets.Taiko.Tests/.vscode/launch.json +++ b/osu.Game.Rulesets.Taiko.Tests/.vscode/launch.json @@ -7,7 +7,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/bin/Debug/netcoreapp2.2/osu.Game.Rulesets.Taiko.Tests.dll" + "${workspaceRoot}/bin/Debug/netcoreapp3.1/osu.Game.Rulesets.Taiko.Tests.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build (Debug)", @@ -20,7 +20,7 @@ "request": "launch", "program": "dotnet", "args": [ - "${workspaceRoot}/bin/Release/netcoreapp2.2/osu.Game.Rulesets.Taiko.Tests.dll" + "${workspaceRoot}/bin/Release/netcoreapp3.1/osu.Game.Rulesets.Taiko.Tests.dll" ], "cwd": "${workspaceRoot}", "preLaunchTask": "Build (Release)", diff --git a/osu.Game.Rulesets.Taiko.Tests/.vscode/tasks.json b/osu.Game.Rulesets.Taiko.Tests/.vscode/tasks.json index 9b91f2c9b9..09340f6f9f 100644 --- a/osu.Game.Rulesets.Taiko.Tests/.vscode/tasks.json +++ b/osu.Game.Rulesets.Taiko.Tests/.vscode/tasks.json @@ -11,9 +11,9 @@ "build", "--no-restore", "osu.Game.Rulesets.Taiko.Tests.csproj", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" @@ -26,10 +26,10 @@ "build", "--no-restore", "osu.Game.Rulesets.Taiko.Tests.csproj", - "/p:Configuration=Release", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.cs new file mode 100644 index 0000000000..d1c4a1c56d --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTaikoRulesetTestScene.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public abstract class DrawableTaikoRulesetTestScene : OsuTestScene + { + protected DrawableTaikoRuleset DrawableRuleset { get; private set; } + protected Container PlayfieldContainer { get; private set; } + + [BackgroundDependencyLoader] + private void load() + { + var controlPointInfo = new ControlPointInfo(); + controlPointInfo.Add(0, new TimingControlPoint()); + + WorkingBeatmap beatmap = CreateWorkingBeatmap(new Beatmap + { + HitObjects = new List { new Hit { Type = HitType.Centre } }, + BeatmapInfo = new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty(), + Metadata = new BeatmapMetadata + { + Artist = @"Unknown", + Title = @"Sample Beatmap", + AuthorString = @"peppy", + }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + ControlPointInfo = controlPointInfo + }); + + Add(PlayfieldContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 768, + Children = new[] { DrawableRuleset = new DrawableTaikoRuleset(new TaikoRuleset(), beatmap.GetPlayableBeatmap(new TaikoRuleset().RulesetInfo)) } + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.cs new file mode 100644 index 0000000000..fb0917341e --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTestHit.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 osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class DrawableTestHit : DrawableHit + { + public readonly HitResult Type; + + public DrawableTestHit(Hit hit, HitResult type = HitResult.Great) + : base(hit) + { + Type = type; + + HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + } + + protected override void UpdateInitialTransforms() + { + // base implementation in DrawableHitObject forces alpha to 1. + // suppress locally to allow hiding the visuals wherever necessary. + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Result.Type = Type; + } + + public override bool OnPressed(TaikoAction action) => false; + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs new file mode 100644 index 0000000000..829bcf34a1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.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.Linq; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class DrawableTestStrongHit : DrawableTestHit + { + private readonly bool hitBoth; + + public DrawableTestStrongHit(double startTime, HitResult type = HitResult.Great, bool hitBoth = true) + : base(new Hit + { + IsStrong = true, + StartTime = startTime, + }, type) + { + this.hitBoth = hitBoth; + } + + protected override void LoadAsyncComplete() + { + base.LoadAsyncComplete(); + + var nestedStrongHit = (DrawableStrongNestedHit)NestedHitObjects.Single(); + nestedStrongHit.Result.Type = hitBoth ? Type : HitResult.Miss; + } + + public override bool OnPressed(TaikoAction action) => false; + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditor.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditor.cs new file mode 100644 index 0000000000..e3c1613bd9 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneEditor.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.Taiko.Tests.Editor +{ + [TestFixture] + public class TestSceneEditor : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new TaikoRuleset(); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.cs new file mode 100644 index 0000000000..626537053a --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Editor/TestSceneTaikoHitObjectComposer.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Edit; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Editor +{ + public class TestSceneTaikoHitObjectComposer : EditorClockTestScene + { + [SetUp] + public void Setup() => Schedule(() => + { + BeatDivisor.Value = 8; + Clock.Seek(0); + + Child = new TestComposer { RelativeSizeAxes = Axes.Both }; + }); + + [Test] + public void BasicTest() + { + } + + private class TestComposer : CompositeDrawable + { + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + public readonly EditorBeatmap EditorBeatmap; + + public TestComposer() + { + InternalChildren = new Drawable[] + { + EditorBeatmap = new EditorBeatmap(new TaikoBeatmap()) + { + BeatmapInfo = { Ruleset = new TaikoRuleset().RulesetInfo } + }, + new TaikoHitObjectComposer(new TaikoRuleset()) + }; + + for (int i = 0; i < 10; i++) + EditorBeatmap.Add(new Hit { StartTime = 125 * i }); + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.cs new file mode 100644 index 0000000000..a83cc16413 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModPerfect.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 NUnit.Framework; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Scoring; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public class TestSceneTaikoModPerfect : ModPerfectTestScene + { + protected override Ruleset CreatePlayerRuleset() => new TestTaikoRuleset(); + + public TestSceneTaikoModPerfect() + : base(new TaikoModPerfect()) + { + } + + [TestCase(false)] + [TestCase(true)] + public void TestHit(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Hit { StartTime = 1000, Type = HitType.Centre }), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestDrumRoll(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new DrumRoll { StartTime = 1000, EndTime = 3000 }), shouldMiss); + + [TestCase(false)] + [TestCase(true)] + public void TestSwell(bool shouldMiss) => CreateHitObjectTest(new HitObjectTestData(new Swell { StartTime = 1000, EndTime = 3000 }), shouldMiss); + + private class TestTaikoRuleset : TaikoRuleset + { + public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new TestTaikoHealthProcessor(); + + private class TestTaikoHealthProcessor : TaikoHealthProcessor + { + protected override void Reset(bool storeResults) + { + base.Reset(storeResults); + + Health.Value = 1; // Don't care about the health condition (only the mod condition) + } + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/SampleLookups/taiko-hitobject-beatmap-custom-sample-bank.osu b/osu.Game.Rulesets.Taiko.Tests/Resources/SampleLookups/taiko-hitobject-beatmap-custom-sample-bank.osu new file mode 100644 index 0000000000..f9755782c2 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Resources/SampleLookups/taiko-hitobject-beatmap-custom-sample-bank.osu @@ -0,0 +1,10 @@ +osu file format v14 + +[General] +Mode: 1 + +[TimingPoints] +0,300,4,1,2,100,1,0 + +[HitObjects] +444,320,1000,5,0,0:0:0:0: diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/approachcircle@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/approachcircle@2x.png new file mode 100644 index 0000000000..72ef665478 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/approachcircle@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-left@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-left@2x.png new file mode 100644 index 0000000000..dc3d7f4c70 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-left@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-right@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-right@2x.png new file mode 100644 index 0000000000..5ca8a40d88 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-bar-right@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-barline@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-barline@2x.png new file mode 100644 index 0000000000..3e44f33095 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-barline@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-inner@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-inner@2x.png new file mode 100644 index 0000000000..15a89ade1b Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-inner@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-outer@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-outer@2x.png new file mode 100644 index 0000000000..a01583c6fb Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-drum-outer@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider-fail@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider-fail@2x.png new file mode 100644 index 0000000000..ac0fef8626 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider-fail@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider@2x.png new file mode 100644 index 0000000000..cca9310322 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taiko-slider@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikobigcircle@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikobigcircle@2x.png new file mode 100644 index 0000000000..440e5b55e5 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikobigcircle@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircle@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircle@2x.png new file mode 100644 index 0000000000..043bfbfae1 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircle@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircleoverlay@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircleoverlay@2x.png new file mode 100644 index 0000000000..4233d9bb6e Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/metrics-skin/taikohitcircleoverlay@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/approachcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/approachcircle.png new file mode 100755 index 0000000000..5aba688756 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/approachcircle.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/skin.ini b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/skin.ini new file mode 100644 index 0000000000..462c2c278e --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/skin.ini @@ -0,0 +1,5 @@ +[General] +Name: an old skin +Author: an old guy + +// no version specified means v1 \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-bar-left.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-bar-left.png new file mode 100644 index 0000000000..ad55fd5a96 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-bar-left.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-inner.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-inner.png new file mode 100644 index 0000000000..f5c02509fb Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-inner.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-outer.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-outer.png new file mode 100644 index 0000000000..53905792cb Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-drum-outer.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider-fail@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider-fail@2x.png new file mode 100644 index 0000000000..2d9974a701 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider-fail@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider@2x.png new file mode 100644 index 0000000000..07b2f167e0 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taiko-slider@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircle.png new file mode 100644 index 0000000000..63504dd52d Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircle.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-0.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-0.png new file mode 100644 index 0000000000..490c196fba Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-0.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-1.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-1.png new file mode 100644 index 0000000000..99cd589a10 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikobigcircleoverlay-1.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircle.png new file mode 100644 index 0000000000..26eec54d07 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircle.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-0.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-0.png new file mode 100644 index 0000000000..272c6bcaf7 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-0.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-1.png b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-1.png new file mode 100644 index 0000000000..e49e82a71f Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/old-skin/taikohitcircleoverlay-1.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/approachcircle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/approachcircle.png new file mode 100644 index 0000000000..56d6d34c1a Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/approachcircle.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonclear.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonclear.png new file mode 100644 index 0000000000..c5bcdbd3fc Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonclear.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonfail.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonfail.png new file mode 100644 index 0000000000..39cf737285 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonfail.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonidle.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonidle.png new file mode 100644 index 0000000000..4c3b2bfec9 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonidle.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonkiai.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonkiai.png new file mode 100644 index 0000000000..7de00b5390 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/pippidonkiai.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider-fail.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider-fail.png new file mode 100644 index 0000000000..78c6ef6e21 Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider-fail.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider.png new file mode 100644 index 0000000000..b824e4585b Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taiko-slider.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taikobigcircle@2x.png b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taikobigcircle@2x.png new file mode 100644 index 0000000000..5d8b60da9e Binary files /dev/null and b/osu.Game.Rulesets.Taiko.Tests/Resources/special-skin/taikobigcircle@2x.png differ diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TaikoSkinnableTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TaikoSkinnableTestScene.cs new file mode 100644 index 0000000000..69250a14e1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TaikoSkinnableTestScene.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.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + public abstract class TaikoSkinnableTestScene : SkinnableTestScene + { + protected override Ruleset CreateRulesetForSkinProvider() => new TaikoRuleset(); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs new file mode 100644 index 0000000000..f6aec20d53 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableBarLine.cs @@ -0,0 +1,100 @@ +// 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.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableBarLine : TaikoSkinnableTestScene + { + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo + { + Direction = { Value = ScrollingDirection.Left }, + TimeRange = { Value = 5000 }, + }; + + [BackgroundDependencyLoader] + private void load() + { + AddStep("Bar line", () => SetContents(() => + { + ScrollingHitObjectContainer hoc; + + var cont = new Container + { + RelativeSizeAxes = Axes.Both, + Height = 0.8f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new TaikoPlayfield(new ControlPointInfo()), + hoc = new ScrollingHitObjectContainer() + } + }; + + hoc.Add(new DrawableBarLine(createBarLineAtCurrentTime()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + return cont; + })); + + AddStep("Bar line (major)", () => SetContents(() => + { + ScrollingHitObjectContainer hoc; + + var cont = new Container + { + RelativeSizeAxes = Axes.Both, + Height = 0.8f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new TaikoPlayfield(new ControlPointInfo()), + hoc = new ScrollingHitObjectContainer() + } + }; + + hoc.Add(new DrawableBarLineMajor(createBarLineAtCurrentTime(true)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + return cont; + })); + } + + private BarLine createBarLineAtCurrentTime(bool major = false) + { + var barline = new BarLine + { + Major = major, + StartTime = Time.Current + 2000, + }; + + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 500 }); + + barline.ApplyDefaults(cpi, new BeatmapDifficulty()); + + return barline; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.cs new file mode 100644 index 0000000000..44646e5fc9 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableDrumRoll.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.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableDrumRoll : TaikoSkinnableTestScene + { + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo + { + Direction = { Value = ScrollingDirection.Left }, + TimeRange = { Value = 5000 }, + }; + + [BackgroundDependencyLoader] + private void load() + { + AddStep("Drum roll", () => SetContents(() => + { + var hoc = new ScrollingHitObjectContainer(); + + hoc.Add(new DrawableDrumRoll(createDrumRollAtCurrentTime()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + }); + + return hoc; + })); + + AddStep("Drum roll (strong)", () => SetContents(() => + { + var hoc = new ScrollingHitObjectContainer(); + + hoc.Add(new DrawableDrumRoll(createDrumRollAtCurrentTime(true)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + }); + + return hoc; + })); + } + + private DrumRoll createDrumRollAtCurrentTime(bool strong = false) + { + var drumroll = new DrumRoll + { + IsStrong = strong, + StartTime = Time.Current + 1000, + Duration = 4000, + }; + + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 500 }); + + drumroll.ApplyDefaults(cpi, new BeatmapDifficulty()); + + return drumroll; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.cs new file mode 100644 index 0000000000..9930d97d31 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableHit.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.Allocation; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableHit : TaikoSkinnableTestScene + { + [BackgroundDependencyLoader] + private void load() + { + AddStep("Centre hit", () => SetContents(() => new DrawableHit(createHitAtCurrentTime()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + + AddStep("Centre hit (strong)", () => SetContents(() => new DrawableHit(createHitAtCurrentTime(true)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + + AddStep("Rim hit", () => SetContents(() => new DrawableHit(createHitAtCurrentTime()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + + AddStep("Rim hit (strong)", () => SetContents(() => new DrawableHit(createHitAtCurrentTime(true)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + })); + } + + private Hit createHitAtCurrentTime(bool strong = false) + { + var hit = new Hit + { + IsStrong = strong, + StartTime = Time.Current + 3000, + }; + + hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + return hit; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs new file mode 100644 index 0000000000..99e103da3b --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs @@ -0,0 +1,229 @@ +// 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 Humanizer; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Testing; +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; +using osu.Game.Rulesets.Taiko.Scoring; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneDrawableTaikoMascot : TaikoSkinnableTestScene + { + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo + { + Direction = { Value = ScrollingDirection.Left }, + TimeRange = { Value = 5000 }, + }; + + private TaikoScoreProcessor scoreProcessor; + + private IEnumerable mascots => this.ChildrenOfType(); + + private IEnumerable animatedMascots => + mascots.Where(mascot => mascot.ChildrenOfType().All(animation => animation.FrameCount > 0)); + + private IEnumerable playfields => this.ChildrenOfType(); + + [SetUp] + public void SetUp() + { + scoreProcessor = new TaikoScoreProcessor(); + } + + [Test] + public void TestStateAnimations() + { + 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))); + } + + [Test] + public void TestInitialState() + { + AddStep("create mascot", () => SetContents(() => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both })); + + AddAssert("mascot initially idle", () => allMascotsIn(TaikoMascotAnimationState.Idle)); + } + + [Test] + public void TestClearStateTransition() + { + AddStep("set beatmap", () => setBeatmap()); + + 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 })); + AddAssert("skins with animations remain in clear state", () => animatedMascotsIn(TaikoMascotAnimationState.Clear)); + AddUntilStep("state reverts to fail", () => allMascotsIn(TaikoMascotAnimationState.Fail)); + + AddStep("set clear state again", () => mascots.ForEach(mascot => mascot.State.Value = TaikoMascotAnimationState.Clear)); + AddAssert("skins with animations change to clear", () => animatedMascotsIn(TaikoMascotAnimationState.Clear)); + } + + [Test] + public void TestIdleState() + { + AddStep("set beatmap", () => setBeatmap()); + + createDrawableRuleset(); + + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); + assertStateAfterResult(new JudgementResult(new StrongHitObject(), new TaikoStrongJudgement()) { Type = HitResult.IgnoreMiss }, TaikoMascotAnimationState.Idle); + } + + [Test] + public void TestKiaiState() + { + AddStep("set beatmap", () => setBeatmap(true)); + + createDrawableRuleset(); + + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Ok }, TaikoMascotAnimationState.Kiai); + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoStrongJudgement()) { Type = HitResult.IgnoreMiss }, TaikoMascotAnimationState.Kiai); + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Fail); + } + + [Test] + public void TestMissState() + { + AddStep("set beatmap", () => setBeatmap()); + + createDrawableRuleset(); + + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Fail); + assertStateAfterResult(new JudgementResult(new DrumRoll(), new TaikoDrumRollJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Ok }, TaikoMascotAnimationState.Idle); + } + + [TestCase(true)] + [TestCase(false)] + public void TestClearStateOnComboMilestone(bool kiai) + { + AddStep("set beatmap", () => setBeatmap(kiai)); + + createDrawableRuleset(); + + AddRepeatStep("reach 49 combo", () => applyNewResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }), 49); + + assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Ok }, TaikoMascotAnimationState.Clear); + } + + [TestCase(true, TaikoMascotAnimationState.Kiai)] + [TestCase(false, TaikoMascotAnimationState.Idle)] + public void TestClearStateOnClearedSwell(bool kiai, TaikoMascotAnimationState expectedStateAfterClear) + { + AddStep("set beatmap", () => setBeatmap(kiai)); + + createDrawableRuleset(); + + assertStateAfterResult(new JudgementResult(new Swell(), new TaikoSwellJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Clear); + AddUntilStep($"state reverts to {expectedStateAfterClear.ToString().ToLower()}", () => allMascotsIn(expectedStateAfterClear)); + } + + private void setBeatmap(bool kiai = false) + { + var controlPointInfo = new ControlPointInfo(); + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 90 }); + + if (kiai) + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + HitObjects = new List { new Hit { Type = HitType.Centre } }, + BeatmapInfo = new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty(), + Metadata = new BeatmapMetadata + { + Artist = "Unknown", + Title = "Sample Beatmap", + AuthorString = "Craftplacer", + }, + Ruleset = new TaikoRuleset().RulesetInfo + }, + ControlPointInfo = controlPointInfo + }); + + scoreProcessor.ApplyBeatmap(Beatmap.Value.Beatmap); + } + + private void createDrawableRuleset() + { + AddUntilStep("wait for beatmap to be loaded", () => Beatmap.Value.Track.IsLoaded); + + AddStep("create drawable ruleset", () => + { + Beatmap.Value.Track.Start(); + + SetContents(() => + { + var ruleset = new TaikoRuleset(); + return new DrawableTaikoRuleset(ruleset, Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo)); + }); + }); + } + + private void assertStateAfterResult(JudgementResult judgementResult, TaikoMascotAnimationState expectedState) + { + TaikoMascotAnimationState[] mascotStates = null; + + AddStep($"{judgementResult.Type.ToString().ToLower()} result for {judgementResult.Judgement.GetType().Name.Humanize(LetterCasing.LowerCase)}", + () => + { + applyNewResult(judgementResult); + // store the states as soon as possible, so that the delay between steps doesn't incorrectly fail the test + // due to not checking if the state changed quickly enough. + Schedule(() => mascotStates = animatedMascots.Select(mascot => mascot.State.Value).ToArray()); + }); + + AddAssert($"state is {expectedState.ToString().ToLower()}", () => mascotStates.All(state => state == expectedState)); + } + + private void applyNewResult(JudgementResult judgementResult) + { + scoreProcessor.ApplyResult(judgementResult); + + foreach (var playfield in playfields) + { + var hit = new DrawableTestHit(new Hit(), judgementResult.Type); + playfield.Add(hit); + + playfield.OnNewResult(hit, judgementResult); + } + + foreach (var mascot in mascots) + { + mascot.LastResult.Value = judgementResult; + } + } + + private bool allMascotsIn(TaikoMascotAnimationState state) => mascots.All(d => d.State.Value == state); + private bool animatedMascotsIn(TaikoMascotAnimationState state) => animatedMascots.Any(d => d.State.Value == state); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs new file mode 100644 index 0000000000..fecb5d4a74 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + [TestFixture] + public class TestSceneHitExplosion : TaikoSkinnableTestScene + { + [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)))); + } + + [Test] + public void TestStrongHit([Values(false, true)] bool hitBoth) + { + AddStep("Great", () => SetContents(() => getContentFor(createStrongHit(HitResult.Great, hitBoth)))); + AddStep("Good", () => SetContents(() => getContentFor(createStrongHit(HitResult.Ok, hitBoth)))); + } + + private Drawable getContentFor(DrawableTestHit hit) + { + return new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + // 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) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; + } + + 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); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneInputDrum.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs similarity index 56% rename from osu.Game.Rulesets.Taiko.Tests/TestSceneInputDrum.cs rename to osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs index 8c1b0c4c62..fa6c9da174 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneInputDrum.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneInputDrum.cs @@ -1,34 +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 System.Collections.Generic; using NUnit.Framework; -using osuTK; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Rulesets.Taiko.Audio; using osu.Game.Rulesets.Taiko.UI; -using osu.Game.Tests.Visual; +using osuTK; -namespace osu.Game.Rulesets.Taiko.Tests +namespace osu.Game.Rulesets.Taiko.Tests.Skinning { [TestFixture] - public class TestSceneInputDrum : OsuTestScene + public class TestSceneInputDrum : TaikoSkinnableTestScene { - public override IReadOnlyList RequiredTypes => new[] + [BackgroundDependencyLoader] + private void load() { - typeof(InputDrum), - typeof(DrumSampleMapping), - typeof(HitSampleInfo), - typeof(SampleControlPoint) - }; - - public TestSceneInputDrum() - { - Add(new TaikoInputManager(new RulesetInfo { ID = 1 }) + SetContents(() => new TaikoInputManager(new RulesetInfo { ID = 1 }) { RelativeSizeAxes = Axes.Both, Child = new Container diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.cs new file mode 100644 index 0000000000..7b7e2c43d1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoPlayfield.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; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + public class TestSceneTaikoPlayfield : TaikoSkinnableTestScene + { + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo + { + Direction = { Value = ScrollingDirection.Left }, + TimeRange = { Value = 5000 }, + }; + + public TestSceneTaikoPlayfield() + { + TaikoBeatmap beatmap; + bool kiai = false; + + AddStep("set beatmap", () => + { + Beatmap.Value = CreateWorkingBeatmap(beatmap = new TaikoBeatmap()); + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 }); + + Beatmap.Value.Track.Start(); + }); + + AddStep("Load playfield", () => SetContents(() => new TaikoPlayfield(new ControlPointInfo()) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 0.6f, + })); + + AddRepeatStep("change height", () => this.ChildrenOfType().ForEach(p => p.Height = Math.Max(0.2f, (p.Height + 0.2f) % 1f)), 50); + + AddStep("Toggle kiai", () => + { + Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new EffectControlPoint { KiaiMode = (kiai = !kiai) }); + }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs new file mode 100644 index 0000000000..114038b81c --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.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.Extensions.IEnumerableExtensions; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Skinning; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Taiko.Tests.Skinning +{ + public class TestSceneTaikoScroller : TaikoSkinnableTestScene + { + private readonly ManualClock clock = new ManualClock(); + + private bool reversed; + + public TestSceneTaikoScroller() + { + AddStep("Load scroller", () => SetContents(() => + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()) + { + Clock = new FramedClock(clock), + Height = 0.4f, + })); + + AddToggleStep("Toggle passing", passing => this.ChildrenOfType().ForEach(s => s.LastResult.Value = + new JudgementResult(null, new Judgement()) { Type = passing ? HitResult.Great : HitResult.Miss })); + + AddToggleStep("toggle playback direction", reversed => this.reversed = reversed); + } + + protected override void Update() + { + base.Update(); + + clock.CurrentTime += (reversed ? -1 : 1) * Clock.ElapsedFrameTime; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs index 68ae7544c2..5e550a5d03 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoBeatmapConversionTest.cs @@ -4,15 +4,15 @@ using System; using System.Collections.Generic; using NUnit.Framework; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Tests.Beatmaps; namespace osu.Game.Rulesets.Taiko.Tests { [TestFixture] + [Timeout(10000)] public class TaikoBeatmapConversionTest : BeatmapConversionTest { protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; @@ -20,19 +20,19 @@ namespace osu.Game.Rulesets.Taiko.Tests [NonParallelizable] [TestCase("basic")] [TestCase("slider-generating-drumroll")] - public new void Test(string name) - { - base.Test(name); - } + [TestCase("sample-to-type-conversions")] + [TestCase("slider-conversion-v6")] + [TestCase("slider-conversion-v14")] + public void Test(string name) => base.Test(name); protected override IEnumerable CreateConvertValue(HitObject hitObject) { yield return new ConvertValue { StartTime = hitObject.StartTime, - EndTime = (hitObject as IHasEndTime)?.EndTime ?? hitObject.StartTime, - IsRim = hitObject is RimHit, - IsCentre = hitObject is CentreHit, + EndTime = hitObject.GetEndTime(), + IsRim = (hitObject as Hit)?.Type == HitType.Rim, + IsCentre = (hitObject as Hit)?.Type == HitType.Centre, IsDrumRoll = hitObject is DrumRoll, IsSwell = hitObject is Swell, IsStrong = ((TaikoHitObject)hitObject).IsStrong @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Taiko.Tests public struct ConvertValue : IEquatable { /// - /// A sane value to account for osu!stable using ints everwhere. + /// A sane value to account for osu!stable using ints everywhere. /// private const float conversion_lenience = 2; diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index e7b6d8615b..71b3c23b50 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs @@ -13,8 +13,8 @@ namespace osu.Game.Rulesets.Taiko.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; - [TestCase(2.9811338051242915d, "diffcalc-test")] - [TestCase(2.9811338051242915d, "diffcalc-test-strong")] + [TestCase(2.2867022617692685d, "diffcalc-test")] + [TestCase(2.2867022617692685d, "diffcalc-test-strong")] public void Test(double expected, string name) => base.Test(expected, name); diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.cs new file mode 100644 index 0000000000..a59544386b --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoLegacyModConversionTest.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; +using NUnit.Framework; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + [TestFixture] + public class TaikoLegacyModConversionTest : LegacyModConversionTest + { + [TestCase(LegacyMods.Easy, new[] { typeof(TaikoModEasy) })] + [TestCase(LegacyMods.HardRock | LegacyMods.DoubleTime, new[] { typeof(TaikoModHardRock), typeof(TaikoModDoubleTime) })] + [TestCase(LegacyMods.DoubleTime, new[] { typeof(TaikoModDoubleTime) })] + [TestCase(LegacyMods.Nightcore, new[] { typeof(TaikoModNightcore) })] + [TestCase(LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(TaikoModNightcore) })] + [TestCase(LegacyMods.Flashlight | LegacyMods.Nightcore | LegacyMods.DoubleTime, new[] { typeof(TaikoModFlashlight), typeof(TaikoModNightcore) })] + [TestCase(LegacyMods.Perfect, new[] { typeof(TaikoModPerfect) })] + [TestCase(LegacyMods.SuddenDeath, new[] { typeof(TaikoModSuddenDeath) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath, new[] { typeof(TaikoModPerfect) })] + [TestCase(LegacyMods.Perfect | LegacyMods.SuddenDeath | LegacyMods.DoubleTime, new[] { typeof(TaikoModDoubleTime), typeof(TaikoModPerfect) })] + public new void Test(LegacyMods legacyMods, Type[] expectedMods) => base.Test(legacyMods, expectedMods); + + protected override Ruleset CreateRuleset() => new TaikoRuleset(); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.cs new file mode 100644 index 0000000000..63854e7ead --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.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 System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Judgements; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + [TestFixture] + public class TestSceneFlyingHits : DrawableTaikoRulesetTestScene + { + [TestCase(HitType.Centre)] + [TestCase(HitType.Rim)] + public void TestFlyingHits(HitType hitType) + { + DrawableFlyingHit flyingHit = null; + + AddStep("add flying hit", () => + { + addFlyingHit(hitType); + + // flying hits all land in one common scrolling container (and stay there for rewind purposes), + // so we need to manually get the latest one. + flyingHit = this.ChildrenOfType() + .OrderByDescending(h => h.HitObject.StartTime) + .FirstOrDefault(); + }); + + AddAssert("hit type is correct", () => flyingHit.HitObject.Type == hitType); + } + + private void addFlyingHit(HitType hitType) + { + var tick = new DrumRollTick { HitWindows = HitWindows.Empty, StartTime = DrawableRuleset.Playfield.Time.Current }; + + DrawableDrumRollTick h; + DrawableRuleset.Playfield.Add(h = new DrawableDrumRollTick(tick) { JudgementType = hitType }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(tick, new TaikoDrumRollTickJudgement()) { Type = HitResult.Great }); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs similarity index 57% rename from osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs rename to osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs index 6f9856df83..e4c0766844 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs @@ -2,42 +2,35 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Judgements; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects.Drawables; using osu.Game.Rulesets.Taiko.UI; -using osu.Game.Tests.Visual; using osuTK; -using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Tests { [TestFixture] - public class TestSceneTaikoPlayfield : OsuTestScene + public class TestSceneHits : DrawableTaikoRulesetTestScene { - private const double default_duration = 1000; + private const double default_duration = 3000; private const float scroll_time = 1000; protected override double TimePerAction => default_duration * 2; private readonly Random rng = new Random(1337); - private DrawableTaikoRuleset drawableRuleset; - private Container playfieldContainer; - [BackgroundDependencyLoader] - private void load() + [Test] + public void TestVariousHits() { AddStep("Hit", () => addHitJudgement(false)); AddStep("Strong hit", () => addStrongHitJudgement(false)); @@ -46,6 +39,7 @@ namespace osu.Game.Rulesets.Taiko.Tests AddStep("Miss :(", addMissJudgement); AddStep("DrumRoll", () => addDrumRoll(false)); AddStep("Strong DrumRoll", () => addDrumRoll(true)); + AddStep("Kiai DrumRoll", () => addDrumRoll(true, kiai: true)); AddStep("Swell", () => addSwell()); AddStep("Centre", () => addCentreHit(false)); AddStep("Strong Centre", () => addCentreHit(true)); @@ -53,41 +47,17 @@ namespace osu.Game.Rulesets.Taiko.Tests AddStep("Strong Rim", () => addRimHit(true)); AddStep("Add bar line", () => addBarLine(false)); AddStep("Add major bar line", () => addBarLine(true)); + AddStep("Add centre w/ bar line", () => + { + addCentreHit(false); + addBarLine(true); + }); AddStep("Height test 1", () => changePlayfieldSize(1)); AddStep("Height test 2", () => changePlayfieldSize(2)); AddStep("Height test 3", () => changePlayfieldSize(3)); AddStep("Height test 4", () => changePlayfieldSize(4)); AddStep("Height test 5", () => changePlayfieldSize(5)); AddStep("Reset height", () => changePlayfieldSize(6)); - - var controlPointInfo = new ControlPointInfo(); - controlPointInfo.TimingPoints.Add(new TimingControlPoint()); - - WorkingBeatmap beatmap = CreateWorkingBeatmap(new Beatmap - { - HitObjects = new List { new CentreHit() }, - BeatmapInfo = new BeatmapInfo - { - BaseDifficulty = new BeatmapDifficulty(), - Metadata = new BeatmapMetadata - { - Artist = @"Unknown", - Title = @"Sample Beatmap", - AuthorString = @"peppy", - }, - Ruleset = new TaikoRuleset().RulesetInfo - }, - ControlPointInfo = controlPointInfo - }); - - Add(playfieldContainer = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = 768, - Children = new[] { drawableRuleset = new DrawableTaikoRuleset(new TaikoRuleset(), beatmap, Array.Empty()) } - }); } private void changePlayfieldSize(int step) @@ -123,134 +93,132 @@ namespace osu.Game.Rulesets.Taiko.Tests switch (step) { default: - playfieldContainer.Delay(delay).ResizeTo(new Vector2(1, rng.Next(25, 400)), 500); + PlayfieldContainer.Delay(delay).ResizeTo(new Vector2(1, rng.Next(25, 400)), 500); break; case 6: - playfieldContainer.Delay(delay).ResizeTo(new Vector2(1, TaikoPlayfield.DEFAULT_HEIGHT), 500); + PlayfieldContainer.Delay(delay).ResizeTo(new Vector2(1, TaikoPlayfield.DEFAULT_HEIGHT), 500); break; } } private void addHitJudgement(bool kiai) { - HitResult hitResult = RNG.Next(2) == 0 ? HitResult.Good : HitResult.Great; + HitResult hitResult = RNG.Next(2) == 0 ? HitResult.Ok : HitResult.Great; var cpi = new ControlPointInfo(); - cpi.EffectPoints.Add(new EffectControlPoint { KiaiMode = kiai }); + cpi.Add(0, new EffectControlPoint { KiaiMode = kiai }); Hit hit = new Hit(); hit.ApplyDefaults(cpi, new BeatmapDifficulty()); - var h = new DrawableTestHit(hit) { X = RNG.NextSingle(hitResult == HitResult.Good ? -0.1f : -0.05f, hitResult == HitResult.Good ? 0.1f : 0.05f) }; + var h = new DrawableTestHit(hit) { X = RNG.NextSingle(hitResult == HitResult.Ok ? -0.1f : -0.05f, hitResult == HitResult.Ok ? 0.1f : 0.05f) }; - ((TaikoPlayfield)drawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new TaikoJudgement()) { Type = hitResult }); + DrawableRuleset.Playfield.Add(h); + + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = hitResult }); } private void addStrongHitJudgement(bool kiai) { - HitResult hitResult = RNG.Next(2) == 0 ? HitResult.Good : HitResult.Great; + HitResult hitResult = RNG.Next(2) == 0 ? HitResult.Ok : HitResult.Great; var cpi = new ControlPointInfo(); - cpi.EffectPoints.Add(new EffectControlPoint { KiaiMode = kiai }); + cpi.Add(0, new EffectControlPoint { KiaiMode = kiai }); Hit hit = new Hit(); hit.ApplyDefaults(cpi, new BeatmapDifficulty()); - var h = new DrawableTestHit(hit) { X = RNG.NextSingle(hitResult == HitResult.Good ? -0.1f : -0.05f, hitResult == HitResult.Good ? 0.1f : 0.05f) }; + var h = new DrawableTestHit(hit) { X = RNG.NextSingle(hitResult == HitResult.Ok ? -0.1f : -0.05f, hitResult == HitResult.Ok ? 0.1f : 0.05f) }; - ((TaikoPlayfield)drawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new TaikoJudgement()) { Type = hitResult }); - ((TaikoPlayfield)drawableRuleset.Playfield).OnNewResult(new TestStrongNestedHit(h), new JudgementResult(new TaikoStrongJudgement()) { Type = HitResult.Great }); + DrawableRuleset.Playfield.Add(h); + + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = hitResult }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(new TestStrongNestedHit(h), new JudgementResult(new HitObject(), new TaikoStrongJudgement()) { Type = HitResult.Great }); } private void addMissJudgement() { - ((TaikoPlayfield)drawableRuleset.Playfield).OnNewResult(new DrawableTestHit(new Hit()), new JudgementResult(new TaikoJudgement()) { Type = HitResult.Miss }); + DrawableTestHit h; + DrawableRuleset.Playfield.Add(h = new DrawableTestHit(new Hit(), HitResult.Miss)); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = HitResult.Miss }); } private void addBarLine(bool major, double delay = scroll_time) { - BarLine bl = new BarLine { StartTime = drawableRuleset.Playfield.Time.Current + delay }; + BarLine bl = new BarLine { StartTime = DrawableRuleset.Playfield.Time.Current + delay }; - drawableRuleset.Playfield.Add(major ? new DrawableBarLineMajor(bl) : new DrawableBarLine(bl)); + DrawableRuleset.Playfield.Add(major ? new DrawableBarLineMajor(bl) : new DrawableBarLine(bl)); } private void addSwell(double duration = default_duration) { var swell = new Swell { - StartTime = drawableRuleset.Playfield.Time.Current + scroll_time, + StartTime = DrawableRuleset.Playfield.Time.Current + scroll_time, Duration = duration, }; swell.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - drawableRuleset.Playfield.Add(new DrawableSwell(swell)); + DrawableRuleset.Playfield.Add(new DrawableSwell(swell)); } - private void addDrumRoll(bool strong, double duration = default_duration) + private void addDrumRoll(bool strong, double duration = default_duration, bool kiai = false) { addBarLine(true); addBarLine(true, scroll_time + duration); var d = new DrumRoll { - StartTime = drawableRuleset.Playfield.Time.Current + scroll_time, + StartTime = DrawableRuleset.Playfield.Time.Current + scroll_time, IsStrong = strong, Duration = duration, + TickRate = 8, }; - d.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var cpi = new ControlPointInfo(); + cpi.Add(-10000, new EffectControlPoint { KiaiMode = kiai }); - drawableRuleset.Playfield.Add(new DrawableDrumRoll(d)); + d.ApplyDefaults(cpi, new BeatmapDifficulty()); + + DrawableRuleset.Playfield.Add(new DrawableDrumRoll(d)); } private void addCentreHit(bool strong) { Hit h = new Hit { - StartTime = drawableRuleset.Playfield.Time.Current + scroll_time, + StartTime = DrawableRuleset.Playfield.Time.Current + scroll_time, IsStrong = strong }; h.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - drawableRuleset.Playfield.Add(new DrawableCentreHit(h)); + DrawableRuleset.Playfield.Add(new DrawableHit(h)); } private void addRimHit(bool strong) { Hit h = new Hit { - StartTime = drawableRuleset.Playfield.Time.Current + scroll_time, + StartTime = DrawableRuleset.Playfield.Time.Current + scroll_time, IsStrong = strong }; h.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - drawableRuleset.Playfield.Add(new DrawableRimHit(h)); + DrawableRuleset.Playfield.Add(new DrawableHit(h)); } private class TestStrongNestedHit : DrawableStrongNestedHit { public TestStrongNestedHit(DrawableHitObject mainObject) - : base(null, mainObject) + : base(new StrongHitObject { StartTime = mainObject.HitObject.StartTime }, mainObject) { } public override bool OnPressed(TaikoAction action) => false; } - - private class DrawableTestHit : DrawableHitObject - { - public DrawableTestHit(TaikoHitObject hitObject) - : base(hitObject) - { - } - - protected override void UpdateState(ArmedState state) - { - } - } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs new file mode 100644 index 0000000000..4ba9c447fb --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.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 System.Linq; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + /// + /// Taiko has some interesting rules for legacy mappings. + /// + [HeadlessTest] + public class TestSceneSampleOutput : TestSceneTaikoPlayer + { + public override void SetUpSteps() + { + base.SetUpSteps(); + AddAssert("has correct samples", () => + { + var names = Player.DrawableRuleset.Playfield.AllHitObjects.OfType().Select(h => string.Join(',', h.GetSamples().Select(s => s.Name))); + + var expected = new[] + { + string.Empty, + string.Empty, + string.Empty, + string.Empty, + HitSampleInfo.HIT_FINISH, + HitSampleInfo.HIT_WHISTLE, + HitSampleInfo.HIT_WHISTLE, + HitSampleInfo.HIT_WHISTLE, + }; + + return names.SequenceEqual(expected); + }); + } + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TaikoBeatmapConversionTest().GetBeatmap("sample-to-type-conversions"); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs new file mode 100644 index 0000000000..75049b7467 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneSwellJudgements.cs @@ -0,0 +1,40 @@ +// 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.Game.Beatmaps; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TestSceneSwellJudgements : TestSceneTaikoPlayer + { + [Test] + public void TestZeroTickTimeOffsets() + { + AddUntilStep("gameplay finished", () => Player.ScoreProcessor.HasCompleted.Value); + AddAssert("all tick offsets are 0", () => Player.Results.Where(r => r.HitObject is SwellTick).All(r => r.TimeOffset == 0)); + } + + protected override bool Autoplay => true; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new Beatmap + { + BeatmapInfo = { Ruleset = new TaikoRuleset().RulesetInfo }, + HitObjects = + { + new Swell + { + StartTime = 1000, + Duration = 1000, + } + } + }; + + return beatmap; + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.cs new file mode 100644 index 0000000000..7089ea6619 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoHitObjectSamples.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.Reflection; +using NUnit.Framework; +using osu.Framework.IO.Stores; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TestSceneTaikoHitObjectSamples : HitObjectSampleTest + { + protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); + + protected override IResourceStore Resources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneTaikoHitObjectSamples))); + + [TestCase("taiko-normal-hitnormal")] + [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] + public void TestDefaultCustomSampleFromBeatmap(string expectedSample) + { + SetupSkins(expectedSample, expectedSample); + + CreateTestWithBeatmap("taiko-hitobject-beatmap-custom-sample-bank.osu"); + + AssertBeatmapLookup(expectedSample); + } + + [TestCase("taiko-normal-hitnormal")] + [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] + public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) + { + SetupSkins(string.Empty, expectedSample); + + CreateTestWithBeatmap("taiko-hitobject-beatmap-custom-sample-bank.osu"); + + AssertUserLookup(expectedSample); + } + + [TestCase("taiko-normal-hitnormal2")] + [TestCase("normal-hitnormal2")] + public void TestUserSkinLookupIgnoresSampleBank(string unwantedSample) + { + SetupSkins(string.Empty, unwantedSample); + + CreateTestWithBeatmap("taiko-hitobject-beatmap-custom-sample-bank.osu"); + + AssertNoLookup(unwantedSample); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayer.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayer.cs new file mode 100644 index 0000000000..cd7511241a --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoPlayer.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.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class TestSceneTaikoPlayer : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs index d0db193738..0be005e1c4 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneTaikoSuddenDeath.cs @@ -4,28 +4,21 @@ using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Beatmaps; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; -using osu.Game.Screens.Play; using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Taiko.Tests { - public class TestSceneTaikoSuddenDeath : PlayerTestScene + public class TestSceneTaikoSuddenDeath : TestSceneTaikoPlayer { - public TestSceneTaikoSuddenDeath() - : base(new TaikoRuleset()) - { - } - protected override bool AllowFail => true; - protected override Player CreatePlayer(Ruleset ruleset) + protected override TestPlayer CreatePlayer(Ruleset ruleset) { - Mods.Value = Mods.Value.Concat(new[] { new TaikoModSuddenDeath() }).ToArray(); - return new ScoreAccessiblePlayer(); + SelectedMods.Value = SelectedMods.Value.Concat(new[] { new TaikoModSuddenDeath() }).ToArray(); + return base.CreatePlayer(ruleset); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => @@ -43,26 +36,16 @@ namespace osu.Game.Rulesets.Taiko.Tests }; [Test] - public void TestSpinnerDoesNotFail() + public void TestSpinnerDoesFail() { bool judged = false; AddStep("Setup judgements", () => { judged = false; - ((ScoreAccessiblePlayer)Player).ScoreProcessor.NewJudgement += b => judged = true; + Player.ScoreProcessor.NewJudgement += b => judged = true; }); AddUntilStep("swell judged", () => judged); - AddAssert("not failed", () => !Player.HasFailed); - } - - private class ScoreAccessiblePlayer : TestPlayer - { - public ScoreAccessiblePlayer() - : base(false, false) - { - } - - public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + AddAssert("failed", () => Player.HasFailed); } } } 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 5510c3a9d9..b59f3a4344 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,14 +2,14 @@ - + - + WinExe - netcoreapp2.2 + netcoreapp3.1 diff --git a/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs b/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.cs new file mode 100644 index 0000000000..e4dc261363 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Audio/DrumSampleContainer.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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Taiko.Audio +{ + /// + /// Stores samples for the input drum. + /// The lifetime of the samples is adjusted so that they are only alive during the appropriate sample control point. + /// + public class DrumSampleContainer : LifetimeManagementContainer + { + private readonly ControlPointInfo controlPoints; + private readonly Dictionary mappings = new Dictionary(); + + private readonly IBindableList samplePoints = new BindableList(); + + public DrumSampleContainer(ControlPointInfo controlPoints) + { + this.controlPoints = controlPoints; + } + + [BackgroundDependencyLoader] + private void load() + { + samplePoints.BindTo(controlPoints.SamplePoints); + samplePoints.BindCollectionChanged((_, __) => recreateMappings(), true); + } + + private void recreateMappings() + { + mappings.Clear(); + ClearInternal(); + + SampleControlPoint[] points = samplePoints.Count == 0 + ? new[] { controlPoints.SamplePointAt(double.MinValue) } + : samplePoints.ToArray(); + + for (int i = 0; i < points.Length; i++) + { + var samplePoint = points[i]; + + var lifetimeStart = i > 0 ? samplePoint.Time : double.MinValue; + var lifetimeEnd = i + 1 < points.Length ? points[i + 1].Time : double.MaxValue; + + AddInternal(mappings[samplePoint.Time] = new DrumSample(samplePoint) + { + LifetimeStart = lifetimeStart, + LifetimeEnd = lifetimeEnd + }); + } + } + + public DrumSample SampleAt(double time) => mappings[controlPoints.SamplePointAt(time).Time]; + + public class DrumSample : CompositeDrawable + { + public override bool RemoveWhenNotAlive => false; + + public PausableSkinnableSound Centre { get; private set; } + public PausableSkinnableSound Rim { get; private set; } + + private readonly SampleControlPoint samplePoint; + + private Bindable sampleBank; + private BindableNumber sampleVolume; + + public DrumSample(SampleControlPoint samplePoint) + { + this.samplePoint = samplePoint; + } + + [BackgroundDependencyLoader] + private void load() + { + sampleBank = samplePoint.SampleBankBindable.GetBoundCopy(); + sampleBank.BindValueChanged(_ => recreate()); + + sampleVolume = samplePoint.SampleVolumeBindable.GetBoundCopy(); + sampleVolume.BindValueChanged(_ => recreate()); + + recreate(); + } + + private void recreate() + { + InternalChildren = new Drawable[] + { + Centre = new PausableSkinnableSound(samplePoint.GetSampleInfo()), + Rim = new PausableSkinnableSound(samplePoint.GetSampleInfo(HitSampleInfo.HIT_CLAP)) + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Audio/DrumSampleMapping.cs b/osu.Game.Rulesets.Taiko/Audio/DrumSampleMapping.cs deleted file mode 100644 index ad2596931d..0000000000 --- a/osu.Game.Rulesets.Taiko/Audio/DrumSampleMapping.cs +++ /dev/null @@ -1,61 +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 osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Skinning; - -namespace osu.Game.Rulesets.Taiko.Audio -{ - public class DrumSampleMapping - { - private readonly ControlPointInfo controlPoints; - private readonly Dictionary mappings = new Dictionary(); - - public readonly List Sounds = new List(); - - public DrumSampleMapping(ControlPointInfo controlPoints) - { - this.controlPoints = controlPoints; - - IEnumerable samplePoints; - if (controlPoints.SamplePoints.Count == 0) - // Get the default sample point - samplePoints = new[] { controlPoints.SamplePointAt(double.MinValue) }; - else - samplePoints = controlPoints.SamplePoints; - - foreach (var s in samplePoints) - { - var centre = s.GetSampleInfo(); - var rim = s.GetSampleInfo(HitSampleInfo.HIT_CLAP); - - // todo: this is ugly - centre.Namespace = "taiko"; - rim.Namespace = "taiko"; - - mappings[s.Time] = new DrumSample - { - Centre = addSound(centre), - Rim = addSound(rim) - }; - } - } - - private SkinnableSound addSound(HitSampleInfo hitSampleInfo) - { - var drawable = new SkinnableSound(hitSampleInfo); - Sounds.Add(drawable); - return drawable; - } - - public DrumSample SampleAt(double time) => mappings[controlPoints.SamplePointAt(time).Time]; - - public class DrumSample - { - public SkinnableSound Centre; - public SkinnableSound Rim; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs index b595f43fbb..16a0726c8c 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmap.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Rulesets.Taiko.Objects; @@ -22,20 +21,20 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps new BeatmapStatistic { Name = @"Hit Count", + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles), Content = hits.ToString(), - Icon = FontAwesome.Regular.Circle }, new BeatmapStatistic { Name = @"Drumroll Count", + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders), Content = drumrolls.ToString(), - Icon = FontAwesome.Regular.Circle }, new BeatmapStatistic { Name = @"Swell Count", + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners), Content = swells.ToString(), - Icon = FontAwesome.Regular.Circle } }; } diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index f0cf8d9c7d..607eaf5dbd 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -8,8 +8,11 @@ using osu.Game.Rulesets.Taiko.Objects; using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Utils; +using System.Threading; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; namespace osu.Game.Rulesets.Taiko.Beatmaps { @@ -19,7 +22,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps /// osu! is generally slower than taiko, so a factor is added to increase /// speed. This must be used everywhere slider length or beat length is used. /// - private const float legacy_velocity_multiplier = 1.4f; + public const float LEGACY_VELOCITY_MULTIPLIER = 1.4f; /// /// Because swells are easier in taiko than spinners are in osu!, @@ -39,22 +42,22 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps private readonly bool isForCurrentRuleset; - protected override IEnumerable ValidConversionTypes { get; } = new[] { typeof(HitObject) }; - - public TaikoBeatmapConverter(IBeatmap beatmap) - : base(beatmap) + public TaikoBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) + : base(beatmap, ruleset) { - isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(new TaikoRuleset().RulesetInfo); + isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo); } - protected override Beatmap ConvertBeatmap(IBeatmap original) + public override bool CanConvert() => true; + + protected override Beatmap ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken) { // Rewrite the beatmap info to add the slider velocity multiplier original.BeatmapInfo = original.BeatmapInfo.Clone(); original.BeatmapInfo.BaseDifficulty = original.BeatmapInfo.BaseDifficulty.Clone(); - original.BeatmapInfo.BaseDifficulty.SliderMultiplier *= legacy_velocity_multiplier; + original.BeatmapInfo.BaseDifficulty.SliderMultiplier *= LEGACY_VELOCITY_MULTIPLIER; - Beatmap converted = base.ConvertBeatmap(original); + Beatmap converted = base.ConvertBeatmap(original, cancellationToken); if (original.BeatmapInfo.RulesetID == 3) { @@ -62,7 +65,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps converted.HitObjects = converted.HitObjects.GroupBy(t => t.StartTime).Select(x => { TaikoHitObject first = x.First(); - if (x.Skip(1).Any() && !(first is Swell)) + if (x.Skip(1).Any() && first.CanBeStrong) first.IsStrong = true; return first; }).ToList(); @@ -71,131 +74,136 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps return converted; } - protected override IEnumerable ConvertHitObject(HitObject obj, IBeatmap beatmap) + protected override IEnumerable ConvertHitObject(HitObject obj, IBeatmap beatmap, CancellationToken cancellationToken) { - var distanceData = obj as IHasDistance; - var repeatsData = obj as IHasRepeats; - var endTimeData = obj as IHasEndTime; - var curveData = obj as IHasCurve; - // Old osu! used hit sounding to determine various hit type information - List samples = obj.Samples; + IList samples = obj.Samples; bool strong = samples.Any(s => s.Name == HitSampleInfo.HIT_FINISH); - if (distanceData != null) + switch (obj) { - // Number of spans of the object - one for the initial length and for each repeat - int spans = repeatsData?.SpanCount() ?? 1; - - TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime); - DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(obj.StartTime); - - double speedAdjustment = difficultyPoint.SpeedMultiplier; - double speedAdjustedBeatLength = timingPoint.BeatLength / speedAdjustment; - - // The true distance, accounting for any repeats. This ends up being the drum roll distance later - double distance = distanceData.Distance * spans * legacy_velocity_multiplier; - - // The velocity of the taiko hit object - calculated as the velocity of a drum roll - double taikoVelocity = taiko_base_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / speedAdjustedBeatLength; - // The duration of the taiko hit object - double taikoDuration = distance / taikoVelocity; - - // The velocity of the osu! hit object - calculated as the velocity of a slider - double osuVelocity = osu_base_scoring_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / speedAdjustedBeatLength; - // The duration of the osu! hit object - double osuDuration = distance / osuVelocity; - - // osu-stable always uses the speed-adjusted beatlength to determine the velocities, but - // only uses it for tick rate if beatmap version < 8 - if (beatmap.BeatmapInfo.BeatmapVersion >= 8) - speedAdjustedBeatLength *= speedAdjustment; - - // If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat - double tickSpacing = Math.Min(speedAdjustedBeatLength / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate, taikoDuration / spans); - - if (!isForCurrentRuleset && tickSpacing > 0 && osuDuration < 2 * speedAdjustedBeatLength) + case IHasDistance distanceData: { - List> allSamples = curveData != null ? curveData.NodeSamples : new List>(new[] { samples }); - - int i = 0; - - for (double j = obj.StartTime; j <= obj.StartTime + taikoDuration + tickSpacing / 8; j += tickSpacing) + if (shouldConvertSliderToHits(obj, beatmap, distanceData, out var taikoDuration, out var tickSpacing)) { - List 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); + List> allSamples = obj is IHasPathWithRepeats curveData ? curveData.NodeSamples : new List>(new[] { samples }); - if (isRim) + int i = 0; + + for (double j = obj.StartTime; j <= obj.StartTime + taikoDuration + tickSpacing / 8; j += tickSpacing) { - yield return new RimHit + 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 }; - } - else - { - yield return new CentreHit - { - StartTime = j, - Samples = currentSamples, - IsStrong = strong - }; - } - i = (i + 1) % allSamples.Count; + i = (i + 1) % allSamples.Count; + + if (Precision.AlmostEquals(0, tickSpacing)) + break; + } } + else + { + yield return new DrumRoll + { + StartTime = obj.StartTime, + Samples = obj.Samples, + IsStrong = strong, + Duration = taikoDuration, + TickRate = beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate == 3 ? 3 : 4 + }; + } + + break; } - else + + case IHasDuration endTimeData: { - yield return new DrumRoll + double hitMultiplier = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty, 3, 5, 7.5) * swell_hit_multiplier; + + yield return new Swell { StartTime = obj.StartTime, Samples = obj.Samples, - IsStrong = strong, - Duration = taikoDuration, - TickRate = beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate == 3 ? 3 : 4 + Duration = endTimeData.Duration, + RequiredHits = (int)Math.Max(1, endTimeData.Duration / 1000 * hitMultiplier) }; + + break; + } + + 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; } } - else if (endTimeData != null) - { - double hitMultiplier = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty, 3, 5, 7.5) * swell_hit_multiplier; + } - yield return new Swell - { - StartTime = obj.StartTime, - Samples = obj.Samples, - Duration = endTimeData.Duration, - RequiredHits = (int)Math.Max(1, endTimeData.Duration / 1000 * hitMultiplier) - }; - } + private bool shouldConvertSliderToHits(HitObject obj, IBeatmap beatmap, IHasDistance distanceData, out double taikoDuration, out double tickSpacing) + { + // DO NOT CHANGE OR REFACTOR ANYTHING IN HERE WITHOUT TESTING AGAINST _ALL_ BEATMAPS. + // Some of these calculations look redundant, but they are not - extremely small floating point errors are introduced to maintain 1:1 compatibility with stable. + // Rounding cannot be used as an alternative since the error deltas have been observed to be between 1e-2 and 1e-6. + + // The true distance, accounting for any repeats. This ends up being the drum roll distance later + int spans = (obj as IHasRepeats)?.SpanCount() ?? 1; + double distance = distanceData.Distance * spans * LEGACY_VELOCITY_MULTIPLIER; + + TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime); + DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(obj.StartTime); + + double beatLength; +#pragma warning disable 618 + if (difficultyPoint is LegacyBeatmapDecoder.LegacyDifficultyControlPoint legacyDifficultyPoint) +#pragma warning restore 618 + beatLength = timingPoint.BeatLength * legacyDifficultyPoint.BpmMultiplier; else - { - bool isRim = samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP || s.Name == HitSampleInfo.HIT_WHISTLE); + beatLength = timingPoint.BeatLength / difficultyPoint.SpeedMultiplier; - if (isRim) - { - yield return new RimHit - { - StartTime = obj.StartTime, - Samples = obj.Samples, - IsStrong = strong - }; - } - else - { - yield return new CentreHit - { - StartTime = obj.StartTime, - Samples = obj.Samples, - IsStrong = strong - }; - } + double sliderScoringPointDistance = osu_base_scoring_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate; + + // The velocity and duration of the taiko hit object - calculated as the velocity of a drum roll. + double taikoVelocity = sliderScoringPointDistance * beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate; + taikoDuration = distance / taikoVelocity * beatLength; + + if (isForCurrentRuleset) + { + tickSpacing = 0; + return false; } + + double osuVelocity = taikoVelocity * (1000f / beatLength); + + // osu-stable always uses the speed-adjusted beatlength to determine the osu! velocity, but only uses it for conversion if beatmap version < 8 + if (beatmap.BeatmapInfo.BeatmapVersion >= 8) + beatLength = timingPoint.BeatLength; + + // If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat + tickSpacing = Math.Min(beatLength / beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate, taikoDuration / spans); + + return tickSpacing > 0 + && distance / osuVelocity * 1000 < 2 * beatLength; } protected override Beatmap CreateBeatmap() => new TaikoBeatmap(); diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/StaminaCheeseDetector.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/StaminaCheeseDetector.cs new file mode 100644 index 0000000000..3b1a9ad777 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/StaminaCheeseDetector.cs @@ -0,0 +1,145 @@ +// 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.Difficulty.Utils; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing +{ + /// + /// Detects special hit object patterns which are easier to hit using special techniques + /// than normally assumed in the fully-alternating play style. + /// + /// + /// This component detects two basic types of patterns, leveraged by the following techniques: + /// + /// Rolling allows hitting patterns with quickly and regularly alternating notes with a single hand. + /// TL tapping makes hitting longer sequences of consecutive same-colour notes with little to no colour changes in-between. + /// + /// + public class StaminaCheeseDetector + { + /// + /// The minimum number of consecutive objects with repeating patterns that can be classified as hittable using a roll. + /// + private const int roll_min_repetitions = 12; + + /// + /// The minimum number of consecutive objects with repeating patterns that can be classified as hittable using a TL tap. + /// + private const int tl_min_repetitions = 16; + + /// + /// The list of all s in the map. + /// + private readonly List hitObjects; + + public StaminaCheeseDetector(List hitObjects) + { + this.hitObjects = hitObjects; + } + + /// + /// Finds and marks all objects in that special difficulty-reducing techiques apply to + /// with the flag. + /// + public void FindCheese() + { + findRolls(3); + findRolls(4); + + findTlTap(0, HitType.Rim); + findTlTap(1, HitType.Rim); + findTlTap(0, HitType.Centre); + findTlTap(1, HitType.Centre); + } + + /// + /// Finds and marks all sequences hittable using a roll. + /// + /// The length of a single repeating pattern to consider (triplets/quadruplets). + private void findRolls(int patternLength) + { + var history = new LimitedCapacityQueue(2 * patternLength); + + // for convenience, we're tracking the index of the item *before* our suspected repeat's start, + // as that index can be simply subtracted from the current index to get the number of elements in between + // without off-by-one errors + int indexBeforeLastRepeat = -1; + int lastMarkEnd = 0; + + for (int i = 0; i < hitObjects.Count; i++) + { + history.Enqueue(hitObjects[i]); + if (!history.Full) + continue; + + if (!containsPatternRepeat(history, patternLength)) + { + // we're setting this up for the next iteration, hence the +1. + // right here this index will point at the queue's front (oldest item), + // but that item is about to be popped next loop with an enqueue. + indexBeforeLastRepeat = i - history.Count + 1; + continue; + } + + int repeatedLength = i - indexBeforeLastRepeat; + if (repeatedLength < roll_min_repetitions) + continue; + + markObjectsAsCheese(Math.Max(lastMarkEnd, i - repeatedLength + 1), i); + lastMarkEnd = i; + } + } + + /// + /// Determines whether the objects stored in contain a repetition of a pattern of length . + /// + private static bool containsPatternRepeat(LimitedCapacityQueue history, int patternLength) + { + for (int j = 0; j < patternLength; j++) + { + if (history[j].HitType != history[j + patternLength].HitType) + return false; + } + + return true; + } + + /// + /// Finds and marks all sequences hittable using a TL tap. + /// + /// Whether sequences starting with an odd- (1) or even-indexed (0) hit object should be checked. + /// The type of hit to check for TL taps. + private void findTlTap(int parity, HitType type) + { + int tlLength = -2; + int lastMarkEnd = 0; + + for (int i = parity; i < hitObjects.Count; i += 2) + { + if (hitObjects[i].HitType == type) + tlLength += 2; + else + tlLength = -2; + + if (tlLength < tl_min_repetitions) + continue; + + markObjectsAsCheese(Math.Max(lastMarkEnd, i - tlLength + 1), i); + lastMarkEnd = i; + } + } + + /// + /// Marks all objects from to (inclusive) as . + /// + private void markObjectsAsCheese(int start, int end) + { + for (int i = start; i <= end; i++) + hitObjects[i].StaminaCheese = true; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs index 24345275c1..ae33c184d0 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs @@ -1,20 +1,94 @@ // 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.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing { + /// + /// Represents a single hit object in taiko difficulty calculation. + /// public class TaikoDifficultyHitObject : DifficultyHitObject { - public readonly bool HasTypeChange; + /// + /// The rhythm required to hit this hit object. + /// + public readonly TaikoDifficultyHitObjectRhythm Rhythm; - public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate) + /// + /// The hit type of this hit object. + /// + public readonly HitType? HitType; + + /// + /// The index of the object in the beatmap. + /// + public readonly int ObjectIndex; + + /// + /// Whether the object should carry a penalty due to being hittable using special techniques + /// making it easier to do so. + /// + public bool StaminaCheese; + + /// + /// Creates a new difficulty hit object. + /// + /// The gameplay associated with this difficulty object. + /// The gameplay preceding . + /// The gameplay preceding . + /// The rate of the gameplay clock. Modified by speed-changing mods. + /// The index of the object in the beatmap. + public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, int objectIndex) : base(hitObject, lastObject, clockRate) { - HasTypeChange = lastObject is RimHit != hitObject is RimHit; + var currentHit = hitObject as Hit; + + Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate); + HitType = currentHit?.Type; + + ObjectIndex = objectIndex; + } + + /// + /// List of most common rhythm changes in taiko maps. + /// + /// + /// The general guidelines for the values are: + /// + /// rhythm changes with ratio closer to 1 (that are not 1) are harder to play, + /// speeding up is generally harder than slowing down (with exceptions of rhythm changes requiring a hand switch). + /// + /// + private static readonly TaikoDifficultyHitObjectRhythm[] common_rhythms = + { + new TaikoDifficultyHitObjectRhythm(1, 1, 0.0), + new TaikoDifficultyHitObjectRhythm(2, 1, 0.3), + new TaikoDifficultyHitObjectRhythm(1, 2, 0.5), + new TaikoDifficultyHitObjectRhythm(3, 1, 0.3), + new TaikoDifficultyHitObjectRhythm(1, 3, 0.35), + new TaikoDifficultyHitObjectRhythm(3, 2, 0.6), // purposefully higher (requires hand switch in full alternating gameplay style) + new TaikoDifficultyHitObjectRhythm(2, 3, 0.4), + new TaikoDifficultyHitObjectRhythm(5, 4, 0.5), + new TaikoDifficultyHitObjectRhythm(4, 5, 0.7) + }; + + /// + /// Returns the closest rhythm change from required to hit this object. + /// + /// The gameplay preceding this one. + /// The gameplay preceding . + /// The rate of the gameplay clock. + private TaikoDifficultyHitObjectRhythm getClosestRhythm(HitObject lastObject, HitObject lastLastObject, double clockRate) + { + double prevLength = (lastObject.StartTime - lastLastObject.StartTime) / clockRate; + double ratio = DeltaTime / prevLength; + + return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First(); } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObjectRhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObjectRhythm.cs new file mode 100644 index 0000000000..ea6a224094 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObjectRhythm.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. + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing +{ + /// + /// Represents a rhythm change in a taiko map. + /// + public class TaikoDifficultyHitObjectRhythm + { + /// + /// The difficulty multiplier associated with this rhythm change. + /// + public readonly double Difficulty; + + /// + /// The ratio of current + /// to previous for the rhythm change. + /// A above 1 indicates a slow-down; a below 1 indicates a speed-up. + /// + public readonly double Ratio; + + /// + /// Creates an object representing a rhythm change. + /// + /// The numerator for . + /// The denominator for + /// The difficulty multiplier associated with this rhythm change. + public TaikoDifficultyHitObjectRhythm(int numerator, int denominator, double difficulty) + { + Ratio = numerator / (double)denominator; + Difficulty = difficulty; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs new file mode 100644 index 0000000000..32421ee00a --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.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 osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Skills +{ + /// + /// Calculates the colour coefficient of taiko difficulty. + /// + public class Colour : Skill + { + protected override double SkillMultiplier => 1; + protected override double StrainDecayBase => 0.4; + + /// + /// Maximum number of entries to keep in . + /// + private const int mono_history_max_length = 5; + + /// + /// Queue with the lengths of the last most recent mono (single-colour) patterns, + /// with the most recent value at the end of the queue. + /// + private readonly LimitedCapacityQueue monoHistory = new LimitedCapacityQueue(mono_history_max_length); + + /// + /// The of the last object hit before the one being considered. + /// + private HitType? previousHitType; + + /// + /// Length of the current mono pattern. + /// + private int currentMonoLength; + + protected override double StrainValueOf(DifficultyHitObject current) + { + // changing from/to a drum roll or a swell does not constitute a colour change. + // hits spaced more than a second apart are also exempt from colour strain. + if (!(current.LastObject is Hit && current.BaseObject is Hit && current.DeltaTime < 1000)) + { + monoHistory.Clear(); + + var currentHit = current.BaseObject as Hit; + currentMonoLength = currentHit != null ? 1 : 0; + previousHitType = currentHit?.Type; + + return 0.0; + } + + var taikoCurrent = (TaikoDifficultyHitObject)current; + + double objectStrain = 0.0; + + if (previousHitType != null && taikoCurrent.HitType != previousHitType) + { + // The colour has changed. + objectStrain = 1.0; + + if (monoHistory.Count < 2) + { + // There needs to be at least two streaks to determine a strain. + objectStrain = 0.0; + } + else if ((monoHistory[^1] + currentMonoLength) % 2 == 0) + { + // The last streak in the history is guaranteed to be a different type to the current streak. + // If the total number of notes in the two streaks is even, nullify this object's strain. + objectStrain = 0.0; + } + + objectStrain *= repetitionPenalties(); + currentMonoLength = 1; + } + else + { + currentMonoLength += 1; + } + + previousHitType = taikoCurrent.HitType; + return objectStrain; + } + + /// + /// The penalty to apply due to the length of repetition in colour streaks. + /// + private double repetitionPenalties() + { + const int most_recent_patterns_to_compare = 2; + double penalty = 1.0; + + monoHistory.Enqueue(currentMonoLength); + + for (int start = monoHistory.Count - most_recent_patterns_to_compare - 1; start >= 0; start--) + { + if (!isSamePattern(start, most_recent_patterns_to_compare)) + continue; + + int notesSince = 0; + for (int i = start; i < monoHistory.Count; i++) notesSince += monoHistory[i]; + penalty *= repetitionPenalty(notesSince); + break; + } + + return penalty; + } + + /// + /// Determines whether the last patterns have repeated in the history + /// of single-colour note sequences, starting from . + /// + private bool isSamePattern(int start, int mostRecentPatternsToCompare) + { + for (int i = 0; i < mostRecentPatternsToCompare; i++) + { + if (monoHistory[start + i] != monoHistory[monoHistory.Count - mostRecentPatternsToCompare + i]) + return false; + } + + return true; + } + + /// + /// Calculates the strain penalty for a colour pattern repetition. + /// + /// The number of notes since the last repetition of the pattern. + private double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince); + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs new file mode 100644 index 0000000000..5569b27ad5 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Rhythm.cs @@ -0,0 +1,167 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Skills +{ + /// + /// Calculates the rhythm coefficient of taiko difficulty. + /// + public class Rhythm : Skill + { + protected override double SkillMultiplier => 10; + protected override double StrainDecayBase => 0; + + /// + /// The note-based decay for rhythm strain. + /// + /// + /// is not used here, as it's time- and not note-based. + /// + private const double strain_decay = 0.96; + + /// + /// Maximum number of entries in . + /// + private const int rhythm_history_max_length = 8; + + /// + /// Contains the last changes in note sequence rhythms. + /// + private readonly LimitedCapacityQueue rhythmHistory = new LimitedCapacityQueue(rhythm_history_max_length); + + /// + /// Contains the rolling rhythm strain. + /// Used to apply per-note decay. + /// + private double currentStrain; + + /// + /// Number of notes since the last rhythm change has taken place. + /// + private int notesSinceRhythmChange; + + protected override double StrainValueOf(DifficultyHitObject current) + { + // drum rolls and swells are exempt. + if (!(current.BaseObject is Hit)) + { + resetRhythmAndStrain(); + return 0.0; + } + + currentStrain *= strain_decay; + + TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current; + notesSinceRhythmChange += 1; + + // rhythm difficulty zero (due to rhythm not changing) => no rhythm strain. + if (hitObject.Rhythm.Difficulty == 0.0) + { + return 0.0; + } + + double objectStrain = hitObject.Rhythm.Difficulty; + + objectStrain *= repetitionPenalties(hitObject); + objectStrain *= patternLengthPenalty(notesSinceRhythmChange); + objectStrain *= speedPenalty(hitObject.DeltaTime); + + // careful - needs to be done here since calls above read this value + notesSinceRhythmChange = 0; + + currentStrain += objectStrain; + return currentStrain; + } + + /// + /// Returns a penalty to apply to the current hit object caused by repeating rhythm changes. + /// + /// + /// Repetitions of more recent patterns are associated with a higher penalty. + /// + /// The current hit object being considered. + private double repetitionPenalties(TaikoDifficultyHitObject hitObject) + { + double penalty = 1; + + rhythmHistory.Enqueue(hitObject); + + for (int mostRecentPatternsToCompare = 2; mostRecentPatternsToCompare <= rhythm_history_max_length / 2; mostRecentPatternsToCompare++) + { + for (int start = rhythmHistory.Count - mostRecentPatternsToCompare - 1; start >= 0; start--) + { + if (!samePattern(start, mostRecentPatternsToCompare)) + continue; + + int notesSince = hitObject.ObjectIndex - rhythmHistory[start].ObjectIndex; + penalty *= repetitionPenalty(notesSince); + break; + } + } + + return penalty; + } + + /// + /// Determines whether the rhythm change pattern starting at is a repeat of any of the + /// . + /// + private bool samePattern(int start, int mostRecentPatternsToCompare) + { + for (int i = 0; i < mostRecentPatternsToCompare; i++) + { + if (rhythmHistory[start + i].Rhythm != rhythmHistory[rhythmHistory.Count - mostRecentPatternsToCompare + i].Rhythm) + return false; + } + + return true; + } + + /// + /// Calculates a single rhythm repetition penalty. + /// + /// Number of notes since the last repetition of a rhythm change. + private static double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince); + + /// + /// Calculates a penalty based on the number of notes since the last rhythm change. + /// Both rare and frequent rhythm changes are penalised. + /// + /// Number of notes since the last rhythm change. + private static double patternLengthPenalty(int patternLength) + { + double shortPatternPenalty = Math.Min(0.15 * patternLength, 1.0); + double longPatternPenalty = Math.Clamp(2.5 - 0.15 * patternLength, 0.0, 1.0); + return Math.Min(shortPatternPenalty, longPatternPenalty); + } + + /// + /// Calculates a penalty for objects that do not require alternating hands. + /// + /// Time (in milliseconds) since the last hit object. + private double speedPenalty(double deltaTime) + { + if (deltaTime < 80) return 1; + if (deltaTime < 210) return Math.Max(0, 1.4 - 0.005 * deltaTime); + + resetRhythmAndStrain(); + return 0.0; + } + + /// + /// Resets the rolling strain value and counter. + /// + private void resetRhythmAndStrain() + { + currentStrain = 0.0; + notesSinceRhythmChange = 0; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs new file mode 100644 index 0000000000..0b61eb9930 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.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. + +using System.Linq; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Difficulty.Utils; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Skills +{ + /// + /// Calculates the stamina coefficient of taiko difficulty. + /// + /// + /// The reference play style chosen uses two hands, with full alternating (the hand changes after every hit). + /// + public class Stamina : Skill + { + protected override double SkillMultiplier => 1; + protected override double StrainDecayBase => 0.4; + + /// + /// Maximum number of entries to keep in . + /// + private const int max_history_length = 2; + + /// + /// The index of the hand this instance is associated with. + /// + /// + /// The value of 0 indicates the left hand (full alternating gameplay starting with left hand is assumed). + /// This naturally translates onto index offsets of the objects in the map. + /// + private readonly int hand; + + /// + /// Stores the last durations between notes hit with the hand indicated by . + /// + private readonly LimitedCapacityQueue notePairDurationHistory = new LimitedCapacityQueue(max_history_length); + + /// + /// Stores the of the last object that was hit by the other hand. + /// + private double offhandObjectDuration = double.MaxValue; + + /// + /// Creates a skill. + /// + /// Whether this instance is performing calculations for the right hand. + public Stamina(bool rightHand) + { + hand = rightHand ? 1 : 0; + } + + protected override double StrainValueOf(DifficultyHitObject current) + { + if (!(current.BaseObject is Hit)) + { + return 0.0; + } + + TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current; + + if (hitObject.ObjectIndex % 2 == hand) + { + double objectStrain = 1; + + if (hitObject.ObjectIndex == 1) + return 1; + + notePairDurationHistory.Enqueue(hitObject.DeltaTime + offhandObjectDuration); + + double shortestRecentNote = notePairDurationHistory.Min(); + objectStrain += speedBonus(shortestRecentNote); + + if (hitObject.StaminaCheese) + objectStrain *= cheesePenalty(hitObject.DeltaTime + offhandObjectDuration); + + return objectStrain; + } + + offhandObjectDuration = hitObject.DeltaTime; + return 0; + } + + /// + /// Applies a penalty for hit objects marked with . + /// + /// The duration between the current and previous note hit using the hand indicated by . + private double cheesePenalty(double notePairDuration) + { + if (notePairDuration > 125) return 1; + if (notePairDuration < 100) return 0.6; + + return 0.6 + (notePairDuration - 100) * 0.016; + } + + /// + /// Applies a speed bonus dependent on the time since the last hit performed using this hand. + /// + /// The duration between the current and previous note hit using the hand indicated by . + private double speedBonus(double notePairDuration) + { + if (notePairDuration >= 200) return 0; + + double bonus = 200 - notePairDuration; + bonus *= bonus; + return bonus / 100000; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs deleted file mode 100644 index c6fe273b50..0000000000 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Strain.cs +++ /dev/null @@ -1,95 +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.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; -using osu.Game.Rulesets.Taiko.Objects; - -namespace osu.Game.Rulesets.Taiko.Difficulty.Skills -{ - public class Strain : Skill - { - private const double rhythm_change_base_threshold = 0.2; - private const double rhythm_change_base = 2.0; - - protected override double SkillMultiplier => 1; - protected override double StrainDecayBase => 0.3; - - private ColourSwitch lastColourSwitch = ColourSwitch.None; - - private int sameColourCount = 1; - - protected override double StrainValueOf(DifficultyHitObject current) - { - double addition = 1; - - // We get an extra addition if we are not a slider or spinner - if (current.LastObject is Hit && current.BaseObject is Hit && current.DeltaTime < 1000) - { - if (hasColourChange(current)) - addition += 0.75; - - if (hasRhythmChange(current)) - addition += 1; - } - else - { - lastColourSwitch = ColourSwitch.None; - sameColourCount = 1; - } - - double additionFactor = 1; - - // Scale the addition factor linearly from 0.4 to 1 for DeltaTime from 0 to 50 - if (current.DeltaTime < 50) - additionFactor = 0.4 + 0.6 * current.DeltaTime / 50; - - return additionFactor * addition; - } - - private bool hasRhythmChange(DifficultyHitObject current) - { - // We don't want a division by zero if some random mapper decides to put two HitObjects at the same time. - if (current.DeltaTime == 0 || Previous.Count == 0 || Previous[0].DeltaTime == 0) - return false; - - double timeElapsedRatio = Math.Max(Previous[0].DeltaTime / current.DeltaTime, current.DeltaTime / Previous[0].DeltaTime); - - if (timeElapsedRatio >= 8) - return false; - - double difference = Math.Log(timeElapsedRatio, rhythm_change_base) % 1.0; - - return difference > rhythm_change_base_threshold && difference < 1 - rhythm_change_base_threshold; - } - - private bool hasColourChange(DifficultyHitObject current) - { - var taikoCurrent = (TaikoDifficultyHitObject)current; - - if (!taikoCurrent.HasTypeChange) - { - sameColourCount++; - return false; - } - - var oldColourSwitch = lastColourSwitch; - var newColourSwitch = sameColourCount % 2 == 0 ? ColourSwitch.Even : ColourSwitch.Odd; - - lastColourSwitch = newColourSwitch; - sameColourCount = 1; - - // We only want a bonus if the parity of the color switch changes - return oldColourSwitch != ColourSwitch.None && oldColourSwitch != newColourSwitch; - } - - private enum ColourSwitch - { - None, - Even, - Odd - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index 75d3807bba..5bed48bcc6 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -7,7 +7,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { public class TaikoDifficultyAttributes : DifficultyAttributes { + public double StaminaStrain; + public double RhythmStrain; + public double ColourStrain; + public double ApproachRate; public double GreatHitWindow; - public int MaxCombo; } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index c8f3e18911..e5485db4df 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.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.Game.Beatmaps; @@ -8,45 +9,33 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Scoring; namespace osu.Game.Rulesets.Taiko.Difficulty { public class TaikoDifficultyCalculator : DifficultyCalculator { - private const double star_scaling_factor = 0.04125; + private const double rhythm_skill_multiplier = 0.014; + private const double colour_skill_multiplier = 0.01; + private const double stamina_skill_multiplier = 0.02; public TaikoDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) : base(ruleset, beatmap) { } - protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] { - if (beatmap.HitObjects.Count == 0) - return new TaikoDifficultyAttributes { Mods = mods, Skills = skills }; - - return new TaikoDifficultyAttributes - { - StarRating = skills.Single().DifficultyValue() * star_scaling_factor, - Mods = mods, - // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future - GreatHitWindow = (int)(beatmap.HitObjects.First().HitWindows.Great / 2) / clockRate, - MaxCombo = beatmap.HitObjects.Count(h => h is Hit), - Skills = skills - }; - } - - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) - { - for (int i = 1; i < beatmap.HitObjects.Count; i++) - yield return new TaikoDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate); - } - - protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] { new Strain() }; + new Colour(), + new Rhythm(), + new Stamina(true), + new Stamina(false), + }; protected override Mod[] DifficultyAdjustmentMods => new Mod[] { @@ -55,5 +44,124 @@ namespace osu.Game.Rulesets.Taiko.Difficulty new TaikoModEasy(), new TaikoModHardRock(), }; + + protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) + { + List taikoDifficultyHitObjects = new List(); + + for (int i = 2; i < beatmap.HitObjects.Count; i++) + { + taikoDifficultyHitObjects.Add( + new TaikoDifficultyHitObject( + beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, i + ) + ); + } + + new StaminaCheeseDetector(taikoDifficultyHitObjects).FindCheese(); + return taikoDifficultyHitObjects; + } + + protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) + { + if (beatmap.HitObjects.Count == 0) + return new TaikoDifficultyAttributes { Mods = mods, Skills = skills }; + + var colour = (Colour)skills[0]; + var rhythm = (Rhythm)skills[1]; + var staminaRight = (Stamina)skills[2]; + var staminaLeft = (Stamina)skills[3]; + + double colourRating = colour.DifficultyValue() * colour_skill_multiplier; + double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; + double staminaRating = (staminaRight.DifficultyValue() + staminaLeft.DifficultyValue()) * stamina_skill_multiplier; + + double staminaPenalty = simpleColourPenalty(staminaRating, colourRating); + staminaRating *= staminaPenalty; + + double combinedRating = locallyCombinedDifficulty(colour, rhythm, staminaRight, staminaLeft, staminaPenalty); + double separatedRating = norm(1.5, colourRating, rhythmRating, staminaRating); + double starRating = 1.4 * separatedRating + 0.5 * combinedRating; + starRating = rescale(starRating); + + HitWindows hitWindows = new TaikoHitWindows(); + hitWindows.SetDifficulty(beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty); + + return new TaikoDifficultyAttributes + { + StarRating = starRating, + Mods = mods, + StaminaStrain = staminaRating, + RhythmStrain = rhythmRating, + ColourStrain = colourRating, + // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future + GreatHitWindow = (int)hitWindows.WindowFor(HitResult.Great) / clockRate, + MaxCombo = beatmap.HitObjects.Count(h => h is Hit), + Skills = skills + }; + } + + /// + /// Calculates the penalty for the stamina skill for maps with low colour difficulty. + /// + /// + /// Some maps (especially converts) can be easy to read despite a high note density. + /// This penalty aims to reduce the star rating of such maps by factoring in colour difficulty to the stamina skill. + /// + private double simpleColourPenalty(double staminaDifficulty, double colorDifficulty) + { + if (colorDifficulty <= 0) return 0.79 - 0.25; + + return 0.79 - Math.Atan(staminaDifficulty / colorDifficulty - 12) / Math.PI / 2; + } + + /// + /// Returns the p-norm of an n-dimensional vector. + /// + /// The value of p to calculate the norm for. + /// The coefficients of the vector. + private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); + + /// + /// Returns the partial star rating of the beatmap, calculated using peak strains from all sections of the map. + /// + /// + /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. + /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). + /// + private double locallyCombinedDifficulty(Colour colour, Rhythm rhythm, Stamina staminaRight, Stamina staminaLeft, double staminaPenalty) + { + List peaks = new List(); + + for (int i = 0; i < colour.StrainPeaks.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; + peaks.Add(norm(2, colourPeak, rhythmPeak, staminaPeak)); + } + + double difficulty = 0; + double weight = 1; + + foreach (double strain in peaks.OrderByDescending(d => d)) + { + difficulty += strain * weight; + weight *= 0.9; + } + + return difficulty; + } + + /// + /// Applies a final re-scaling of the star rating to bring maps with recorded full combos below 9.5 stars. + /// + /// The raw star rating value before re-scaling. + private double rescale(double sr) + { + if (sr < 0) return sr; + + return 10.43 * Math.Log(sr / 8 + 1); + } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 70249db0f6..2d9b95ae88 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Game.Beatmaps; +using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -19,22 +19,22 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private Mod[] mods; private int countGreat; - private int countGood; + private int countOk; private int countMeh; private int countMiss; - public TaikoPerformanceCalculator(Ruleset ruleset, WorkingBeatmap beatmap, ScoreInfo score) - : base(ruleset, beatmap, score) + public TaikoPerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) + : base(ruleset, attributes, score) { } public override double Calculate(Dictionary categoryDifficulty = null) { mods = Score.Mods; - countGreat = Convert.ToInt32(Score.Statistics[HitResult.Great]); - countGood = Convert.ToInt32(Score.Statistics[HitResult.Good]); - countMeh = Convert.ToInt32(Score.Statistics[HitResult.Meh]); - countMiss = Convert.ToInt32(Score.Statistics[HitResult.Miss]); + countGreat = Score.Statistics.GetOrDefault(HitResult.Great); + countOk = Score.Statistics.GetOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetOrDefault(HitResult.Miss); // Don't count scores made with supposedly unranked mods if (mods.Any(m => !m.Ranked)) @@ -71,16 +71,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty double strainValue = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0075) - 4.0, 2.0) / 100000.0; // Longer maps are worth more - double lengthBonus = 1 + 0.1f * Math.Min(1.0, totalHits / 1500.0); + double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); strainValue *= lengthBonus; // Penalize misses exponentially. This mainly fixes tag4 maps and the likes until a per-hitobject solution is available strainValue *= Math.Pow(0.985, countMiss); - // Combo scaling - if (Attributes.MaxCombo > 0) - strainValue *= Math.Min(Math.Pow(Score.MaxCombo, 0.5) / Math.Pow(Attributes.MaxCombo, 0.5), 1.0); - if (mods.Any(m => m is ModHidden)) strainValue *= 1.025; @@ -105,6 +101,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty return accValue * Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); } - private int totalHits => countGreat + countGood + countMeh + countMiss; + private int totalHits => countGreat + countOk + countMeh + countMiss; } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/DrumRollPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/DrumRollPlacementBlueprint.cs new file mode 100644 index 0000000000..eb07ce7635 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/DrumRollPlacementBlueprint.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.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class DrumRollPlacementBlueprint : TaikoSpanPlacementBlueprint + { + public DrumRollPlacementBlueprint() + : base(new DrumRoll()) + { + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPiece.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPiece.cs new file mode 100644 index 0000000000..b02e3aa9ba --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPiece.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.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class HitPiece : CompositeDrawable + { + public HitPiece() + { + Origin = Anchor.Centre; + + InternalChild = new CircularContainer + { + Masking = true, + BorderThickness = 10, + BorderColour = Color4.Yellow, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both + } + } + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.cs new file mode 100644 index 0000000000..c5191ab241 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/HitPlacementBlueprint.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 osu.Framework.Input.Events; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class HitPlacementBlueprint : PlacementBlueprint + { + private readonly HitPiece piece; + + private static Hit hit; + + public HitPlacementBlueprint() + : base(hit = new Hit()) + { + InternalChild = piece = new HitPiece + { + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE * TaikoPlayfield.DEFAULT_HEIGHT) + }; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + switch (e.Button) + { + case MouseButton.Left: + hit.Type = HitType.Centre; + EndPlacement(true); + return true; + + case MouseButton.Right: + hit.Type = HitType.Rim; + EndPlacement(true); + return true; + } + + return false; + } + + public override void UpdatePosition(SnapResult result) + { + piece.Position = ToLocalSpace(result.ScreenSpacePosition); + base.UpdatePosition(result); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/LengthPiece.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/LengthPiece.cs new file mode 100644 index 0000000000..6b651fd739 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/LengthPiece.cs @@ -0,0 +1,40 @@ +// 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.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class LengthPiece : CompositeDrawable + { + public LengthPiece() + { + Origin = Anchor.CentreLeft; + + InternalChild = new Container + { + Masking = true, + Colour = Color4.Yellow, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.X, + Height = 8, + }, + new Box + { + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = 8, + } + } + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/SwellPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/SwellPlacementBlueprint.cs new file mode 100644 index 0000000000..95fa82a0f2 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/SwellPlacementBlueprint.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.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class SwellPlacementBlueprint : TaikoSpanPlacementBlueprint + { + public SwellPlacementBlueprint() + : base(new Swell()) + { + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs new file mode 100644 index 0000000000..62f69122cc --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSelectionBlueprint.cs @@ -0,0 +1,40 @@ +// 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.Edit; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class TaikoSelectionBlueprint : OverlaySelectionBlueprint + { + public TaikoSelectionBlueprint(DrawableHitObject hitObject) + : base(hitObject) + { + RelativeSizeAxes = Axes.None; + + AddInternal(new HitPiece + { + RelativeSizeAxes = Axes.Both, + Origin = Anchor.TopLeft + }); + } + + protected override void Update() + { + base.Update(); + + // Move the rectangle to cover the hitobjects + var topLeft = new Vector2(float.MaxValue, float.MaxValue); + var bottomRight = new Vector2(float.MinValue, float.MinValue); + + topLeft = Vector2.ComponentMin(topLeft, Parent.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.TopLeft)); + bottomRight = Vector2.ComponentMax(bottomRight, Parent.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.BottomRight)); + + Size = bottomRight - topLeft; + Position = topLeft; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs new file mode 100644 index 0000000000..468d980b23 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -0,0 +1,110 @@ +// 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.Input.Events; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Taiko.Edit.Blueprints +{ + public class TaikoSpanPlacementBlueprint : PlacementBlueprint + { + private readonly HitPiece headPiece; + private readonly HitPiece tailPiece; + + private readonly LengthPiece lengthPiece; + + private readonly IHasDuration spanPlacementObject; + + public TaikoSpanPlacementBlueprint(HitObject hitObject) + : base(hitObject) + { + spanPlacementObject = hitObject as IHasDuration; + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + headPiece = new HitPiece + { + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE * TaikoPlayfield.DEFAULT_HEIGHT) + }, + lengthPiece = new LengthPiece + { + Height = TaikoHitObject.DEFAULT_SIZE * TaikoPlayfield.DEFAULT_HEIGHT + }, + tailPiece = new HitPiece + { + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE * TaikoPlayfield.DEFAULT_HEIGHT) + } + }; + } + + private double originalStartTime; + private Vector2 originalPosition; + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button != MouseButton.Left) + return false; + + BeginPlacement(true); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button != MouseButton.Left) + return; + + base.OnMouseUp(e); + EndPlacement(true); + } + + public override void UpdatePosition(SnapResult result) + { + base.UpdatePosition(result); + + if (PlacementActive) + { + if (result.Time is double dragTime) + { + if (dragTime < originalStartTime) + { + HitObject.StartTime = dragTime; + spanPlacementObject.Duration = Math.Abs(dragTime - originalStartTime); + headPiece.Position = ToLocalSpace(result.ScreenSpacePosition); + tailPiece.Position = originalPosition; + } + else + { + HitObject.StartTime = originalStartTime; + spanPlacementObject.Duration = Math.Abs(dragTime - originalStartTime); + tailPiece.Position = ToLocalSpace(result.ScreenSpacePosition); + headPiece.Position = originalPosition; + } + + lengthPiece.X = headPiece.X; + lengthPiece.Width = tailPiece.X - headPiece.X; + } + } + else + { + lengthPiece.Position = headPiece.Position = tailPiece.Position = ToLocalSpace(result.ScreenSpacePosition); + + if (result.Time is double startTime) + { + originalStartTime = HitObject.StartTime = startTime; + originalPosition = ToLocalSpace(result.ScreenSpacePosition); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs new file mode 100644 index 0000000000..587a4efecb --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.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; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Taiko.Edit.Blueprints; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class DrumRollCompositionTool : HitObjectCompositionTool + { + public DrumRollCompositionTool() + : base(nameof(DrumRoll)) + { + } + + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); + + public override PlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs new file mode 100644 index 0000000000..3e97b4e322 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.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; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Taiko.Edit.Blueprints; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class HitCompositionTool : HitObjectCompositionTool + { + public HitCompositionTool() + : base(nameof(Hit)) + { + } + + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); + + public override PlacementBlueprint CreatePlacementBlueprint() => new HitPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs new file mode 100644 index 0000000000..918afde1dd --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.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; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Taiko.Edit.Blueprints; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class SwellCompositionTool : HitObjectCompositionTool + { + public SwellCompositionTool() + : base(nameof(Swell)) + { + } + + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners); + + public override PlacementBlueprint CreatePlacementBlueprint() => new SwellPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.cs new file mode 100644 index 0000000000..35227b3c64 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoBlueprintContainer.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 osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Edit.Blueprints; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class TaikoBlueprintContainer : ComposeBlueprintContainer + { + public TaikoBlueprintContainer(IEnumerable hitObjects) + : base(hitObjects) + { + } + + protected override SelectionHandler CreateSelectionHandler() => new TaikoSelectionHandler(); + + public override OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => + new TaikoSelectionBlueprint(hitObject); + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs new file mode 100644 index 0000000000..cdc9672a8e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.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.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class TaikoHitObjectComposer : HitObjectComposer + { + public TaikoHitObjectComposer(TaikoRuleset ruleset) + : base(ruleset) + { + } + + protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] + { + new HitCompositionTool(), + new DrumRollCompositionTool(), + new SwellCompositionTool() + }; + + protected override ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects) + => new TaikoBlueprintContainer(hitObjects); + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs new file mode 100644 index 0000000000..a05de1f217 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.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.UserInterface; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public class TaikoSelectionHandler : SelectionHandler + { + private readonly Bindable selectionRimState = new Bindable(); + private readonly Bindable selectionStrongState = new Bindable(); + + [BackgroundDependencyLoader] + private void load() + { + selectionStrongState.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + SetStrongState(false); + break; + + case TernaryState.True: + SetStrongState(true); + break; + } + }; + + selectionRimState.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + SetRimState(false); + break; + + case TernaryState.True: + SetRimState(true); + break; + } + }; + } + + public void SetStrongState(bool state) + { + var hits = EditorBeatmap.SelectedHitObjects.OfType(); + + EditorBeatmap.BeginChange(); + + foreach (var h in hits) + { + if (h.IsStrong != state) + { + h.IsStrong = state; + EditorBeatmap.Update(h); + } + } + + EditorBeatmap.EndChange(); + } + + 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(); + } + + 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.HitObject is TaikoHitObject)) + yield return new TernaryStateMenuItem("Strong") { State = { BindTarget = selectionStrongState } }; + } + + public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + + protected override void UpdateTernaryStates() + { + base.UpdateTernaryStates(); + + selectionRimState.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects.OfType(), h => h.Type == HitType.Rim); + selectionStrongState.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects.OfType(), h => h.IsStrong); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs index 604daa929f..0d91002f4b 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollJudgement.cs @@ -7,8 +7,6 @@ namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoDrumRollJudgement : TaikoJudgement { - public override bool AffectsCombo => false; - protected override double HealthIncreaseFor(HitResult result) { // Drum rolls can be ignored with no health penalty diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs index a617028f1c..647ad7853d 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoDrumRollTickJudgement.cs @@ -7,25 +7,13 @@ namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoDrumRollTickJudgement : TaikoJudgement { - public override bool AffectsCombo => false; - - protected override int NumericResultFor(HitResult result) - { - switch (result) - { - case HitResult.Great: - return 200; - - default: - return 0; - } - } + public override HitResult MaxResult => HitResult.SmallTickHit; protected override double HealthIncreaseFor(HitResult result) { switch (result) { - case HitResult.Great: + case HitResult.SmallTickHit: return 0.15; default: diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoJudgement.cs index eb5f443365..e272c1a4ef 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoJudgement.cs @@ -10,21 +10,6 @@ namespace osu.Game.Rulesets.Taiko.Judgements { public override HitResult MaxResult => HitResult.Great; - protected override int NumericResultFor(HitResult result) - { - switch (result) - { - case HitResult.Good: - return 100; - - case HitResult.Great: - return 300; - - default: - return 0; - } - } - protected override double HealthIncreaseFor(HitResult result) { switch (result) @@ -32,7 +17,7 @@ namespace osu.Game.Rulesets.Taiko.Judgements case HitResult.Miss: return -1.0; - case HitResult.Good: + case HitResult.Ok: return 1.1; case HitResult.Great: diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs index e045ea324f..06495ad9f4 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoStrongJudgement.cs @@ -7,9 +7,9 @@ namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoStrongJudgement : TaikoJudgement { + public override HitResult MaxResult => HitResult.SmallBonus; + // MainObject already changes the HP protected override double HealthIncreaseFor(HitResult result) => 0; - - public override bool AffectsCombo => false; } } diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs index 29be5e0eac..4d61efd3ee 100644 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs +++ b/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellJudgement.cs @@ -7,8 +7,6 @@ namespace osu.Game.Rulesets.Taiko.Judgements { public class TaikoSwellJudgement : TaikoJudgement { - public override bool AffectsCombo => false; - protected override double HealthIncreaseFor(HitResult result) { switch (result) diff --git a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellTickJudgement.cs b/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellTickJudgement.cs deleted file mode 100644 index b28b6a0d17..0000000000 --- a/osu.Game.Rulesets.Taiko/Judgements/TaikoSwellTickJudgement.cs +++ /dev/null @@ -1,16 +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.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Taiko.Judgements -{ - public class TaikoSwellTickJudgement : TaikoJudgement - { - public override bool AffectsCombo => false; - - protected override int NumericResultFor(HitResult result) => 0; - - protected override double HealthIncreaseFor(HitResult result) => 0; - } -} diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.cs new file mode 100644 index 0000000000..71aa007d3b --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModCinema.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.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Replays; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public class TaikoModCinema : ModCinema + { + public override Score CreateReplayScore(IBeatmap beatmap) => new Score + { + ScoreInfo = new ScoreInfo { User = new User { Username = "mekkadosu!" } }, + Replay = new TaikoAutoGenerator(beatmap).Generate(), + }; + } +} diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs new file mode 100644 index 0000000000..56a73ad7df --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.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 TaikoModDifficultyAdjust : ModDifficultyAdjust + { + } +} diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs index b7db3307ad..1253b7c8ae 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModFlashlight.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Layout; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; @@ -30,13 +30,15 @@ namespace osu.Game.Rulesets.Taiko.Mods private class TaikoFlashlight : Flashlight { - private readonly Cached flashlightProperties = new Cached(); + private readonly LayoutValue flashlightProperties = new LayoutValue(Invalidation.DrawSize); private readonly TaikoPlayfield taikoPlayfield; public TaikoFlashlight(TaikoPlayfield taikoPlayfield) { this.taikoPlayfield = taikoPlayfield; FlashlightSize = new Vector2(0, getSizeFor(0)); + + AddLayout(flashlightProperties); } private float getSizeFor(int combo) @@ -56,16 +58,6 @@ namespace osu.Game.Rulesets.Taiko.Mods protected override string FragmentShader => "CircularFlashlight"; - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.DrawSize) > 0) - { - flashlightProperties.Invalidate(); - } - - return base.Invalidate(invalidation, source, shallPropagate); - } - protected override void Update() { base.Update(); diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs index e45081b6d6..5377eb1072 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModNightcore.cs @@ -2,10 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Mods { - public class TaikoModNightcore : ModNightcore + public class TaikoModNightcore : ModNightcore { public override double ScoreMultiplier => 1.12; } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs new file mode 100644 index 0000000000..1cf19ac18e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.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.Utils; +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 TaikoModRandom : ModRandom, IApplicableToBeatmap + { + public override string Description => @"Shuffle around the colours!"; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var taikoBeatmap = (TaikoBeatmap)beatmap; + + foreach (var obj in taikoBeatmap.HitObjects) + { + if (obj is Hit hit) + hit.Type = RNG.Next(2) == 0 ? HitType.Centre : HitType.Rim; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/BarLine.cs b/osu.Game.Rulesets.Taiko/Objects/BarLine.cs index a07012fd71..6306195704 100644 --- a/osu.Game.Rulesets.Taiko/Objects/BarLine.cs +++ b/osu.Game.Rulesets.Taiko/Objects/BarLine.cs @@ -1,9 +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.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; + namespace osu.Game.Rulesets.Taiko.Objects { - public class BarLine : TaikoHitObject + public class BarLine : TaikoHitObject, IBarLine { + public bool Major { get; set; } + + public override Judgement CreateJudgement() => new IgnoreJudgement(); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLine.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLine.cs index f8909fb98c..aadcc420df 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLine.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLine.cs @@ -3,15 +3,17 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects; using osuTK; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { /// /// A line that scrolls alongside hit objects in the playfield and visualises control points. /// - public class DrawableBarLine : DrawableHitObject + public class DrawableBarLine : DrawableHitObject { /// /// The width of the line tracker. @@ -26,7 +28,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// /// The visual line tracker. /// - protected Box Tracker; + protected SkinnableDrawable Line; /// /// The bar line. @@ -44,18 +46,18 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables RelativeSizeAxes = Axes.Y; Width = tracker_width; - AddInternal(Tracker = new Box + AddInternal(Line = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.BarLine), _ => new Box + { + RelativeSizeAxes = Axes.Both, + EdgeSmoothness = new Vector2(0.5f, 0), + }) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - EdgeSmoothness = new Vector2(0.5f, 0), - Alpha = 0.75f + Alpha = 0.75f, }); } - protected override void UpdateState(ArmedState state) - { - } + protected override void UpdateHitStateTransforms(ArmedState state) => this.FadeOut(150); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs index 4d3a1a3f8a..62aab3524b 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableBarLineMajor.cs @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } }); - Tracker.Alpha = 1f; + Line.Alpha = 1f; } protected override void LoadComplete() diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableCentreHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableCentreHit.cs deleted file mode 100644 index 4979135f50..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableCentreHit.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.Allocation; -using osu.Game.Graphics; -using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; - -namespace osu.Game.Rulesets.Taiko.Objects.Drawables -{ - public class DrawableCentreHit : DrawableHit - { - public override TaikoAction[] HitActions { get; } = { TaikoAction.LeftCentre, TaikoAction.RightCentre }; - - public DrawableCentreHit(Hit hit) - : base(hit) - { - MainPiece.Add(new CentreHitSymbolPiece()); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - MainPiece.AccentColour = colours.PinkDarker; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index 9b4df74a61..c596fa2c7c 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -1,18 +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; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; -using osuTK; using osuTK.Graphics; using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -28,49 +31,90 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// private int rollingHits; - public DrawableDrumRoll(DrumRoll drumRoll) - : base(drumRoll) - { - RelativeSizeAxes = Axes.Y; - - Container tickContainer; - MainPiece.Add(tickContainer = new Container { RelativeSizeAxes = Axes.Both }); - - foreach (var tick in drumRoll.NestedHitObjects.OfType()) - { - var newTick = new DrawableDrumRollTick(tick); - newTick.OnNewResult += onNewTickResult; - - AddNested(newTick); - tickContainer.Add(newTick); - } - } - - protected override TaikoPiece CreateMainPiece() => new ElongatedCirclePiece(); - - public override bool OnPressed(TaikoAction action) => false; + private Container tickContainer; private Color4 colourIdle; private Color4 colourEngaged; + public DrawableDrumRoll(DrumRoll drumRoll) + : base(drumRoll) + { + RelativeSizeAxes = Axes.Y; + } + [BackgroundDependencyLoader] private void load(OsuColour colours) { - MainPiece.AccentColour = colourIdle = colours.YellowDark; + colourIdle = colours.YellowDark; colourEngaged = colours.YellowDarker; + + Content.Add(tickContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Depth = float.MinValue + }); } - private void onNewTickResult(DrawableHitObject obj, JudgementResult result) + protected override void LoadComplete() { - if (result.Type > HitResult.Miss) + base.LoadComplete(); + + OnNewResult += onNewResult; + } + + protected override void RecreatePieces() + { + base.RecreatePieces(); + updateColour(); + } + + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) + { + case DrawableDrumRollTick tick: + tickContainer.Add(tick); + break; + } + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + tickContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case DrumRollTick tick: + return new DrawableDrumRollTick(tick); + } + + return base.CreateNestedHitObject(hitObject); + } + + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollBody), + _ => new ElongatedCirclePiece()); + + public override bool OnPressed(TaikoAction action) => false; + + private void onNewResult(DrawableHitObject obj, JudgementResult result) + { + if (!(obj is DrawableDrumRollTick)) + return; + + if (result.IsHit) rollingHits++; else rollingHits--; - rollingHits = MathHelper.Clamp(rollingHits, 0, rolling_hits_for_engaged_colour); + rollingHits = Math.Clamp(rollingHits, 0, rolling_hits_for_engaged_colour); - Color4 newColour = Interpolation.ValueAt((float)rollingHits / rolling_hits_for_engaged_colour, colourIdle, colourEngaged, 0, 1); - MainPiece.FadeAccent(newColour, 100); + updateColour(); } protected override void CheckForResult(bool userTriggered, double timeOffset) @@ -82,25 +126,42 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables return; int countHit = NestedHitObjects.Count(o => o.IsHit); + if (countHit >= HitObject.RequiredGoodHits) - ApplyResult(r => r.Type = countHit >= HitObject.RequiredGreatHits ? HitResult.Great : HitResult.Good); + { + ApplyResult(r => r.Type = countHit >= HitObject.RequiredGreatHits ? HitResult.Great : HitResult.Ok); + } else - ApplyResult(r => r.Type = HitResult.Miss); + ApplyResult(r => r.Type = r.Judgement.MinResult); } - protected override void UpdateState(ArmedState state) + protected override void UpdateHitStateTransforms(ArmedState state) { switch (state) { case ArmedState.Hit: case ArmedState.Miss: - this.FadeOut(100).Expire(); + this.FadeOut(100); break; } } + protected override void Update() + { + base.Update(); + + OriginPosition = new Vector2(DrawHeight); + Content.X = DrawHeight / 2; + } + protected override DrawableStrongNestedHit CreateStrongHit(StrongHitObject hitObject) => new StrongNestedHit(hitObject, this); + private void updateColour() + { + Color4 newColour = Interpolation.ValueAt((float)rollingHits / rolling_hits_for_engaged_colour, colourIdle, colourEngaged, 0, 1); + (MainPiece.Drawable as IHasAccentColour)?.FadeAccent(newColour, 100); + } + private class StrongNestedHit : DrawableStrongNestedHit { public StrongNestedHit(StrongHitObject strong, DrawableDrumRoll drumRoll) @@ -113,7 +174,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables if (!MainObject.Judged) return; - ApplyResult(r => r.Type = MainObject.IsHit ? HitResult.Great : HitResult.Miss); + ApplyResult(r => r.Type = MainObject.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult); } public override bool OnPressed(TaikoAction action) => false; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs index 9259c693d9..bf44a80037 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRollTick.cs @@ -4,52 +4,60 @@ using System; using osu.Framework.Graphics; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { public class DrawableDrumRollTick : DrawableTaikoHitObject { + /// + /// The hit type corresponding to the that the user pressed to hit this . + /// + public HitType JudgementType; + public DrawableDrumRollTick(DrumRollTick tick) : base(tick) { FillMode = FillMode.Fit; } - public override bool DisplayResult => false; - - protected override TaikoPiece CreateMainPiece() => new TickPiece - { - Filled = HitObject.FirstTick - }; + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollTick), + _ => new TickPiece + { + Filled = HitObject.FirstTick + }); protected override void CheckForResult(bool userTriggered, double timeOffset) { if (!userTriggered) { if (timeOffset > HitObject.HitWindow) - ApplyResult(r => r.Type = HitResult.Miss); + ApplyResult(r => r.Type = r.Judgement.MinResult); return; } if (Math.Abs(timeOffset) > HitObject.HitWindow) return; - ApplyResult(r => r.Type = HitResult.Great); + ApplyResult(r => r.Type = r.Judgement.MaxResult); } - protected override void UpdateState(ArmedState state) + protected override void UpdateHitStateTransforms(ArmedState state) { switch (state) { case ArmedState.Hit: - this.ScaleTo(0, 100, Easing.OutQuint).Expire(); + this.ScaleTo(0, 100, Easing.OutQuint); break; } } - public override bool OnPressed(TaikoAction action) => UpdateResult(true); + public override bool OnPressed(TaikoAction action) + { + JudgementType = action == TaikoAction.LeftRim || action == TaikoAction.RightRim ? HitType.Rim : HitType.Centre; + return UpdateResult(true); + } protected override DrawableStrongNestedHit CreateStrongHit(StrongHitObject hitObject) => new StrongNestedHit(hitObject, this); @@ -65,7 +73,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables if (!MainObject.Judged) return; - ApplyResult(r => r.Type = MainObject.IsHit ? HitResult.Great : HitResult.Miss); + ApplyResult(r => r.Type = MainObject.IsHit ? r.Judgement.MaxResult : r.Judgement.MinResult); } public override bool OnPressed(TaikoAction action) => false; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableFlyingHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableFlyingHit.cs new file mode 100644 index 0000000000..3253c1ce5a --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableFlyingHit.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.Beatmaps.ControlPoints; + +namespace osu.Game.Rulesets.Taiko.Objects.Drawables +{ + /// + /// A hit used specifically for drum rolls, where spawning flying hits is required. + /// + public class DrawableFlyingHit : DrawableHit + { + public DrawableFlyingHit(DrawableDrumRollTick drumRollTick) + : base(new IgnoreHit + { + StartTime = drumRollTick.HitObject.StartTime + drumRollTick.Result.TimeOffset, + IsStrong = drumRollTick.HitObject.IsStrong, + Type = drumRollTick.JudgementType + }) + { + HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + ApplyResult(r => r.Type = r.Judgement.MaxResult); + } + + protected override void LoadSamples() + { + // block base call - flying hits are not supposed to play samples + // the base call could overwrite the type of this hit + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index 34ae7db984..4a3759794b 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -2,42 +2,148 @@ // 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 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.Scoring; using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { - public abstract class DrawableHit : DrawableTaikoHitObject + public class DrawableHit : DrawableTaikoHitObject { /// /// A list of keys which can result in hits for this HitObject. /// - public abstract TaikoAction[] HitActions { get; } + public TaikoAction[] HitActions { get; private set; } /// /// The action that caused this to be hit. /// - public TaikoAction? HitAction { get; private set; } + public TaikoAction? HitAction + { + get; + private set; + } private bool validActionPressed; private bool pressHandledThisFrame; - protected DrawableHit(Hit hit) + private readonly Bindable type; + + public DrawableHit(Hit hit) : base(hit) { + type = HitObject.TypeBindable.GetBoundCopy(); FillMode = FillMode.Fit; + + updateActionsFromType(); + } + + [BackgroundDependencyLoader] + private void load() + { + type.BindValueChanged(_ => + { + updateActionsFromType(); + + // will overwrite samples, should only be called on change. + updateSamplesFromTypeChange(); + + RecreatePieces(); + }); + } + + 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 { Name = HitSampleInfo.HIT_CLAP }); + else + { + foreach (var sample in rimSamples) + HitObject.Samples.Remove(sample); + } + } + } + + private void updateActionsFromType() + { + HitActions = + HitObject.Type == HitType.Centre + ? new[] { TaikoAction.LeftCentre, TaikoAction.RightCentre } + : new[] { TaikoAction.LeftRim, TaikoAction.RightRim }; + } + + protected override SkinnableDrawable CreateMainPiece() => HitObject.Type == HitType.Centre + ? new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.CentreHit), _ => new CentreHitCirclePiece(), confineMode: ConfineMode.ScaleToFit) + : new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.RimHit), _ => new RimHitCirclePiece(), confineMode: ConfineMode.ScaleToFit); + + public override IEnumerable GetSamples() + { + // normal and claps are always handled by the drum (see DrumSampleMapping). + // in addition, whistles are excluded as they are an alternative rim marker. + + var samples = HitObject.Samples.Where(s => + s.Name != HitSampleInfo.HIT_NORMAL + && s.Name != HitSampleInfo.HIT_CLAP + && s.Name != HitSampleInfo.HIT_WHISTLE); + + if (HitObject.Type == HitType.Rim && HitObject.IsStrong) + { + // strong + rim always maps to whistle. + // TODO: this should really be in the legacy decoder, but can't be because legacy encoding parity would be broken. + // when we add a taiko editor, this is probably not going to play nice. + + var corrected = samples.ToList(); + + for (var i = 0; i < corrected.Count; i++) + { + var s = corrected[i]; + + if (s.Name != HitSampleInfo.HIT_FINISH) + continue; + + var sClone = s.Clone(); + sClone.Name = HitSampleInfo.HIT_WHISTLE; + corrected[i] = sClone; + } + + return corrected; + } + + return samples; } protected override void CheckForResult(bool userTriggered, double timeOffset) { + Debug.Assert(HitObject.HitWindows != null); + if (!userTriggered) { if (!HitObject.HitWindows.CanBeHit(timeOffset)) - ApplyResult(r => r.Type = HitResult.Miss); + ApplyResult(r => r.Type = r.Judgement.MinResult); return; } @@ -46,7 +152,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables return; if (!validActionPressed) - ApplyResult(r => r.Type = HitResult.Miss); + ApplyResult(r => r.Type = r.Judgement.MinResult); else ApplyResult(r => r.Type = result); } @@ -55,7 +161,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { if (pressHandledThisFrame) return true; - if (Judged) return false; @@ -63,22 +168,20 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables // Only count this as handled if the new judgement is a hit var result = UpdateResult(true); - if (IsHit) HitAction = action; // Regardless of whether we've hit or not, any secondary key presses in the same frame should be discarded // E.g. hitting a non-strong centre as a strong should not fall through and perform a hit on the next note pressHandledThisFrame = true; - return result; } - public override bool OnReleased(TaikoAction action) + public override void OnReleased(TaikoAction action) { if (action == HitAction) HitAction = null; - return base.OnReleased(action); + base.OnReleased(action); } protected override void Update() @@ -88,60 +191,42 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables // The input manager processes all input prior to us updating, so this is the perfect time // for us to remove the extra press blocking, before input is handled in the next frame pressHandledThisFrame = false; - - Size = BaseSize * Parent.RelativeChildSize; } - protected override void UpdateState(ArmedState state) + protected override void UpdateHitStateTransforms(ArmedState state) { - // TODO: update to use new state management. - var circlePiece = MainPiece as CirclePiece; - circlePiece?.FlashBox.FinishTransforms(); + Debug.Assert(HitObject.HitWindows != null); - var offset = !AllJudged ? 0 : Time.Current - HitObject.StartTime; - - using (BeginDelayedSequence(HitObject.StartTime - Time.Current + offset, true)) + switch (state) { - switch (State.Value) - { - case ArmedState.Idle: - validActionPressed = false; + case ArmedState.Idle: + validActionPressed = false; - UnproxyContent(); - this.Delay(HitObject.HitWindows.HalfWindowFor(HitResult.Miss)).Expire(); - break; + UnproxyContent(); + break; - case ArmedState.Miss: - this.FadeOut(100) - .Expire(); - break; + case ArmedState.Miss: + this.FadeOut(100); + break; - case ArmedState.Hit: - // If we're far enough away from the left stage, we should bring outselves in front of it - ProxyContent(); + case ArmedState.Hit: + // If we're far enough away from the left stage, we should bring outselves in front of it + ProxyContent(); - var flash = circlePiece?.FlashBox; + var flash = (MainPiece.Drawable as CirclePiece)?.FlashBox; + flash?.FadeTo(0.9f).FadeOut(300); - if (flash != null) - { - flash.FadeTo(0.9f); - flash.FadeOut(300); - } + const float gravity_time = 300; + const float gravity_travel_height = 200; - const float gravity_time = 300; - const float gravity_travel_height = 200; + this.ScaleTo(0.8f, gravity_time * 2, Easing.OutQuad); - this.ScaleTo(0.8f, gravity_time * 2, Easing.OutQuad); + this.MoveToY(-gravity_travel_height, gravity_time, Easing.Out) + .Then() + .MoveToY(gravity_travel_height * 2, gravity_time * 2, Easing.In); - this.MoveToY(-gravity_travel_height, gravity_time, Easing.Out) - .Then() - .MoveToY(gravity_travel_height * 2, gravity_time * 2, Easing.In); - - this.FadeOut(800) - .Expire(); - - break; - } + this.FadeOut(800); + break; } } @@ -172,19 +257,19 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables if (!MainObject.Result.IsHit) { - ApplyResult(r => r.Type = HitResult.Miss); + ApplyResult(r => r.Type = r.Judgement.MinResult); return; } if (!userTriggered) { if (timeOffset - MainObject.Result.TimeOffset > second_hit_window) - ApplyResult(r => r.Type = HitResult.Miss); + ApplyResult(r => r.Type = r.Judgement.MinResult); return; } if (Math.Abs(timeOffset - MainObject.Result.TimeOffset) <= second_hit_window) - ApplyResult(r => r.Type = MainObject.Result.Type); + ApplyResult(r => r.Type = r.Judgement.MaxResult); } public override bool OnPressed(TaikoAction action) diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableRimHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableRimHit.cs deleted file mode 100644 index 5a12d71cea..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableRimHit.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.Allocation; -using osu.Game.Graphics; -using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; - -namespace osu.Game.Rulesets.Taiko.Objects.Drawables -{ - public class DrawableRimHit : DrawableHit - { - public override TaikoAction[] HitActions { get; } = { TaikoAction.LeftRim, TaikoAction.RightRim }; - - public DrawableRimHit(Hit hit) - : base(hit) - { - MainPiece.Add(new RimHitSymbolPiece()); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - MainPiece.AccentColour = colours.BlueDarker; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs index 98a2e8a721..108e42eea5 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableStrongNestedHit.cs @@ -18,9 +18,5 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { MainObject = mainObject; } - - protected override void UpdateState(ArmedState state) - { - } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index 5ec9dc61e2..ff0a27023d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -10,11 +9,12 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; -using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -25,14 +25,16 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables private const float target_ring_scale = 5f; private const float inner_ring_alpha = 0.65f; - private readonly List ticks = new List(); + /// + /// Offset away from the start time of the swell at which the ring starts appearing. + /// + private const double ring_appear_offset = 100; + private readonly Container ticks; private readonly Container bodyContainer; private readonly CircularContainer targetRing; private readonly CircularContainer expandingRing; - private readonly SwellSymbolPiece symbol; - public DrawableSwell(Swell swell) : base(swell) { @@ -51,7 +53,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Origin = Anchor.Centre, Alpha = 0, RelativeSizeAxes = Axes.Both, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Masking = true, Children = new[] { @@ -70,7 +72,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables RelativeSizeAxes = Axes.Both, Masking = true, BorderThickness = target_ring_thick_border, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Children = new Drawable[] { new Box @@ -103,26 +105,24 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables } }); - MainPiece.Add(symbol = new SwellSymbolPiece()); - - foreach (var tick in HitObject.NestedHitObjects.OfType()) - { - var vis = new DrawableSwellTick(tick); - - ticks.Add(vis); - AddInternal(vis); - AddNested(vis); - } + AddInternal(ticks = new Container { RelativeSizeAxes = Axes.Both }); } [BackgroundDependencyLoader] private void load(OsuColour colours) { - MainPiece.AccentColour = colours.YellowDark; expandingRing.Colour = colours.YellowLight; targetRing.BorderColour = colours.YellowDark.Opacity(0.25f); } + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Swell), + _ => new SwellCirclePiece + { + // to allow for rotation transform + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + protected override void LoadComplete() { base.LoadComplete(); @@ -131,24 +131,62 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Width *= Parent.RelativeChildSize.X; } + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); + + switch (hitObject) + { + case DrawableSwellTick tick: + ticks.Add(tick); + break; + } + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + ticks.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case SwellTick tick: + return new DrawableSwellTick(tick); + } + + return base.CreateNestedHitObject(hitObject); + } + protected override void CheckForResult(bool userTriggered, double timeOffset) { if (userTriggered) { - var nextTick = ticks.Find(j => !j.IsHit); + DrawableSwellTick nextTick = null; - nextTick?.TriggerResult(HitResult.Great); + foreach (var t in ticks) + { + if (!t.IsHit) + { + nextTick = t; + break; + } + } + + nextTick?.TriggerResult(true); var numHits = ticks.Count(r => r.IsHit); var completion = (float)numHits / HitObject.RequiredHits; expandingRing - .FadeTo(expandingRing.Alpha + MathHelper.Clamp(completion / 16, 0.1f, 0.6f), 50) + .FadeTo(expandingRing.Alpha + Math.Clamp(completion / 16, 0.1f, 0.6f), 50) .Then() .FadeTo(completion / 8, 2000, Easing.OutQuint); - symbol.RotateTo((float)(completion * HitObject.Duration / 8), 4000, Easing.OutQuint); + MainPiece.Drawable.RotateTo((float)(completion * HitObject.Duration / 8), 4000, Easing.OutQuint); expandingRing.ScaleTo(1f + Math.Min(target_ring_scale - 1f, (target_ring_scale - 1f) * completion * 1.3f), 260, Easing.OutQuint); @@ -170,35 +208,35 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables continue; } - tick.TriggerResult(HitResult.Miss); + tick.TriggerResult(false); } - var hitResult = numHits > HitObject.RequiredHits / 2 ? HitResult.Good : HitResult.Miss; - - ApplyResult(r => r.Type = hitResult); + ApplyResult(r => r.Type = numHits > HitObject.RequiredHits / 2 ? HitResult.Ok : r.Judgement.MinResult); } } - protected override void UpdateState(ArmedState state) + protected override void UpdateStartTimeStateTransforms() { - const float preempt = 100; - const float out_transition_time = 300; + base.UpdateStartTimeStateTransforms(); + + using (BeginDelayedSequence(-ring_appear_offset, true)) + targetRing.ScaleTo(target_ring_scale, 400, Easing.OutQuint); + } + + protected override void UpdateHitStateTransforms(ArmedState state) + { + const double transition_duration = 300; switch (state) { case ArmedState.Idle: - UnproxyContent(); expandingRing.FadeTo(0); - using (BeginAbsoluteSequence(HitObject.StartTime - preempt, true)) - targetRing.ScaleTo(target_ring_scale, preempt * 4, Easing.OutQuint); break; case ArmedState.Miss: case ArmedState.Hit: - this.FadeOut(out_transition_time, Easing.Out); - bodyContainer.ScaleTo(1.4f, out_transition_time); - - Expire(); + this.FadeOut(transition_duration, Easing.Out); + bodyContainer.ScaleTo(1.4f, transition_duration); break; } } @@ -212,9 +250,10 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables // Make the swell stop at the hit target X = Math.Max(0, X); - double t = Math.Min(HitObject.StartTime, Time.Current); - if (t == HitObject.StartTime) + if (Time.Current >= HitObject.StartTime - ring_appear_offset) ProxyContent(); + else + UnproxyContent(); } private bool? lastWasCentre; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs index 41a8fd9a75..6202583494 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwellTick.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 osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Scoring; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -15,16 +16,21 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { } - public void TriggerResult(HitResult type) => ApplyResult(r => r.Type = type); + protected override void UpdateInitialTransforms() => this.FadeOut(); + + public void TriggerResult(bool hit) + { + HitObject.StartTime = Time.Current; + ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult); + } protected override void CheckForResult(bool userTriggered, double timeOffset) { } - protected override void UpdateState(ArmedState state) - { - } - public override bool OnPressed(TaikoAction action) => false; + + protected override SkinnableDrawable CreateMainPiece() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.DrumRollTick), + _ => new TickPiece()); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index b46738c69a..d8d75a7614 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -1,16 +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.Graphics; -using osu.Framework.Input.Bindings; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces; -using osuTK; -using System.Linq; -using osu.Game.Audio; 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.Primitives; +using osu.Framework.Input.Bindings; +using osu.Game.Audio; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects.Drawables { @@ -44,7 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// /// Moves to a layer proxied above the playfield. - /// Does nothing is content is already proxied. + /// Does nothing if content is already proxied. /// protected void ProxyContent() { @@ -76,59 +79,146 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public Drawable CreateProxiedContent() => proxiedContent.CreateProxy(); public abstract bool OnPressed(TaikoAction action); - public virtual bool OnReleased(TaikoAction action) => false; + + public virtual void OnReleased(TaikoAction action) + { + } + + public override double LifetimeStart + { + get => base.LifetimeStart; + set + { + base.LifetimeStart = value; + proxiedContent.LifetimeStart = value; + } + } + + public override double LifetimeEnd + { + get => base.LifetimeEnd; + set + { + base.LifetimeEnd = value; + proxiedContent.LifetimeEnd = value; + } + } private class ProxiedContentContainer : Container { - public override double LifetimeStart => Parent?.LifetimeStart ?? base.LifetimeStart; - public override double LifetimeEnd => Parent?.LifetimeEnd ?? base.LifetimeEnd; + public override bool RemoveWhenNotAlive => false; } } - public abstract class DrawableTaikoHitObject : DrawableTaikoHitObject - where TaikoHitType : TaikoHitObject + public abstract class DrawableTaikoHitObject : DrawableTaikoHitObject + where TObject : TaikoHitObject { public override Vector2 OriginPosition => new Vector2(DrawHeight / 2); - protected readonly Vector2 BaseSize; + public new TObject HitObject; - protected readonly TaikoPiece MainPiece; + protected Vector2 BaseSize; + protected SkinnableDrawable MainPiece; - public new TaikoHitType HitObject; + private readonly Bindable isStrong; - protected DrawableTaikoHitObject(TaikoHitType hitObject) + private readonly Container strongHitContainer; + + protected DrawableTaikoHitObject(TObject hitObject) : base(hitObject) { HitObject = hitObject; + isStrong = HitObject.IsStrongBindable.GetBoundCopy(); Anchor = Anchor.CentreLeft; Origin = Anchor.Custom; RelativeSizeAxes = Axes.Both; - Size = BaseSize = new Vector2(HitObject.IsStrong ? TaikoHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE); - Content.Add(MainPiece = CreateMainPiece()); - MainPiece.KiaiMode = HitObject.Kiai; + AddInternal(strongHitContainer = new Container()); + } - var strongObject = HitObject.NestedHitObjects.OfType().FirstOrDefault(); - - if (strongObject != null) + [BackgroundDependencyLoader] + private void load() + { + isStrong.BindValueChanged(_ => { - var strongHit = CreateStrongHit(strongObject); + // will overwrite samples, should only be called on change. + updateSamplesFromStrong(); - AddNested(strongHit); - AddInternal(strongHit); + RecreatePieces(); + }); + + RecreatePieces(); + } + + private HitSampleInfo[] getStrongSamples() => HitObject.Samples.Where(s => s.Name == HitSampleInfo.HIT_FINISH).ToArray(); + + protected override void LoadSamples() + { + base.LoadSamples(); + + if (HitObject.CanBeStrong) + isStrong.Value = getStrongSamples().Any(); + } + + private void updateSamplesFromStrong() + { + var strongSamples = getStrongSamples(); + + if (isStrong.Value != strongSamples.Any()) + { + if (isStrong.Value) + HitObject.Samples.Add(new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH }); + else + { + foreach (var sample in strongSamples) + HitObject.Samples.Remove(sample); + } } } - protected override bool UseTransformStateManagement => false; + protected virtual void RecreatePieces() + { + Size = BaseSize = new Vector2(HitObject.IsStrong ? TaikoHitObject.DEFAULT_STRONG_SIZE : TaikoHitObject.DEFAULT_SIZE); - // Normal and clap samples are handled by the drum - protected override IEnumerable GetSamples() => HitObject.Samples.Where(s => s.Name != HitSampleInfo.HIT_NORMAL && s.Name != HitSampleInfo.HIT_CLAP); + MainPiece?.Expire(); + Content.Add(MainPiece = CreateMainPiece()); + } - protected override string SampleNamespace => "Taiko"; + protected override void AddNestedHitObject(DrawableHitObject hitObject) + { + base.AddNestedHitObject(hitObject); - protected virtual TaikoPiece CreateMainPiece() => new CirclePiece(); + switch (hitObject) + { + case DrawableStrongNestedHit strong: + strongHitContainer.Add(strong); + break; + } + } + + protected override void ClearNestedHitObjects() + { + base.ClearNestedHitObjects(); + strongHitContainer.Clear(); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + { + switch (hitObject) + { + case StrongHitObject strong: + return CreateStrongHit(strong); + } + + return base.CreateNestedHitObject(hitObject); + } + + // Most osu!taiko hitsounds are managed by the drum (see DrumSampleMapping). + public override IEnumerable GetSamples() => Enumerable.Empty(); + + protected abstract SkinnableDrawable CreateMainPiece(); /// /// Creates the handler for this 's . diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitCirclePiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitCirclePiece.cs new file mode 100644 index 0000000000..0509841ba8 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitCirclePiece.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; + +namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces +{ + public class CentreHitCirclePiece : CirclePiece + { + public CentreHitCirclePiece() + { + Add(new CentreHitSymbolPiece()); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.PinkDarker; + } + + /// + /// The symbol used for centre hit pieces. + /// + public class CentreHitSymbolPiece : Container + { + public CentreHitSymbolPiece() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(SYMBOL_SIZE); + Padding = new MarginPadding(SYMBOL_BORDER); + + Children = new[] + { + new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new[] { new Box { RelativeSizeAxes = Axes.Both } } + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitSymbolPiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitSymbolPiece.cs deleted file mode 100644 index 7ed61ede96..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CentreHitSymbolPiece.cs +++ /dev/null @@ -1,36 +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 osuTK; -using osu.Framework.Graphics.Shapes; - -namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces -{ - /// - /// The symbol used for centre hit pieces. - /// - public class CentreHitSymbolPiece : Container - { - public CentreHitSymbolPiece() - { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - - RelativeSizeAxes = Axes.Both; - Size = new Vector2(CirclePiece.SYMBOL_SIZE); - Padding = new MarginPadding(CirclePiece.SYMBOL_BORDER); - - Children = new[] - { - new CircularContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new[] { new Box { RelativeSizeAxes = Axes.Both } } - } - }; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CirclePiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CirclePiece.cs index b7db819717..f515a35c18 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/CirclePiece.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.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -8,8 +9,9 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.Backgrounds; using osuTK.Graphics; using osu.Game.Beatmaps.ControlPoints; -using osu.Framework.Audio.Track; using osu.Framework.Graphics.Effects; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces { @@ -20,21 +22,23 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces /// for a usage example. /// /// - public class CirclePiece : TaikoPiece + public abstract class CirclePiece : BeatSyncedContainer, IHasAccentColour { public const float SYMBOL_SIZE = 0.45f; public const float SYMBOL_BORDER = 8; private const double pre_beat_transition_time = 80; + private Color4 accentColour; + /// /// The colour of the inner circle and outer glows. /// - public override Color4 AccentColour + public Color4 AccentColour { - get => base.AccentColour; + get => accentColour; set { - base.AccentColour = value; + accentColour = value; background.Colour = AccentColour; @@ -42,15 +46,17 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces } } + private bool kiaiMode; + /// /// Whether Kiai mode effects are enabled for this circle piece. /// - public override bool KiaiMode + public bool KiaiMode { - get => base.KiaiMode; + get => kiaiMode; set { - base.KiaiMode = value; + kiaiMode = value; resetEdgeEffects(); } @@ -64,8 +70,10 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces public Box FlashBox; - public CirclePiece() + protected CirclePiece() { + RelativeSizeAxes = Axes.Both; + EarlyActivationMilliseconds = pre_beat_transition_time; AddRangeInternal(new Drawable[] @@ -112,7 +120,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Colour = Color4.White, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Alpha = 0, AlwaysPresent = true } @@ -140,7 +148,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces }; } - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { if (!effectPoint.KiaiMode) return; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/ElongatedCirclePiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/ElongatedCirclePiece.cs index 7e3272e42b..034ab6dd21 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/ElongatedCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/ElongatedCirclePiece.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.Graphics; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces { @@ -12,18 +14,15 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces RelativeSizeAxes = Axes.Y; } + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.YellowDark; + } + protected override void Update() { base.Update(); - - var padding = Content.DrawHeight * Content.Width / 2; - - Content.Padding = new MarginPadding - { - Left = padding, - Right = padding, - }; - Width = Parent.DrawSize.X + DrawHeight; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitCirclePiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitCirclePiece.cs new file mode 100644 index 0000000000..3273ab7fa7 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitCirclePiece.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.Graphics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces +{ + public class RimHitCirclePiece : CirclePiece + { + public RimHitCirclePiece() + { + Add(new RimHitSymbolPiece()); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.BlueDarker; + } + + /// + /// The symbol used for rim hit pieces. + /// + public class RimHitSymbolPiece : CircularContainer + { + public RimHitSymbolPiece() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(SYMBOL_SIZE); + + BorderThickness = SYMBOL_BORDER; + BorderColour = Color4.White; + Masking = true; + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + }; + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitSymbolPiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitSymbolPiece.cs deleted file mode 100644 index e4c964a884..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/RimHitSymbolPiece.cs +++ /dev/null @@ -1,39 +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 osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; - -namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces -{ - /// - /// The symbol used for rim hit pieces. - /// - public class RimHitSymbolPiece : CircularContainer - { - public RimHitSymbolPiece() - { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - - RelativeSizeAxes = Axes.Both; - Size = new Vector2(CirclePiece.SYMBOL_SIZE); - - BorderThickness = CirclePiece.SYMBOL_BORDER; - BorderColour = Color4.White; - Masking = true; - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - }; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/SwellSymbolPiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/SwellSymbolPiece.cs index 0ed9923924..a8f9f0b94d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/SwellSymbolPiece.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/SwellSymbolPiece.cs @@ -1,36 +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 osu.Framework.Allocation; using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces { - /// - /// The symbol used for swell pieces. - /// - public class SwellSymbolPiece : Container + public class SwellCirclePiece : CirclePiece { - public SwellSymbolPiece() + public SwellCirclePiece() { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; + Add(new SwellSymbolPiece()); + } - RelativeSizeAxes = Axes.Both; - Size = new Vector2(CirclePiece.SYMBOL_SIZE); - Padding = new MarginPadding(CirclePiece.SYMBOL_BORDER); + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.YellowDark; + } - Children = new[] + /// + /// The symbol used for swell pieces. + /// + public class SwellSymbolPiece : Container + { + public SwellSymbolPiece() { - new SpriteIcon + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(SYMBOL_SIZE); + Padding = new MarginPadding(SYMBOL_BORDER); + + Children = new[] { - RelativeSizeAxes = Axes.Both, - Icon = FontAwesome.Solid.Asterisk, - Shadow = false - } - }; + new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.Asterisk, + Shadow = false + } + }; + } } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TaikoPiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TaikoPiece.cs deleted file mode 100644 index 773e3ae907..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TaikoPiece.cs +++ /dev/null @@ -1,40 +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.Game.Graphics; -using osuTK.Graphics; -using osu.Game.Graphics.Containers; -using osu.Framework.Graphics; - -namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces -{ - public class TaikoPiece : BeatSyncedContainer, IHasAccentColour - { - private Color4 accentColour; - - /// - /// The colour of the inner circle and outer glows. - /// - public virtual Color4 AccentColour - { - get => accentColour; - set => accentColour = value; - } - - private bool kiaiMode; - - /// - /// Whether Kiai mode effects are enabled for this circle piece. - /// - public virtual bool KiaiMode - { - get => kiaiMode; - set => kiaiMode = value; - } - - public TaikoPiece() - { - RelativeSizeAxes = Axes.Both; - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TickPiece.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TickPiece.cs index 83cf7a64ec..0648bcebcd 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TickPiece.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/Pieces/TickPiece.cs @@ -9,7 +9,7 @@ using osu.Framework.Graphics.Shapes; namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces { - public class TickPiece : TaikoPiece + public class TickPiece : CompositeDrawable { /// /// Any tick that is not the first for a drumroll is not filled, but is instead displayed @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces FillMode = FillMode.Fit; Size = new Vector2(tick_size); - Add(new CircularContainer + InternalChild = new CircularContainer { RelativeSizeAxes = Axes.Both, Masking = true, @@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables.Pieces AlwaysPresent = true } } - }); + }; } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs index 1d25735fe3..5f52160be1 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs @@ -3,24 +3,38 @@ using osu.Game.Rulesets.Objects.Types; using System; +using System.Threading; 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.Beatmaps; using osu.Game.Rulesets.Taiko.Judgements; +using osuTK; namespace osu.Game.Rulesets.Taiko.Objects { - public class DrumRoll : TaikoHitObject, IHasEndTime + public class DrumRoll : TaikoHitObject, IHasPath { /// /// Drum roll distance that results in a duration of 1 speed-adjusted beat length. /// private const float base_distance = 100; - public double EndTime => StartTime + Duration; + public double EndTime + { + get => StartTime + Duration; + set => Duration = value - StartTime; + } public double Duration { get; set; } + /// + /// Velocity of this . + /// + public double Velocity { get; private set; } + /// /// Numer of ticks per beat length. /// @@ -49,22 +63,26 @@ namespace osu.Game.Rulesets.Taiko.Objects base.ApplyDefaultsToSelf(controlPointInfo, difficulty); TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); + DifficultyControlPoint difficultyPoint = controlPointInfo.DifficultyPointAt(StartTime); + + double scoringDistance = base_distance * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; + Velocity = scoringDistance / timingPoint.BeatLength; tickSpacing = timingPoint.BeatLength / TickRate; overallDifficulty = difficulty.OverallDifficulty; } - protected override void CreateNestedHitObjects() + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - createTicks(); + createTicks(cancellationToken); RequiredGoodHits = NestedHitObjects.Count * Math.Min(0.15, 0.05 + 0.10 / 6 * overallDifficulty); RequiredGreatHits = NestedHitObjects.Count * Math.Min(0.30, 0.10 + 0.20 / 6 * overallDifficulty); - base.CreateNestedHitObjects(); + base.CreateNestedHitObjects(cancellationToken); } - private void createTicks() + private void createTicks(CancellationToken cancellationToken) { if (tickSpacing == 0) return; @@ -73,6 +91,8 @@ namespace osu.Game.Rulesets.Taiko.Objects for (double t = StartTime; t < EndTime + tickSpacing / 2; t += tickSpacing) { + cancellationToken.ThrowIfCancellationRequested(); + AddNested(new DrumRollTick { FirstTick = first, @@ -86,5 +106,16 @@ namespace osu.Game.Rulesets.Taiko.Objects } public override Judgement CreateJudgement() => new TaikoDrumRollJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + #region LegacyBeatmapEncoder + + double IHasDistance.Distance => Duration * Velocity; + + SliderPath IHasPath.Path + => new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(1) }, ((IHasDistance)this).Distance / TaikoBeatmapConverter.LEGACY_VELOCITY_MULTIPLIER); + + #endregion } } diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs index 8448036f76..8a8be3e38d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Judgements; namespace osu.Game.Rulesets.Taiko.Objects @@ -25,5 +26,7 @@ namespace osu.Game.Rulesets.Taiko.Objects public double HitWindow => TickSpacing / 2; public override Judgement CreateJudgement() => new TaikoDrumRollTickJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Hit.cs b/osu.Game.Rulesets.Taiko/Objects/Hit.cs index 6cc9357580..68cc8d0ead 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Hit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Hit.cs @@ -1,9 +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.Framework.Bindables; + namespace osu.Game.Rulesets.Taiko.Objects { public class Hit : TaikoHitObject { + public readonly Bindable TypeBindable = new Bindable(); + + /// + /// The that actuates this . + /// + public HitType Type + { + get => TypeBindable.Value; + set => TypeBindable.Value = value; + } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/HitType.cs b/osu.Game.Rulesets.Taiko/Objects/HitType.cs new file mode 100644 index 0000000000..17b3fdbd04 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Objects/HitType.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.Rulesets.Taiko.Objects +{ + /// + /// The type of a . + /// + public enum HitType + { + /// + /// A that can be hit by the centre portion of the drum. + /// + Centre, + + /// + /// A that can be hit by the rim portion of the drum. + /// + Rim + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/CentreHit.cs b/osu.Game.Rulesets.Taiko/Objects/IgnoreHit.cs similarity index 58% rename from osu.Game.Rulesets.Taiko/Objects/CentreHit.cs rename to osu.Game.Rulesets.Taiko/Objects/IgnoreHit.cs index a6354b16ed..302f940ef4 100644 --- a/osu.Game.Rulesets.Taiko/Objects/CentreHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/IgnoreHit.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 osu.Game.Rulesets.Judgements; + namespace osu.Game.Rulesets.Taiko.Objects { - public class CentreHit : Hit + public class IgnoreHit : Hit { + public override Judgement CreateJudgement() => new IgnoreJudgement(); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/StrongHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/StrongHitObject.cs index 2a03c23934..72a04698be 100644 --- a/osu.Game.Rulesets.Taiko/Objects/StrongHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/StrongHitObject.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Judgements; namespace osu.Game.Rulesets.Taiko.Objects @@ -9,5 +10,7 @@ namespace osu.Game.Rulesets.Taiko.Objects public class StrongHitObject : TaikoHitObject { public override Judgement CreateJudgement() => new TaikoStrongJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Swell.cs b/osu.Game.Rulesets.Taiko/Objects/Swell.cs index befa728570..bf8b7bc178 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Swell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Swell.cs @@ -1,16 +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 System.Threading; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Judgements; namespace osu.Game.Rulesets.Taiko.Objects { - public class Swell : TaikoHitObject, IHasEndTime + public class Swell : TaikoHitObject, IHasDuration { - public double EndTime => StartTime + Duration; + public double EndTime + { + get => StartTime + Duration; + set => Duration = value - StartTime; + } + + public override bool CanBeStrong => false; public double Duration { get; set; } @@ -19,19 +26,19 @@ namespace osu.Game.Rulesets.Taiko.Objects /// public int RequiredHits = 10; - public override bool IsStrong + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - set => throw new NotSupportedException($"{nameof(Swell)} cannot be a strong hitobject."); - } - - protected override void CreateNestedHitObjects() - { - base.CreateNestedHitObjects(); + base.CreateNestedHitObjects(cancellationToken); for (int i = 0; i < RequiredHits; i++) + { + cancellationToken.ThrowIfCancellationRequested(); AddNested(new SwellTick()); + } } public override Judgement CreateJudgement() => new TaikoSwellJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs b/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs index c2ae784b2a..bdc0478195 100644 --- a/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/SwellTick.cs @@ -2,12 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Taiko.Judgements; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Taiko.Objects { public class SwellTick : TaikoHitObject { - public override Judgement CreateJudgement() => new TaikoSwellTickJudgement(); + public override Judgement CreateJudgement() => new IgnoreJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; } } diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs index 3592d73004..d2c37d965c 100644 --- a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs @@ -1,10 +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 System; +using System.Threading; +using osu.Framework.Bindables; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Judgements; +using osu.Game.Rulesets.Taiko.Scoring; namespace osu.Game.Rulesets.Taiko.Objects { @@ -25,18 +29,35 @@ namespace osu.Game.Rulesets.Taiko.Objects /// public const float DEFAULT_STRONG_SIZE = DEFAULT_SIZE * STRONG_SCALE; + public readonly Bindable IsStrongBindable = new BindableBool(); + + /// + /// Whether this can be made a "strong" (large) hit. + /// + public virtual bool CanBeStrong => true; + /// /// Whether this HitObject is a "strong" type. /// Strong hit objects give more points for hitting the hit object with both keys. /// - public virtual bool IsStrong { get; set; } - - protected override void CreateNestedHitObjects() + public bool IsStrong { - base.CreateNestedHitObjects(); + get => IsStrongBindable.Value; + set + { + if (value && !CanBeStrong) + throw new InvalidOperationException($"Object of type {GetType()} cannot be strong"); + + IsStrongBindable.Value = value; + } + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + base.CreateNestedHitObjects(cancellationToken); if (IsStrong) - AddNested(new StrongHitObject { StartTime = (this as IHasEndTime)?.EndTime ?? StartTime }); + AddNested(new StrongHitObject { StartTime = this.GetEndTime() }); } public override Judgement CreateJudgement() => new TaikoJudgement(); diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoHitWindows.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoHitWindows.cs deleted file mode 100644 index f232919cbf..0000000000 --- a/osu.Game.Rulesets.Taiko/Objects/TaikoHitWindows.cs +++ /dev/null @@ -1,41 +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 osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Taiko.Objects -{ - public class TaikoHitWindows : HitWindows - { - private static readonly IReadOnlyDictionary base_ranges = new Dictionary - { - { HitResult.Great, (100, 70, 40) }, - { HitResult.Good, (240, 160, 100) }, - { HitResult.Miss, (270, 190, 140) }, - }; - - public override bool IsHitResultAllowed(HitResult result) - { - switch (result) - { - case HitResult.Great: - case HitResult.Good: - case HitResult.Miss: - return true; - - default: - return false; - } - } - - public override void SetDifficulty(double difficulty) - { - Great = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Great]); - Good = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Good]); - Miss = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Miss]); - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs index 422ba748e3..db2e5948f5 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoAutoGenerator.cs @@ -6,10 +6,10 @@ using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; using osu.Game.Replays; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Taiko.Replays { @@ -30,6 +30,9 @@ namespace osu.Game.Rulesets.Taiko.Replays public override Replay Generate() { + if (Beatmap.HitObjects.Count == 0) + return Replay; + bool hitButton = true; Frames.Add(new TaikoReplayFrame(-100000)); @@ -38,89 +41,91 @@ namespace osu.Game.Rulesets.Taiko.Replays for (int i = 0; i < Beatmap.HitObjects.Count; i++) { TaikoHitObject h = Beatmap.HitObjects[i]; + double endTime = h.GetEndTime(); - IHasEndTime endTimeData = h as IHasEndTime; - double endTime = endTimeData?.EndTime ?? h.StartTime; - - Swell swell = h as Swell; - DrumRoll drumRoll = h as DrumRoll; - Hit hit = h as Hit; - - if (swell != null) + switch (h) { - int d = 0; - int count = 0; - int req = swell.RequiredHits; - double hitRate = Math.Min(swell_hit_speed, swell.Duration / req); - - for (double j = h.StartTime; j < endTime; j += hitRate) + case Swell swell: { - TaikoAction action; + int d = 0; + int count = 0; + int req = swell.RequiredHits; + double hitRate = Math.Min(swell_hit_speed, swell.Duration / req); - switch (d) + for (double j = h.StartTime; j < endTime; j += hitRate) { - default: - case 0: - action = TaikoAction.LeftCentre; - break; + TaikoAction action; - case 1: - action = TaikoAction.LeftRim; - break; + switch (d) + { + default: + case 0: + action = TaikoAction.LeftCentre; + break; - case 2: - action = TaikoAction.RightCentre; - break; + case 1: + action = TaikoAction.LeftRim; + break; - case 3: - action = TaikoAction.RightRim; + case 2: + action = TaikoAction.RightCentre; + break; + + case 3: + action = TaikoAction.RightRim; + break; + } + + Frames.Add(new TaikoReplayFrame(j, action)); + d = (d + 1) % 4; + if (++count == req) break; } - Frames.Add(new TaikoReplayFrame(j, action)); - d = (d + 1) % 4; - if (++count == req) - break; + break; } - } - else if (drumRoll != null) - { - foreach (var tick in drumRoll.NestedHitObjects.OfType()) + + case DrumRoll drumRoll: { - Frames.Add(new TaikoReplayFrame(tick.StartTime, hitButton ? TaikoAction.LeftCentre : TaikoAction.RightCentre)); - hitButton = !hitButton; - } - } - else if (hit != null) - { - TaikoAction[] actions; + foreach (var tick in drumRoll.NestedHitObjects.OfType()) + { + Frames.Add(new TaikoReplayFrame(tick.StartTime, hitButton ? TaikoAction.LeftCentre : TaikoAction.RightCentre)); + hitButton = !hitButton; + } - if (hit is CentreHit) + break; + } + + case Hit hit: { - actions = h.IsStrong - ? new[] { TaikoAction.LeftCentre, TaikoAction.RightCentre } - : new[] { hitButton ? TaikoAction.LeftCentre : TaikoAction.RightCentre }; - } - else - { - actions = h.IsStrong - ? new[] { TaikoAction.LeftRim, TaikoAction.RightRim } - : new[] { hitButton ? TaikoAction.LeftRim : TaikoAction.RightRim }; + TaikoAction[] actions; + + if (hit.Type == HitType.Centre) + { + actions = h.IsStrong + ? new[] { TaikoAction.LeftCentre, TaikoAction.RightCentre } + : new[] { hitButton ? TaikoAction.LeftCentre : TaikoAction.RightCentre }; + } + else + { + actions = h.IsStrong + ? new[] { TaikoAction.LeftRim, TaikoAction.RightRim } + : new[] { hitButton ? TaikoAction.LeftRim : TaikoAction.RightRim }; + } + + Frames.Add(new TaikoReplayFrame(h.StartTime, actions)); + break; } - Frames.Add(new TaikoReplayFrame(h.StartTime, actions)); + default: + throw new InvalidOperationException("Unknown hit object type."); } - else - throw new InvalidOperationException("Unknown hit object type."); - Frames.Add(new TaikoReplayFrame(endTime + KEY_UP_DELAY)); + var nextHitObject = GetNextObject(i); // Get the next object that requires pressing the same button - if (i < Beatmap.HitObjects.Count - 1) - { - double waitTime = Beatmap.HitObjects[i + 1].StartTime - 1000; - if (waitTime > endTime) - Frames.Add(new TaikoReplayFrame(waitTime)); - } + bool canDelayKeyUp = nextHitObject == null || nextHitObject.StartTime > endTime + KEY_UP_DELAY; + double calculatedDelay = canDelayKeyUp ? KEY_UP_DELAY : (nextHitObject.StartTime - endTime) * 0.9; + Frames.Add(new TaikoReplayFrame(endTime + calculatedDelay)); hitButton = !hitButton; } diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs index 97337acc45..138e8f9785 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoFramedReplayInputHandler.cs @@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Taiko.Replays protected override bool IsImportant(TaikoReplayFrame frame) => frame.Actions.Any(); - public override List GetPendingInputs() => new List { new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() } }; + public override void CollectPendingInputs(List inputs) + { + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); + } } } diff --git a/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs b/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs index 5203415e90..d2a7329a28 100644 --- a/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs +++ b/osu.Game.Rulesets.Taiko/Replays/TaikoReplayFrame.cs @@ -23,12 +23,24 @@ namespace osu.Game.Rulesets.Taiko.Replays Actions.AddRange(actions); } - public void ConvertFrom(LegacyReplayFrame legacyFrame, IBeatmap beatmap) + public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) { - if (legacyFrame.MouseRight1) Actions.Add(TaikoAction.LeftRim); - if (legacyFrame.MouseRight2) Actions.Add(TaikoAction.RightRim); - if (legacyFrame.MouseLeft1) Actions.Add(TaikoAction.LeftCentre); - if (legacyFrame.MouseLeft2) Actions.Add(TaikoAction.RightCentre); + if (currentFrame.MouseRight1) Actions.Add(TaikoAction.LeftRim); + if (currentFrame.MouseRight2) Actions.Add(TaikoAction.RightRim); + if (currentFrame.MouseLeft1) Actions.Add(TaikoAction.LeftCentre); + if (currentFrame.MouseLeft2) Actions.Add(TaikoAction.RightCentre); + } + + public LegacyReplayFrame ToLegacy(IBeatmap beatmap) + { + ReplayButtonState state = ReplayButtonState.None; + + if (Actions.Contains(TaikoAction.LeftRim)) state |= ReplayButtonState.Right1; + if (Actions.Contains(TaikoAction.RightRim)) state |= ReplayButtonState.Right2; + if (Actions.Contains(TaikoAction.LeftCentre)) state |= ReplayButtonState.Left1; + if (Actions.Contains(TaikoAction.RightCentre)) state |= ReplayButtonState.Left2; + + return new LegacyReplayFrame(Time, null, null, state); } } } diff --git a/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitclap.wav b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitclap.wav new file mode 100755 index 0000000000..9ea2be5855 Binary files /dev/null and b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitclap.wav differ diff --git a/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitfinish.wav b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitfinish.wav new file mode 100755 index 0000000000..af270ae12a Binary files /dev/null and b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitfinish.wav differ diff --git a/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitnormal.wav b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitnormal.wav new file mode 100755 index 0000000000..3d8024c6ae Binary files /dev/null and b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitnormal.wav differ diff --git a/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitwhistle.wav b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitwhistle.wav new file mode 100755 index 0000000000..16d254cc87 Binary files /dev/null and b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/normal-hitwhistle.wav differ diff --git a/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitclap.wav b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitclap.wav new file mode 100755 index 0000000000..b4cfa26265 Binary files /dev/null and b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitclap.wav differ diff --git a/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitfinish.wav b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitfinish.wav new file mode 100755 index 0000000000..97804a5a61 Binary files /dev/null and b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitfinish.wav differ diff --git a/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitnormal.wav b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitnormal.wav new file mode 100755 index 0000000000..67f02877a8 Binary files /dev/null and b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitnormal.wav differ diff --git a/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitwhistle.wav b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitwhistle.wav new file mode 100755 index 0000000000..10206cd228 Binary files /dev/null and b/osu.Game.Rulesets.Taiko/Resources/Samples/Gameplay/soft-hitwhistle.wav differ diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions-expected-conversion.json b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions-expected-conversion.json new file mode 100644 index 0000000000..47ca6aef68 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions-expected-conversion.json @@ -0,0 +1,116 @@ +{ + "Mappings": [ + { + "StartTime": 110.0, + "Objects": [ + { + "StartTime": 110.0, + "EndTime": 110.0, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 538.0, + "Objects": [ + { + "StartTime": 538.0, + "EndTime": 538.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 967.0, + "Objects": [ + { + "StartTime": 967.0, + "EndTime": 967.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 1395.0, + "Objects": [ + { + "StartTime": 1395.0, + "EndTime": 1395.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 1824.0, + "Objects": [ + { + "StartTime": 1824.0, + "EndTime": 1824.0, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + } + ] + }, + { + "StartTime": 2252.0, + "Objects": [ + { + "StartTime": 2252.0, + "EndTime": 2252.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + } + ] + }, + { + "StartTime": 2681.0, + "Objects": [ + { + "StartTime": 2681.0, + "EndTime": 2681.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + } + ] + }, + { + "StartTime": 3110.0, + "Objects": [ + { + "StartTime": 3110.0, + "EndTime": 3110.0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + } + ] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions.osu b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions.osu new file mode 100644 index 0000000000..a3537e7149 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/sample-to-type-conversions.osu @@ -0,0 +1,62 @@ +osu file format v14 + +[General] +AudioFilename: audio.mp3 +AudioLeadIn: 0 +PreviewTime: -1 +Countdown: 0 +SampleSet: Normal +StackLeniency: 0.5 +Mode: 1 +LetterboxInBreaks: 0 +WidescreenStoryboard: 1 + +[Editor] +Bookmarks: 110,13824,54967,82395,109824 +DistanceSpacing: 0.1 +BeatDivisor: 4 +GridSize: 32 +TimelineZoom: 3.099999 + +[Metadata] +Title:test +TitleUnicode:test +Artist:sample conversion +ArtistUnicode:sample conversion +Creator:banchobot +Version:sample test +Source: +Tags: +BeatmapID:0 +BeatmapSetID:-1 + +[Difficulty] +HPDrainRate:6 +CircleSize:2 +OverallDifficulty:6 +ApproachRate:7 +SliderMultiplier:1.4 +SliderTickRate:4 + +[Events] +//Background and Video events +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Layer 4 (Overlay) +//Storyboard Sound Samples + +[TimingPoints] +110,428.571428571429,4,1,0,100,1,0 + +[HitObjects] +256,192,110,5,0,0:0:0:0: +256,192,538,1,8,0:0:0:0: +256,192,967,1,2,0:0:0:0: +256,192,1395,1,10,0:0:0:0: +256,192,1824,1,4,0:0:0:0: +256,192,2252,1,12,0:0:0:0: +256,192,2681,1,6,0:0:0:0: +256,192,3110,1,14,0:0:0:0: diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14-expected-conversion.json b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14-expected-conversion.json new file mode 100644 index 0000000000..6a6063cb74 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14-expected-conversion.json @@ -0,0 +1,379 @@ +{ + "Mappings": [{ + "StartTime": 2000, + "Objects": [{ + "StartTime": 2000, + "EndTime": 2000, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 2173, + "EndTime": 2173, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 4000, + "Objects": [{ + "StartTime": 4000, + "EndTime": 4000, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 4173, + "EndTime": 4173, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 6000, + "Objects": [{ + "StartTime": 6000, + "EndTime": 6000, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 6271, + "EndTime": 6271, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 6542, + "EndTime": 6542, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + }, + { + "StartTime": 8000, + "Objects": [{ + "StartTime": 8000, + "EndTime": 8000, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8026, + "EndTime": 8026, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8053, + "EndTime": 8053, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8080, + "EndTime": 8080, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8107, + "EndTime": 8107, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8133, + "EndTime": 8133, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8160, + "EndTime": 8160, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8187, + "EndTime": 8187, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8214, + "EndTime": 8214, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8241, + "EndTime": 8241, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8267, + "EndTime": 8267, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8294, + "EndTime": 8294, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8321, + "EndTime": 8321, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8348, + "EndTime": 8348, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8374, + "EndTime": 8374, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8401, + "EndTime": 8401, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8428, + "EndTime": 8428, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8455, + "EndTime": 8455, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8482, + "EndTime": 8482, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8508, + "EndTime": 8508, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8535, + "EndTime": 8535, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8562, + "EndTime": 8562, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8589, + "EndTime": 8589, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8615, + "EndTime": 8615, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8642, + "EndTime": 8642, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8669, + "EndTime": 8669, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8696, + "EndTime": 8696, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8723, + "EndTime": 8723, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8749, + "EndTime": 8749, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8776, + "EndTime": 8776, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8803, + "EndTime": 8803, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8830, + "EndTime": 8830, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 8857, + "EndTime": 8857, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14.osu b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14.osu new file mode 100644 index 0000000000..4c8fb1fde6 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v14.osu @@ -0,0 +1,32 @@ +osu file format v14 + +[General] +Mode: 0 + +[Difficulty] +HPDrainRate:7 +CircleSize:4 +OverallDifficulty:8 +ApproachRate:9.2 +SliderMultiplier:2.3 +SliderTickRate:1 + +[TimingPoints] +0,333.333333333333,4,1,0,50,1,0 +2000,-100,4,2,0,80,0,0 + +6000,389.61038961039,4,2,1,60,1,0 + +8000,428.571428571429,4,3,1,65,1,0 +8000,-133.333333333333,4,1,1,45,0,0 + +[HitObjects] +// Should convert. +48,32,2000,6,0,B|168:32,1,120,4|2 +312,68,4000,2,0,B|288:52|256:44|216:52|200:68,1,120,0|8 + +// Should convert. +184,224,6000,2,0,L|336:308,2,160,2|2|0,0:0|0:0|0:0,0:0:0:0: + +// Should convert. +328,36,8000,6,0,L|332:16,32,10.7812504112721,0|0,0:0,0:0:0:0: diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6-expected-conversion.json b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6-expected-conversion.json new file mode 100644 index 0000000000..c3d3c52ebd --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6-expected-conversion.json @@ -0,0 +1,137 @@ +{ + "Mappings": [{ + "StartTime": 0, + "Objects": [{ + "StartTime": 0, + "EndTime": 0, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 162, + "EndTime": 162, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 325, + "EndTime": 325, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 487, + "EndTime": 487, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 650, + "EndTime": 650, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 813, + "EndTime": 813, + "IsRim": false, + "IsCentre": true, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 975, + "EndTime": 975, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + } + ] + }, + { + "StartTime": 2000, + "Objects": [{ + "StartTime": 2000, + "EndTime": 2000, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2162, + "EndTime": 2162, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2325, + "EndTime": 2325, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 2487, + "EndTime": 2487, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2650, + "EndTime": 2650, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + }, + { + "StartTime": 2813, + "EndTime": 2813, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": true + }, + { + "StartTime": 2975, + "EndTime": 2975, + "IsRim": true, + "IsCentre": false, + "IsDrumRoll": false, + "IsSwell": false, + "IsStrong": false + } + ] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6.osu b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6.osu new file mode 100644 index 0000000000..c1e4c3bbd7 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Resources/Testing/Beatmaps/slider-conversion-v6.osu @@ -0,0 +1,20 @@ +osu file format v6 + +[General] +Mode: 0 + +[Difficulty] +HPDrainRate:3 +CircleSize:4 +OverallDifficulty:1 +SliderMultiplier:1.2 +SliderTickRate:3 + +[TimingPoints] +0,487.884208814441,4,1,0,60,1,0 +2000,-100,4,1,0,65,0,1 + +[HitObjects] +// Should convert. +376,64,0,6,0,B|256:32|136:64,1,240,6|0 +256,120,2000,6,8,C|264:192|336:192,2,120,8|8|6 \ No newline at end of file diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs new file mode 100644 index 0000000000..f7a1d130eb --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoHealthProcessor.cs @@ -0,0 +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; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Scoring +{ + /// + /// A for the taiko ruleset. + /// Taiko fails if the player has not half-filled their health by the end of the map. + /// + public class TaikoHealthProcessor : AccumulatingHealthProcessor + { + /// + /// A value used for calculating . + /// + private const double object_count_factor = 3; + + /// + /// HP multiplier for a successful . + /// + private double hpMultiplier; + + /// + /// HP multiplier for a that does not satisfy . + /// + private double hpMissMultiplier; + + public TaikoHealthProcessor() + : base(0.5) + { + } + + public override void ApplyBeatmap(IBeatmap beatmap) + { + base.ApplyBeatmap(beatmap); + + hpMultiplier = 1 / (object_count_factor * Math.Max(1, beatmap.HitObjects.OfType().Count()) * BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.5, 0.75, 0.98)); + hpMissMultiplier = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.0018, 0.0075, 0.0120); + } + + protected override double GetHealthIncreaseFor(JudgementResult result) + => base.GetHealthIncreaseFor(result) * (result.IsHit ? hpMultiplier : hpMissMultiplier); + } +} diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs new file mode 100644 index 0000000000..cf806c0c97 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.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.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Taiko.Scoring +{ + public class TaikoHitWindows : HitWindows + { + private static readonly DifficultyRange[] taiko_ranges = + { + new DifficultyRange(HitResult.Great, 50, 35, 20), + new DifficultyRange(HitResult.Ok, 120, 80, 50), + new DifficultyRange(HitResult.Miss, 135, 95, 70), + }; + + public override bool IsHitResultAllowed(HitResult result) + { + switch (result) + { + case HitResult.Great: + case HitResult.Ok: + case HitResult.Miss: + return true; + } + + return false; + } + + protected override DifficultyRange[] GetRanges() => taiko_ranges; + } +} diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs index 68ddf2db19..1829ea2513 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs @@ -1,61 +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 osu.Game.Beatmaps; -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.UI; namespace osu.Game.Rulesets.Taiko.Scoring { - internal class TaikoScoreProcessor : ScoreProcessor + internal class TaikoScoreProcessor : ScoreProcessor { - /// - /// A value used for calculating . - /// - private const double object_count_factor = 3; + protected override double DefaultAccuracyPortion => 0.75; - /// - /// Taiko fails at the end of the map if the player has not half-filled their HP bar. - /// - protected override bool DefaultFailCondition => JudgedHits == MaxHits && Health.Value <= 0.5; - - /// - /// HP multiplier for a successful . - /// - private double hpMultiplier; - - /// - /// HP multiplier for a . - /// - private double hpMissMultiplier; - - public TaikoScoreProcessor(DrawableRuleset drawableRuleset) - : base(drawableRuleset) - { - } - - protected override void ApplyBeatmap(Beatmap beatmap) - { - base.ApplyBeatmap(beatmap); - - hpMultiplier = 1 / (object_count_factor * beatmap.HitObjects.FindAll(o => o is Hit).Count * BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.5, 0.75, 0.98)); - - hpMissMultiplier = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, 0.0018, 0.0075, 0.0120); - } - - protected override double HealthAdjustmentFactorFor(JudgementResult result) - => result.Type == HitResult.Miss ? hpMissMultiplier : hpMultiplier; - - protected override void Reset(bool storeResults) - { - base.Reset(storeResults); - - Health.Value = 0; - } - - public override HitWindows CreateHitWindows() => new TaikoHitWindows(); + protected override double DefaultComboPortion => 0.25; } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyBarLine.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyBarLine.cs new file mode 100644 index 0000000000..7d08a21ab1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyBarLine.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.Sprites; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyBarLine : Sprite + { + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + Texture = skin.GetTexture("taiko-barline"); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(1, 0.88f); + FillMode = FillMode.Fill; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyCirclePiece.cs new file mode 100644 index 0000000000..9b73ccd248 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyCirclePiece.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyCirclePiece : CompositeDrawable, IHasAccentColour + { + private Drawable backgroundLayer; + + public LegacyCirclePiece() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, DrawableHitObject drawableHitObject) + { + Drawable getDrawableFor(string lookup) + { + const string normal_hit = "taikohit"; + const string big_hit = "taikobig"; + + string prefix = ((drawableHitObject as DrawableTaikoHitObject)?.HitObject.IsStrong ?? false) ? big_hit : normal_hit; + + return skin.GetAnimation($"{prefix}{lookup}", true, false) ?? + // fallback to regular size if "big" version doesn't exist. + skin.GetAnimation($"{normal_hit}{lookup}", true, false); + } + + // backgroundLayer is guaranteed to exist due to the pre-check in TaikoLegacySkinTransformer. + AddInternal(backgroundLayer = getDrawableFor("circle")); + + var foregroundLayer = getDrawableFor("circleoverlay"); + if (foregroundLayer != null) + AddInternal(foregroundLayer); + + // Animations in taiko skins are used in a custom way (>150 combo and animating in time with beat). + // For now just stop at first frame for sanity. + foreach (var c in InternalChildren) + { + (c as IFramedAnimation)?.Stop(); + + c.Anchor = Anchor.Centre; + c.Origin = Anchor.Centre; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateAccentColour(); + } + + protected override void Update() + { + base.Update(); + + // Not all skins (including the default osu-stable) have similar sizes for "hitcircle" and "hitcircleoverlay". + // This ensures they are scaled relative to each other but also match the expected DrawableHit size. + foreach (var c in InternalChildren) + c.Scale = new Vector2(DrawHeight / 128); + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + if (value == accentColour) + return; + + accentColour = value; + if (IsLoaded) + updateAccentColour(); + } + } + + private void updateAccentColour() + { + backgroundLayer.Colour = LegacyColourCompatibility.DisallowZeroAlpha(accentColour); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyDrumRoll.cs new file mode 100644 index 0000000000..5ab8e3a8c8 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyDrumRoll.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyDrumRoll : CompositeDrawable, IHasAccentColour + { + private LegacyCirclePiece headCircle; + + private Sprite body; + + private Sprite end; + + public LegacyDrumRoll() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin, OsuColour colours) + { + InternalChildren = new Drawable[] + { + end = new Sprite + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Texture = skin.GetTexture("taiko-roll-end", WrapMode.ClampToEdge, WrapMode.ClampToEdge), + FillMode = FillMode.Fit, + }, + body = new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = skin.GetTexture("taiko-roll-middle", WrapMode.ClampToEdge, WrapMode.ClampToEdge), + }, + headCircle = new LegacyCirclePiece + { + RelativeSizeAxes = Axes.Y, + }, + }; + + AccentColour = colours.YellowDark; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateAccentColour(); + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + if (value == accentColour) + return; + + accentColour = value; + if (IsLoaded) + updateAccentColour(); + } + } + + private void updateAccentColour() + { + var colour = LegacyColourCompatibility.DisallowZeroAlpha(accentColour); + + headCircle.AccentColour = colour; + body.Colour = colour; + end.Colour = colour; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyHit.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyHit.cs new file mode 100644 index 0000000000..b11b64c22c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyHit.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 osu.Framework.Allocation; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyHit : LegacyCirclePiece + { + private readonly TaikoSkinComponents component; + + public LegacyHit(TaikoSkinComponents component) + { + this.component = component; + } + + [BackgroundDependencyLoader] + private void load() + { + AccentColour = LegacyColourCompatibility.DisallowZeroAlpha( + component == TaikoSkinComponents.CentreHit + ? new Color4(235, 69, 44, 255) + : new Color4(67, 142, 172, 255)); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyHitExplosion.cs new file mode 100644 index 0000000000..19493271be --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyHitExplosion.cs @@ -0,0 +1,100 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyHitExplosion : CompositeDrawable + { + private readonly Drawable sprite; + private readonly Drawable strongSprite; + + private DrawableStrongNestedHit nestedStrongHit; + private bool switchedToStrongSprite; + + /// + /// Creates a new legacy hit explosion. + /// + /// + /// Contrary to stable's, this implementation doesn't require a frame-perfect hit + /// for the strong sprite to be displayed. + /// + /// The normal legacy explosion sprite. + /// The strong legacy explosion sprite. + public LegacyHitExplosion(Drawable sprite, Drawable strongSprite = null) + { + this.sprite = sprite; + this.strongSprite = strongSprite; + } + + [BackgroundDependencyLoader] + private void load(DrawableHitObject judgedObject) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + AutoSizeAxes = Axes.Both; + + AddInternal(sprite.With(s => + { + s.Anchor = Anchor.Centre; + s.Origin = Anchor.Centre; + })); + + if (strongSprite != null) + { + AddInternal(strongSprite.With(s => + { + s.Alpha = 0; + s.Anchor = Anchor.Centre; + s.Origin = Anchor.Centre; + })); + } + + if (judgedObject is DrawableHit hit) + nestedStrongHit = hit.NestedHitObjects.SingleOrDefault() as DrawableStrongNestedHit; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + const double animation_time = 120; + + 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() + { + base.Update(); + + 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; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyInputDrum.cs new file mode 100644 index 0000000000..b7b55b9ae0 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyInputDrum.cs @@ -0,0 +1,181 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Game.Rulesets.Taiko.Audio; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + /// + /// A component of the playfield that captures input and displays input as a drum. + /// + internal class LegacyInputDrum : Container + { + private LegacyHalfDrum left; + private LegacyHalfDrum right; + private Container content; + + public LegacyInputDrum() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + Child = content = new Container + { + Size = new Vector2(180, 200), + Children = new Drawable[] + { + new Sprite + { + Texture = skin.GetTexture("taiko-bar-left") + }, + left = new LegacyHalfDrum(false) + { + Name = "Left Half", + RelativeSizeAxes = Axes.Both, + RimAction = TaikoAction.LeftRim, + CentreAction = TaikoAction.LeftCentre + }, + right = new LegacyHalfDrum(true) + { + Name = "Right Half", + RelativeSizeAxes = Axes.Both, + Origin = Anchor.TopRight, + Scale = new Vector2(-1, 1), + RimAction = TaikoAction.RightRim, + CentreAction = TaikoAction.RightCentre + } + } + }; + + // this will be used in the future for stable skin alignment. keeping here for reference. + const float taiko_bar_y = 0; + + // stable things + const float ratio = 1.6f; + + // because the right half is flipped, we need to position using width - position to get the true "topleft" origin position + float negativeScaleAdjust = content.Width / ratio; + + if (skin.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value >= 2.1m) + { + left.Centre.Position = new Vector2(0, taiko_bar_y) * ratio; + right.Centre.Position = new Vector2(negativeScaleAdjust - 56, taiko_bar_y) * ratio; + left.Rim.Position = new Vector2(0, taiko_bar_y) * ratio; + right.Rim.Position = new Vector2(negativeScaleAdjust - 56, taiko_bar_y) * ratio; + } + else + { + left.Centre.Position = new Vector2(18, taiko_bar_y + 31) * ratio; + right.Centre.Position = new Vector2(negativeScaleAdjust - 54, taiko_bar_y + 31) * ratio; + left.Rim.Position = new Vector2(8, taiko_bar_y + 23) * ratio; + right.Rim.Position = new Vector2(negativeScaleAdjust - 53, taiko_bar_y + 23) * ratio; + } + } + + protected override void Update() + { + base.Update(); + + // Relying on RelativeSizeAxes.Both + FillMode.Fit doesn't work due to the precise pixel layout requirements. + // This is a bit ugly but makes the non-legacy implementations a lot cleaner to implement. + content.Scale = new Vector2(DrawHeight / content.Size.Y); + } + + /// + /// A half-drum. Contains one centre and one rim hit. + /// + private class LegacyHalfDrum : Container, IKeyBindingHandler + { + /// + /// The key to be used for the rim of the half-drum. + /// + public TaikoAction RimAction; + + /// + /// The key to be used for the centre of the half-drum. + /// + public TaikoAction CentreAction; + + public readonly Sprite Rim; + public readonly Sprite Centre; + + [Resolved] + private DrumSampleContainer sampleContainer { get; set; } + + public LegacyHalfDrum(bool flipped) + { + Masking = true; + + Children = new Drawable[] + { + Rim = new Sprite + { + Scale = new Vector2(-1, 1), + Origin = flipped ? Anchor.TopLeft : Anchor.TopRight, + Alpha = 0, + }, + Centre = new Sprite + { + Alpha = 0, + Origin = flipped ? Anchor.TopRight : Anchor.TopLeft, + } + }; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + Rim.Texture = skin.GetTexture(@"taiko-drum-outer"); + Centre.Texture = skin.GetTexture(@"taiko-drum-inner"); + } + + public bool OnPressed(TaikoAction action) + { + Drawable target = null; + var drumSample = sampleContainer.SampleAt(Time.Current); + + if (action == CentreAction) + { + target = Centre; + drumSample.Centre?.Play(); + } + else if (action == RimAction) + { + target = Rim; + drumSample.Rim?.Play(); + } + + if (target != null) + { + const float alpha_amount = 1; + + const float down_time = 80; + const float up_time = 50; + + target.Animate( + t => t.FadeTo(Math.Min(target.Alpha + alpha_amount, 1), down_time) + ).Then( + t => t.FadeOut(up_time) + ); + } + + return false; + } + + public void OnReleased(TaikoAction action) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs new file mode 100644 index 0000000000..e029040ef3 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class LegacyTaikoScroller : CompositeDrawable + { + public Bindable LastResult = new Bindable(); + + public LegacyTaikoScroller() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader(true)] + private void load(GameplayBeatmap gameplayBeatmap) + { + if (gameplayBeatmap != null) + ((IBindable)LastResult).BindTo(gameplayBeatmap.LastJudgementResult); + } + + private bool passing; + + protected override void LoadComplete() + { + base.LoadComplete(); + + LastResult.BindValueChanged(result => + { + var r = result.NewValue; + + // always ignore hitobjects that don't affect combo (drumroll ticks etc.) + if (r?.Type.AffectsCombo() == false) + return; + + passing = r == null || r.IsHit; + + foreach (var sprite in InternalChildren.OfType()) + sprite.Passing = passing; + }, true); + } + + protected override void Update() + { + 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; + + // ensure we have enough sprites + if (!InternalChildren.Any() + || InternalChildren.First().ScreenSpaceDrawQuad.Width * InternalChildren.Count < ScreenSpaceDrawQuad.Width * 2) + AddInternal(new ScrollerSprite { Passing = passing }); + + var first = InternalChildren.First(); + var last = InternalChildren.Last(); + + foreach (var sprite in InternalChildren) + { + // add the x coordinates and perform re-layout on all sprites as spacing may change with gameplay scale. + sprite.X = currentX; + currentX += sprite.DrawWidth; + } + + if (first.ScreenSpaceDrawQuad.TopLeft.X >= ScreenSpaceDrawQuad.TopLeft.X) + { + foreach (var internalChild in InternalChildren) + internalChild.X -= first.DrawWidth; + } + + if (last.ScreenSpaceDrawQuad.TopRight.X <= ScreenSpaceDrawQuad.TopRight.X) + { + foreach (var internalChild in InternalChildren) + internalChild.X += first.DrawWidth; + } + } + + private class ScrollerSprite : CompositeDrawable + { + private Sprite passingSprite; + private Sprite failingSprite; + + private bool passing = true; + + public bool Passing + { + get => passing; + set + { + if (value == passing) + return; + + passing = value; + + if (IsLoaded) + updatePassing(); + } + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + + FillMode = FillMode.Fit; + + InternalChildren = new Drawable[] + { + passingSprite = new Sprite { Texture = skin.GetTexture("taiko-slider") }, + failingSprite = new Sprite { Texture = skin.GetTexture("taiko-slider-fail"), Alpha = 0 }, + }; + + updatePassing(); + } + + protected override void Update() + { + base.Update(); + + foreach (var c in InternalChildren) + c.Scale = new Vector2(DrawHeight / c.Height); + } + + private void updatePassing() + { + if (passing) + { + passingSprite.Show(); + failingSprite.FadeOut(200); + } + else + { + failingSprite.FadeIn(200); + passingSprite.Delay(200).FadeOut(); + } + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyHitTarget.cs b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyHitTarget.cs new file mode 100644 index 0000000000..e522fb7c10 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyHitTarget.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class TaikoLegacyHitTarget : CompositeDrawable + { + private Container content; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + RelativeSizeAxes = Axes.Both; + + InternalChild = content = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Sprite + { + Texture = skin.GetTexture("approachcircle"), + Scale = new Vector2(0.73f), + Alpha = 0.47f, // eyeballed to match stable + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new Sprite + { + Texture = skin.GetTexture("taikobigcircle"), + Scale = new Vector2(0.7f), + Alpha = 0.22f, // eyeballed to match stable + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } + }; + } + + protected override void Update() + { + base.Update(); + + // Relying on RelativeSizeAxes.Both + FillMode.Fit doesn't work due to the precise pixel layout requirements. + // This is a bit ugly but makes the non-legacy implementations a lot cleaner to implement. + content.Scale = new Vector2(DrawHeight / TaikoPlayfield.DEFAULT_HEIGHT); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyPlayfieldBackgroundRight.cs b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyPlayfieldBackgroundRight.cs new file mode 100644 index 0000000000..4bbb6be6b1 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacyPlayfieldBackgroundRight.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.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class TaikoLegacyPlayfieldBackgroundRight : BeatSyncedContainer + { + private Sprite kiai; + + private bool kiaiDisplayed; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Sprite + { + Texture = skin.GetTexture("taiko-bar-right"), + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + }, + kiai = new Sprite + { + Texture = skin.GetTexture("taiko-bar-right-glow"), + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + Alpha = 0, + } + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + + if (effectPoint.KiaiMode != kiaiDisplayed) + { + kiaiDisplayed = effectPoint.KiaiMode; + + kiai.ClearTransforms(); + kiai.FadeTo(kiaiDisplayed ? 1 : 0, 200); + } + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs new file mode 100644 index 0000000000..c88480d18f --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.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 System.Collections.Generic; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Audio; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Taiko.Skinning +{ + public class TaikoLegacySkinTransformer : LegacySkinTransformer + { + private Lazy hasExplosion; + + public TaikoLegacySkinTransformer(ISkinSource source) + : base(source) + { + Source.SourceChanged += sourceChanged; + sourceChanged(); + } + + private void sourceChanged() + { + hasExplosion = new Lazy(() => Source.GetTexture(getHitName(TaikoSkinComponents.TaikoExplosionGreat)) != null); + } + + public override Drawable GetDrawableComponent(ISkinComponent component) + { + if (component is GameplaySkinComponent) + { + // if a taiko skin is providing explosion sprites, hide the judgements completely + if (hasExplosion.Value) + return Drawable.Empty(); + } + + if (!(component is TaikoSkinComponent taikoComponent)) + return null; + + switch (taikoComponent.Component) + { + case TaikoSkinComponents.DrumRollBody: + if (GetTexture("taiko-roll-middle") != null) + return new LegacyDrumRoll(); + + return null; + + case TaikoSkinComponents.InputDrum: + if (GetTexture("taiko-bar-left") != null) + return new LegacyInputDrum(); + + return null; + + case TaikoSkinComponents.CentreHit: + case TaikoSkinComponents.RimHit: + + if (GetTexture("taikohitcircle") != null) + return new LegacyHit(taikoComponent.Component); + + return null; + + case TaikoSkinComponents.DrumRollTick: + return this.GetAnimation("sliderscorepoint", false, false); + + case TaikoSkinComponents.HitTarget: + if (GetTexture("taikobigcircle") != null) + return new TaikoLegacyHitTarget(); + + return null; + + case TaikoSkinComponents.PlayfieldBackgroundRight: + if (GetTexture("taiko-bar-right") != null) + return new TaikoLegacyPlayfieldBackgroundRight(); + + 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(); + + return null; + + case TaikoSkinComponents.BarLine: + if (GetTexture("taiko-barline") != null) + return new LegacyBarLine(); + + return null; + + case TaikoSkinComponents.TaikoExplosionMiss: + + var missSprite = this.GetAnimation(getHitName(taikoComponent.Component), true, false); + if (missSprite != null) + return new LegacyHitExplosion(missSprite); + + return null; + + case TaikoSkinComponents.TaikoExplosionOk: + case TaikoSkinComponents.TaikoExplosionGreat: + + var hitName = getHitName(taikoComponent.Component); + var hitSprite = this.GetAnimation(hitName, true, false); + + if (hitSprite != null) + { + var strongHitSprite = this.GetAnimation($"{hitName}k", true, false); + + return new LegacyHitExplosion(hitSprite, strongHitSprite); + } + + return null; + + case TaikoSkinComponents.Scroller: + if (GetTexture("taiko-slider") != null) + return new LegacyTaikoScroller(); + + return null; + + case TaikoSkinComponents.Mascot: + return new DrawableTaikoMascot(); + } + + return Source.GetDrawableComponent(component); + } + + private string getHitName(TaikoSkinComponents component) + { + switch (component) + { + case TaikoSkinComponents.TaikoExplosionMiss: + return "taiko-hit0"; + + case TaikoSkinComponents.TaikoExplosionOk: + return "taiko-hit100"; + + case TaikoSkinComponents.TaikoExplosionGreat: + return "taiko-hit300"; + } + + throw new ArgumentOutOfRangeException(nameof(component), $"Invalid component type: {component}"); + } + + public override SampleChannel GetSample(ISampleInfo sampleInfo) => Source.GetSample(new LegacyTaikoSampleInfo(sampleInfo)); + + public override IBindable GetConfig(TLookup lookup) => Source.GetConfig(lookup); + + private class LegacyTaikoSampleInfo : ISampleInfo + { + private readonly ISampleInfo source; + + public LegacyTaikoSampleInfo(ISampleInfo source) + { + this.source = source; + } + + public IEnumerable LookupNames + { + get + { + foreach (var name in source.LookupNames) + yield return name.Insert(name.LastIndexOf('/') + 1, "taiko-"); + + foreach (var name in source.LookupNames) + yield return name; + } + } + + public int Volume => source.Volume; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index a67004e9c7..73e9c16d07 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -12,20 +12,38 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.Replays.Types; -using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Replays; using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Beatmaps; using osu.Game.Rulesets.Taiko.Difficulty; +using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Scoring; +using System; +using System.Linq; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Taiko.Edit; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Skinning; +using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko { - public class TaikoRuleset : Ruleset + public class TaikoRuleset : Ruleset, ILegacyRuleset { - public override DrawableRuleset CreateDrawableRulesetWith(WorkingBeatmap beatmap, IReadOnlyList mods) => new DrawableTaikoRuleset(this, beatmap, mods); - public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new TaikoBeatmapConverter(beatmap); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new DrawableTaikoRuleset(this, beatmap, mods); + + public override ScoreProcessor CreateScoreProcessor() => new TaikoScoreProcessor(); + + public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new TaikoHealthProcessor(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new TaikoBeatmapConverter(beatmap, this); + + public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new TaikoLegacySkinTransformer(source); + + public const string SHORT_NAME = "taiko"; public override IEnumerable GetDefaultKeyBindings(int variant = 0) => new[] { @@ -37,14 +55,21 @@ namespace osu.Game.Rulesets.Taiko new KeyBinding(InputKey.K, TaikoAction.RightRim), }; - public override IEnumerable ConvertLegacyMods(LegacyMods mods) + public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { if (mods.HasFlag(LegacyMods.Nightcore)) yield return new TaikoModNightcore(); else if (mods.HasFlag(LegacyMods.DoubleTime)) yield return new TaikoModDoubleTime(); - if (mods.HasFlag(LegacyMods.Autoplay)) + if (mods.HasFlag(LegacyMods.Perfect)) + yield return new TaikoModPerfect(); + else if (mods.HasFlag(LegacyMods.SuddenDeath)) + yield return new TaikoModSuddenDeath(); + + if (mods.HasFlag(LegacyMods.Cinema)) + yield return new TaikoModCinema(); + else if (mods.HasFlag(LegacyMods.Autoplay)) yield return new TaikoModAutoplay(); if (mods.HasFlag(LegacyMods.Easy)) @@ -65,14 +90,8 @@ namespace osu.Game.Rulesets.Taiko if (mods.HasFlag(LegacyMods.NoFail)) yield return new TaikoModNoFail(); - if (mods.HasFlag(LegacyMods.Perfect)) - yield return new TaikoModPerfect(); - if (mods.HasFlag(LegacyMods.Relax)) yield return new TaikoModRelax(); - - if (mods.HasFlag(LegacyMods.SuddenDeath)) - yield return new TaikoModSuddenDeath(); } public override IEnumerable GetModsFor(ModType type) @@ -97,41 +116,104 @@ namespace osu.Game.Rulesets.Taiko new TaikoModFlashlight(), }; + case ModType.Conversion: + return new Mod[] + { + new TaikoModRandom(), + new TaikoModDifficultyAdjust(), + }; + case ModType.Automation: return new Mod[] { - new MultiMod(new TaikoModAutoplay(), new ModCinema()), + new MultiMod(new TaikoModAutoplay(), new TaikoModCinema()), new TaikoModRelax(), }; case ModType.Fun: return new Mod[] { - new MultiMod(new ModWindUp(), new ModWindDown()) + new MultiMod(new ModWindUp(), new ModWindDown()) }; default: - return new Mod[] { }; + return Array.Empty(); } } public override string Description => "osu!taiko"; - public override string ShortName => "taiko"; + public override string ShortName => SHORT_NAME; + + public override string PlayingVerb => "Bashing drums"; public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetTaiko }; + public override HitObjectComposer CreateHitObjectComposer() => new TaikoHitObjectComposer(this); + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new TaikoDifficultyCalculator(this, beatmap); - public override PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => new TaikoPerformanceCalculator(this, beatmap, score); + public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => new TaikoPerformanceCalculator(this, attributes, score); - public override int? LegacyID => 1; + public int LegacyID => 1; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new TaikoReplayFrame(); - public TaikoRuleset(RulesetInfo rulesetInfo = null) - : base(rulesetInfo) + protected override IEnumerable GetValidHitResults() { + return new[] + { + HitResult.Great, + HitResult.Ok, + + HitResult.SmallTickHit, + + HitResult.SmallBonus, + }; + } + + public override string GetDisplayNameForHitResult(HitResult result) + { + switch (result) + { + case HitResult.SmallTickHit: + return "drum tick"; + + case HitResult.SmallBonus: + return "strong bonus"; + } + + return base.GetDisplayNameForHitResult(result); + } + + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + { + var timedHitEvents = score.HitEvents.Where(e => e.HitObject is Hit).ToList(); + + return new[] + { + new StatisticRow + { + Columns = new[] + { + new StatisticItem("Timing Distribution", new HitEventTimingDistributionGraph(timedHitEvents) + { + RelativeSizeAxes = Axes.X, + Height = 250 + }), + } + }, + new StatisticRow + { + Columns = new[] + { + new StatisticItem(string.Empty, new SimpleStatisticTable(3, new SimpleStatisticItem[] + { + new UnstableRate(timedHitEvents) + })) + } + } + }; } } } diff --git a/osu.Game.Rulesets.Taiko/TaikoSkinComponent.cs b/osu.Game.Rulesets.Taiko/TaikoSkinComponent.cs new file mode 100644 index 0000000000..e6e4bc0dd7 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/TaikoSkinComponent.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.Skinning; + +namespace osu.Game.Rulesets.Taiko +{ + public class TaikoSkinComponent : GameplaySkinComponent + { + public TaikoSkinComponent(TaikoSkinComponents component) + : base(component) + { + } + + protected override string RulesetPrefix => TaikoRuleset.SHORT_NAME; + + protected override string ComponentName => Component.ToString().ToLower(); + } +} diff --git a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs new file mode 100644 index 0000000000..132d8f8868 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.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.Taiko +{ + public enum TaikoSkinComponents + { + InputDrum, + CentreHit, + RimHit, + DrumRollBody, + DrumRollTick, + Swell, + HitTarget, + PlayfieldBackgroundLeft, + PlayfieldBackgroundRight, + BarLine, + TaikoExplosionMiss, + TaikoExplosionOk, + TaikoExplosionGreat, + Scroller, + Mascot, + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs new file mode 100644 index 0000000000..3bd20e4bb4 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.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.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.UI +{ + internal class DefaultHitExplosion : CircularContainer + { + private readonly DrawableHitObject judgedObject; + private readonly HitResult result; + + public DefaultHitExplosion(DrawableHitObject judgedObject, HitResult result) + { + this.judgedObject = judgedObject; + this.result = result; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.Both; + + BorderColour = Color4.White; + BorderThickness = 1; + + Blending = BlendingParameters.Additive; + + Alpha = 0.15f; + Masking = true; + + if (!result.IsHit()) + return; + + bool isRim = (judgedObject.HitObject as Hit)?.Type == HitType.Rim; + + InternalChildren = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = isRim ? colours.BlueDarker : colours.PinkDarker, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.ScaleTo(3f, 1000, Easing.OutQuint); + this.FadeOut(500); + + Expire(true); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs index f91bbb14e8..cbfc5a8628 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoJudgement.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Taiko.UI { switch (Result.Type) { - case HitResult.Good: + case HitResult.Ok: JudgementBody.Colour = colours.GreenLight; break; diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs new file mode 100644 index 0000000000..6a16f311bf --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs @@ -0,0 +1,124 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Judgements; +using osu.Game.Screens.Play; + +namespace osu.Game.Rulesets.Taiko.UI +{ + public class DrawableTaikoMascot : BeatSyncedContainer + { + public readonly Bindable State; + public readonly Bindable LastResult; + + private readonly Dictionary animations; + private TaikoMascotAnimation currentAnimation; + + private bool lastObjectHit = true; + private bool kiaiMode; + + public DrawableTaikoMascot(TaikoMascotAnimationState startingState = TaikoMascotAnimationState.Idle) + { + Origin = Anchor = Anchor.BottomLeft; + + State = new Bindable(startingState); + LastResult = new Bindable(); + + animations = new Dictionary(); + } + + [BackgroundDependencyLoader(true)] + private void load(TextureStore textures, GameplayBeatmap gameplayBeatmap) + { + InternalChildren = new[] + { + animations[TaikoMascotAnimationState.Idle] = new TaikoMascotAnimation(TaikoMascotAnimationState.Idle), + animations[TaikoMascotAnimationState.Clear] = new TaikoMascotAnimation(TaikoMascotAnimationState.Clear), + animations[TaikoMascotAnimationState.Kiai] = new TaikoMascotAnimation(TaikoMascotAnimationState.Kiai), + animations[TaikoMascotAnimationState.Fail] = new TaikoMascotAnimation(TaikoMascotAnimationState.Fail), + }; + + if (gameplayBeatmap != null) + ((IBindable)LastResult).BindTo(gameplayBeatmap.LastJudgementResult); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + animations.Values.ForEach(animation => animation.Hide()); + + State.BindValueChanged(mascotStateChanged, true); + LastResult.BindValueChanged(onNewResult); + } + + private void onNewResult(ValueChangedEvent resultChangedEvent) + { + var result = resultChangedEvent.NewValue; + if (result == null) + return; + + // TODO: missing support for clear/fail state transition at end of beatmap gameplay + + if (triggerComboClear(result) || triggerSwellClear(result)) + { + State.Value = TaikoMascotAnimationState.Clear; + // always consider a clear equivalent to a hit to avoid clear -> miss transitions + lastObjectHit = true; + } + + if (!result.Type.AffectsCombo()) + return; + + lastObjectHit = result.IsHit; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + kiaiMode = effectPoint.KiaiMode; + } + + protected override void Update() + { + base.Update(); + State.Value = getNextState(); + } + + private TaikoMascotAnimationState getNextState() + { + // don't change state if current animation is still playing (and we haven't rewound before it). + // used for clear state - others are manually animated on new beats. + if (currentAnimation?.Completed == false && currentAnimation.DisplayTime <= Time.Current) + return State.Value; + + if (!lastObjectHit) + return TaikoMascotAnimationState.Fail; + + return kiaiMode ? TaikoMascotAnimationState.Kiai : TaikoMascotAnimationState.Idle; + } + + private void mascotStateChanged(ValueChangedEvent state) + { + currentAnimation?.Hide(); + currentAnimation = animations[state.NewValue]; + currentAnimation.Show(); + } + + private bool triggerComboClear(JudgementResult judgementResult) + => (judgementResult.ComboAtJudgement + 1) % 50 == 0 && judgementResult.Type.AffectsCombo() && judgementResult.IsHit; + + private bool triggerSwellClear(JudgementResult judgementResult) + => judgementResult.Judgement is TaikoSwellJudgement && judgementResult.IsHit; + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index ec3a56e9c7..e6aacf34dc 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -3,32 +3,34 @@ using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects.Drawables; -using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.Taiko.Replays; -using System.Linq; using osu.Framework.Input; using osu.Game.Configuration; using osu.Game.Input.Handlers; using osu.Game.Replays; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Taiko.UI { public class DrawableTaikoRuleset : DrawableScrollingRuleset { + private SkinnableDrawable scroller; + protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Overlapping; protected override bool UserScrollSpeedAdjustment => false; - public DrawableTaikoRuleset(Ruleset ruleset, WorkingBeatmap beatmap, IReadOnlyList mods) + public DrawableTaikoRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) { Direction.Value = ScrollingDirection.Left; @@ -38,52 +40,22 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load() { - loadBarLines(); - } + new BarLineGenerator(Beatmap).BarLines.ForEach(bar => Playfield.Add(bar.Major ? new DrawableBarLineMajor(bar) : new DrawableBarLine(bar))); - private void loadBarLines() - { - TaikoHitObject lastObject = Beatmap.HitObjects[Beatmap.HitObjects.Count - 1]; - double lastHitTime = 1 + ((lastObject as IHasEndTime)?.EndTime ?? lastObject.StartTime); - - var timingPoints = Beatmap.ControlPointInfo.TimingPoints.ToList(); - - if (timingPoints.Count == 0) - return; - - int currentIndex = 0; - int currentBeat = 0; - double time = timingPoints[currentIndex].Time; - - while (time <= lastHitTime) + FrameStableComponents.Add(scroller = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty()) { - int nextIndex = currentIndex + 1; - - if (nextIndex < timingPoints.Count && time > timingPoints[nextIndex].Time) - { - currentIndex = nextIndex; - time = timingPoints[currentIndex].Time; - currentBeat = 0; - } - - var currentPoint = timingPoints[currentIndex]; - - var barLine = new BarLine - { - StartTime = time, - }; - - barLine.ApplyDefaults(Beatmap.ControlPointInfo, Beatmap.BeatmapInfo.BaseDifficulty); - - bool isMajor = currentBeat % (int)currentPoint.TimeSignature == 0; - Playfield.Add(isMajor ? new DrawableBarLineMajor(barLine) : new DrawableBarLine(barLine)); - - time += currentPoint.BeatLength * (int)currentPoint.TimeSignature; - currentBeat++; - } + RelativeSizeAxes = Axes.X, + Depth = float.MaxValue + }); } - public override ScoreProcessor CreateScoreProcessor() => new TaikoScoreProcessor(this); + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + var playfieldScreen = Playfield.ScreenSpaceDrawQuad; + scroller.Height = ToLocalSpace(playfieldScreen.TopLeft + new Vector2(0, playfieldScreen.Height / 20)).Y; + } public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new TaikoPlayfieldAdjustmentContainer(); @@ -95,11 +67,8 @@ namespace osu.Game.Rulesets.Taiko.UI { switch (h) { - case CentreHit centreHit: - return new DrawableCentreHit(centreHit); - - case RimHit rimHit: - return new DrawableRimHit(rimHit); + case Hit hit: + return new DrawableHit(hit); case DrumRoll drumRoll: return new DrawableDrumRoll(drumRoll); @@ -112,5 +81,7 @@ namespace osu.Game.Rulesets.Taiko.UI } protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new TaikoFramedReplayInputHandler(replay); + + protected override ReplayRecorder CreateReplayRecorder(Replay replay) => new TaikoReplayRecorder(replay); } } diff --git a/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs b/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs new file mode 100644 index 0000000000..9bfb6aa839 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.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 osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Performance; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Rulesets.Taiko.UI +{ + internal class DrumRollHitContainer : ScrollingHitObjectContainer + { + protected override void Update() + { + base.Update(); + + // Remove any auxiliary hit notes that were spawned during a drum roll but subsequently rewound. + for (var i = AliveInternalChildren.Count - 1; i >= 0; i--) + { + var flyingHit = (DrawableFlyingHit)AliveInternalChildren[i]; + if (Time.Current <= flyingHit.HitObject.StartTime) + Remove(flyingHit); + } + } + + protected override void OnChildLifetimeBoundaryCrossed(LifetimeBoundaryCrossedEvent e) + { + base.OnChildLifetimeBoundaryCrossed(e); + + // ensure all old hits are removed on becoming alive (may miss being in the AliveInternalChildren list above). + if (e.Kind == LifetimeBoundaryKind.Start && e.Direction == LifetimeBoundaryCrossingDirection.Backward) + Remove((DrawableHitObject)e.Child); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs index 404960c26f..247c0dde73 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs @@ -1,15 +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 osuTK; -using osuTK.Graphics; 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.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.UI { @@ -20,55 +20,52 @@ namespace osu.Game.Rulesets.Taiko.UI { public override bool RemoveWhenNotAlive => true; + [Cached(typeof(DrawableHitObject))] public readonly DrawableHitObject JudgedObject; - private readonly Box innerFill; + private readonly HitResult result; - private readonly bool isRim; + private SkinnableDrawable skinnable; - public HitExplosion(DrawableHitObject judgedObject, bool isRim) + public override double LifetimeStart => skinnable.Drawable.LifetimeStart; + + public override double LifetimeEnd => skinnable.Drawable.LifetimeEnd; + + public HitExplosion(DrawableHitObject judgedObject, HitResult result) { - this.isRim = isRim; - JudgedObject = judgedObject; + this.result = result; - Anchor = Anchor.CentreLeft; + Anchor = Anchor.Centre; Origin = Anchor.Centre; RelativeSizeAxes = Axes.Both; Size = new Vector2(TaikoHitObject.DEFAULT_SIZE); RelativePositionAxes = Axes.Both; - - BorderColour = Color4.White; - BorderThickness = 1; - - Alpha = 0.15f; - Masking = true; - - Children = new[] - { - innerFill = new Box - { - RelativeSizeAxes = Axes.Both, - } - }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - innerFill.Colour = isRim ? colours.BlueDarker : colours.PinkDarker; + Child = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(result)), _ => new DefaultHitExplosion(JudgedObject, result)); } - protected override void LoadComplete() + private static TaikoSkinComponents getComponentName(HitResult result) { - base.LoadComplete(); + switch (result) + { + case HitResult.Miss: + return TaikoSkinComponents.TaikoExplosionMiss; - this.ScaleTo(3f, 1000, Easing.OutQuint); - this.FadeOut(500); + case HitResult.Ok: + return TaikoSkinComponents.TaikoExplosionOk; - Expire(true); + case HitResult.Great: + return TaikoSkinComponents.TaikoExplosionGreat; + } + + throw new ArgumentOutOfRangeException(nameof(result), $"Invalid result type: {result}"); } /// diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs index aa37ff7008..1ca1be1bdf 100644 --- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs +++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs @@ -12,6 +12,8 @@ using osu.Framework.Input.Bindings; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Rulesets.Taiko.Audio; +using osu.Game.Screens.Play; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.UI { @@ -22,48 +24,54 @@ namespace osu.Game.Rulesets.Taiko.UI { private const float middle_split = 0.025f; - private readonly ControlPointInfo controlPoints; + [Cached] + private DrumSampleContainer sampleContainer; public InputDrum(ControlPointInfo controlPoints) { - this.controlPoints = controlPoints; + sampleContainer = new DrumSampleContainer(controlPoints); RelativeSizeAxes = Axes.Both; - FillMode = FillMode.Fit; } [BackgroundDependencyLoader] private void load() { - var sampleMappings = new DrumSampleMapping(controlPoints); - Children = new Drawable[] { - new TaikoHalfDrum(false, sampleMappings) + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.InputDrum), _ => new Container { - Name = "Left Half", - Anchor = Anchor.Centre, - Origin = Anchor.CentreRight, RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.X, - X = -middle_split / 2, - RimAction = TaikoAction.LeftRim, - CentreAction = TaikoAction.LeftCentre - }, - new TaikoHalfDrum(true, sampleMappings) - { - Name = "Right Half", - Anchor = Anchor.Centre, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.X, - X = middle_split / 2, - RimAction = TaikoAction.RightRim, - CentreAction = TaikoAction.RightCentre - } + FillMode = FillMode.Fit, + Scale = new Vector2(0.9f), + Children = new Drawable[] + { + new TaikoHalfDrum(false) + { + Name = "Left Half", + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + X = -middle_split / 2, + RimAction = TaikoAction.LeftRim, + CentreAction = TaikoAction.LeftCentre + }, + new TaikoHalfDrum(true) + { + Name = "Right Half", + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.X, + X = middle_split / 2, + RimAction = TaikoAction.RightRim, + CentreAction = TaikoAction.RightCentre + } + } + }), + sampleContainer }; - - AddRangeInternal(sampleMappings.Sounds); } /// @@ -86,12 +94,11 @@ namespace osu.Game.Rulesets.Taiko.UI private readonly Sprite centre; private readonly Sprite centreHit; - private readonly DrumSampleMapping sampleMappings; + [Resolved] + private DrumSampleContainer sampleContainer { get; set; } - public TaikoHalfDrum(bool flipped, DrumSampleMapping sampleMappings) + public TaikoHalfDrum(bool flipped) { - this.sampleMappings = sampleMappings; - Masking = true; Children = new Drawable[] @@ -108,7 +115,7 @@ namespace osu.Game.Rulesets.Taiko.UI Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Alpha = 0, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, }, centre = new Sprite { @@ -124,7 +131,7 @@ namespace osu.Game.Rulesets.Taiko.UI RelativeSizeAxes = Axes.Both, Size = new Vector2(0.7f), Alpha = 0, - Blending = BlendingMode.Additive + Blending = BlendingParameters.Additive } }; } @@ -132,21 +139,24 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load(TextureStore textures, OsuColour colours) { - rim.Texture = textures.Get(@"Play/Taiko/taiko-drum-outer"); - rimHit.Texture = textures.Get(@"Play/Taiko/taiko-drum-outer-hit"); - centre.Texture = textures.Get(@"Play/Taiko/taiko-drum-inner"); - centreHit.Texture = textures.Get(@"Play/Taiko/taiko-drum-inner-hit"); + rim.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-outer"); + rimHit.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-outer-hit"); + centre.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-inner"); + centreHit.Texture = textures.Get(@"Gameplay/taiko/taiko-drum-inner-hit"); rimHit.Colour = colours.Blue; centreHit.Colour = colours.Pink; } + [Resolved(canBeNull: true)] + private GameplayClock gameplayClock { get; set; } + public bool OnPressed(TaikoAction action) { Drawable target = null; Drawable back = null; - var drumSample = sampleMappings.SampleAt(Time.Current); + var drumSample = sampleContainer.SampleAt(Time.Current); if (action == CentreAction) { @@ -187,7 +197,9 @@ namespace osu.Game.Rulesets.Taiko.UI return false; } - public bool OnReleased(TaikoAction action) => false; + public void OnReleased(TaikoAction action) + { + } } } } diff --git a/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs index e80b463481..067d390894 100644 --- a/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/KiaiHitExplosion.cs @@ -18,14 +18,12 @@ namespace osu.Game.Rulesets.Taiko.UI public override bool RemoveWhenNotAlive => true; public readonly DrawableHitObject JudgedObject; + private readonly HitType type; - private readonly bool isRim; - - public KiaiHitExplosion(DrawableHitObject judgedObject, bool isRim) + public KiaiHitExplosion(DrawableHitObject judgedObject, HitType type) { - this.isRim = isRim; - JudgedObject = judgedObject; + this.type = type; Anchor = Anchor.CentreLeft; Origin = Anchor.Centre; @@ -33,6 +31,8 @@ namespace osu.Game.Rulesets.Taiko.UI RelativeSizeAxes = Axes.Both; Size = new Vector2(TaikoHitObject.DEFAULT_SIZE, 1); + Blending = BlendingParameters.Additive; + Masking = true; Alpha = 0.25f; @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Taiko.UI EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, - Colour = isRim ? colours.BlueDarker : colours.PinkDarker, + Colour = type == HitType.Rim ? colours.BlueDarker : colours.PinkDarker, Radius = 60, }; } diff --git a/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundLeft.cs b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundLeft.cs new file mode 100644 index 0000000000..2a8890a95d --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundLeft.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.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.UI +{ + internal class PlayfieldBackgroundLeft : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.Both; + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Gray1, + RelativeSizeAxes = Axes.Both, + }, + new Box + { + Anchor = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + Width = 10, + Colour = Framework.Graphics.Colour.ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.6f), Color4.Black.Opacity(0)), + }, + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundRight.cs b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundRight.cs new file mode 100644 index 0000000000..44bfdacf37 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/PlayfieldBackgroundRight.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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Taiko.UI +{ + public class PlayfieldBackgroundRight : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Name = "Transparent playfield background"; + RelativeSizeAxes = Axes.Both; + Masking = true; + BorderColour = colours.Gray1; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.2f), + Radius = 5, + }; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Gray0, + Alpha = 0.6f + }, + new Container + { + Name = "Border", + RelativeSizeAxes = Axes.Both, + Masking = true, + MaskingSmoothness = 0, + BorderThickness = 2, + AlwaysPresent = true, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + } + }; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/HitTarget.cs b/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs similarity index 90% rename from osu.Game.Rulesets.Taiko/UI/HitTarget.cs rename to osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs index 2bb208bd1d..caddc8b122 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitTarget.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoHitTarget.cs @@ -13,15 +13,17 @@ namespace osu.Game.Rulesets.Taiko.UI /// /// A component that is displayed at the hit position in the taiko playfield. /// - internal class HitTarget : Container + internal class TaikoHitTarget : Container { /// /// Thickness of all drawn line pieces. /// private const float border_thickness = 2.5f; - public HitTarget() + public TaikoHitTarget() { + RelativeSizeAxes = Axes.Both; + Children = new Drawable[] { new Box @@ -39,8 +41,7 @@ namespace osu.Game.Rulesets.Taiko.UI Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Scale = new Vector2(TaikoHitObject.DEFAULT_STRONG_SIZE), + Size = new Vector2(TaikoHitObject.DEFAULT_STRONG_SIZE), Masking = true, BorderColour = Color4.White, BorderThickness = border_thickness, @@ -61,8 +62,7 @@ namespace osu.Game.Rulesets.Taiko.UI Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Scale = new Vector2(TaikoHitObject.DEFAULT_SIZE), + Size = new Vector2(TaikoHitObject.DEFAULT_SIZE), Masking = true, BorderColour = Color4.White, BorderThickness = border_thickness, diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs new file mode 100644 index 0000000000..9c76aea54c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs @@ -0,0 +1,140 @@ +// 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.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.UI +{ + public sealed class TaikoMascotAnimation : BeatSyncedContainer + { + private readonly TextureAnimation textureAnimation; + + private int currentFrame; + + public double DisplayTime; + + public TaikoMascotAnimation(TaikoMascotAnimationState state) + { + InternalChild = textureAnimation = createTextureAnimation(state).With(animation => + { + animation.Origin = animation.Anchor = Anchor.BottomLeft; + animation.Scale = new Vector2(0.51f); // close enough to stable + }); + + RelativeSizeAxes = Axes.Both; + Origin = Anchor = Anchor.BottomLeft; + + // needs to be always present to prevent the animation clock consuming time spent when not present. + AlwaysPresent = true; + } + + public bool Completed => !textureAnimation.IsPlaying || textureAnimation.PlaybackPosition >= textureAnimation.Duration; + + public override void Show() + { + base.Show(); + DisplayTime = Time.Current; + textureAnimation.Seek(0); + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + // assume that if the animation is playing on its own, it's independent from the beat and doesn't need to be touched. + if (textureAnimation.FrameCount == 0 || textureAnimation.IsPlaying) + return; + + textureAnimation.GotoFrame(currentFrame); + currentFrame = (currentFrame + 1) % textureAnimation.FrameCount; + } + + private static TextureAnimation createTextureAnimation(TaikoMascotAnimationState state) + { + switch (state) + { + case TaikoMascotAnimationState.Clear: + return new ClearMascotTextureAnimation(); + + case TaikoMascotAnimationState.Idle: + case TaikoMascotAnimationState.Kiai: + case TaikoMascotAnimationState.Fail: + return new ManualMascotTextureAnimation(state); + + default: + throw new ArgumentOutOfRangeException(nameof(state), $"Mascot animations for state {state} are not supported"); + } + } + + private class ManualMascotTextureAnimation : TextureAnimation + { + private readonly TaikoMascotAnimationState state; + + public ManualMascotTextureAnimation(TaikoMascotAnimationState state) + { + this.state = state; + + IsPlaying = false; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + for (int frameIndex = 0; true; frameIndex++) + { + var texture = getAnimationFrame(skin, state, frameIndex); + + if (texture == null) + break; + + AddFrame(texture); + } + } + } + + private class ClearMascotTextureAnimation : TextureAnimation + { + private const float clear_animation_speed = 1000 / 10f; + + private static readonly int[] clear_animation_sequence = { 0, 1, 2, 3, 4, 5, 6, 5, 6, 5, 4, 3, 2, 1, 0 }; + + public ClearMascotTextureAnimation() + { + DefaultFrameLength = clear_animation_speed; + Loop = false; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + foreach (var frameIndex in clear_animation_sequence) + { + var texture = getAnimationFrame(skin, TaikoMascotAnimationState.Clear, frameIndex); + + if (texture == null) + // as per https://osu.ppy.sh/help/wiki/Skinning/osu!taiko#pippidon + break; + + AddFrame(texture); + } + } + } + + private static Texture getAnimationFrame(ISkin skin, TaikoMascotAnimationState state, int frameIndex) + { + var texture = skin.GetTexture($"pippidon{state.ToString().ToLower()}{frameIndex}"); + + if (frameIndex == 0 && texture == null) + texture = skin.GetTexture($"pippidon{state.ToString().ToLower()}"); + + return texture; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimationState.cs b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimationState.cs new file mode 100644 index 0000000000..02bf245b7b --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimationState.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. + +namespace osu.Game.Rulesets.Taiko.UI +{ + public enum TaikoMascotAnimationState + { + Idle, + Clear, + Kiai, + Fail + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 7427a3235d..120cf264c3 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -3,208 +3,163 @@ 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.Graphics.Shapes; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; -using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects.Drawables; using osu.Game.Rulesets.Taiko.Judgements; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Skinning; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.UI { public class TaikoPlayfield : ScrollingPlayfield { + private readonly ControlPointInfo controlPoints; + /// /// Default height of a when inside a . /// public const float DEFAULT_HEIGHT = 178; - /// - /// The offset from which the center of the hit target lies at. - /// - public const float HIT_TARGET_OFFSET = 100; + private Container hitExplosionContainer; + private Container kiaiExplosionContainer; + private JudgementContainer judgementContainer; + private ScrollingHitObjectContainer drumRollHitContainer; + internal Drawable HitTarget; + private SkinnableDrawable mascot; - /// - /// The size of the left area of the playfield. This area contains the input drum. - /// - private const float left_area_size = 240; + private ProxyContainer topLevelHitContainer; + private ProxyContainer barlineContainer; + private Container rightArea; + private Container leftArea; - private readonly Container hitExplosionContainer; - private readonly Container kiaiExplosionContainer; - private readonly JudgementContainer judgementContainer; - internal readonly HitTarget HitTarget; - - private readonly Container topLevelHitContainer; - - private readonly Container barlineContainer; - - private readonly Container overlayBackgroundContainer; - private readonly Container backgroundContainer; - - private readonly Box overlayBackground; - private readonly Box background; + private Container hitTargetOffsetContent; public TaikoPlayfield(ControlPointInfo controlPoints) { - InternalChildren = new Drawable[] + this.controlPoints = controlPoints; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChildren = new[] { - backgroundContainer = new Container - { - Name = "Transparent playfield background", - RelativeSizeAxes = Axes.Both, - Masking = true, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.2f), - Radius = 5, - }, - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.6f - }, - } - }, - new Container + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundRight), _ => new PlayfieldBackgroundRight()), + rightArea = new Container { Name = "Right area", RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = left_area_size }, + RelativePositionAxes = Axes.Both, Children = new Drawable[] { new Container { Name = "Masked elements before hit objects", RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = HIT_TARGET_OFFSET }, - Masking = true, - Children = new Drawable[] + FillMode = FillMode.Fit, + Children = new[] { hitExplosionContainer = new Container { RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Blending = BlendingMode.Additive, }, - HitTarget = new HitTarget + HitTarget = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.HitTarget), _ => new TaikoHitTarget()) { - Anchor = Anchor.CentreLeft, - Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit } } }, - barlineContainer = new Container + hitTargetOffsetContent = new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = HIT_TARGET_OFFSET } - }, - new Container - { - Name = "Hit objects", - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = HIT_TARGET_OFFSET }, - Masking = true, - Child = HitObjectContainer - }, - kiaiExplosionContainer = new Container - { - Name = "Kiai hit explosions", - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Margin = new MarginPadding { Left = HIT_TARGET_OFFSET }, - Blending = BlendingMode.Additive - }, - judgementContainer = new JudgementContainer - { - Name = "Judgements", - RelativeSizeAxes = Axes.Y, - Margin = new MarginPadding { Left = HIT_TARGET_OFFSET }, - Blending = BlendingMode.Additive + Children = new Drawable[] + { + barlineContainer = new ProxyContainer + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Name = "Hit objects", + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + HitObjectContainer, + drumRollHitContainer = new DrumRollHitContainer() + } + }, + kiaiExplosionContainer = new Container + { + Name = "Kiai hit explosions", + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + }, + judgementContainer = new JudgementContainer + { + Name = "Judgements", + RelativeSizeAxes = Axes.Y, + }, + } }, } }, - overlayBackgroundContainer = new Container + leftArea = new Container { Name = "Left overlay", - RelativeSizeAxes = Axes.Y, - Size = new Vector2(left_area_size, 1), + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + BorderColour = colours.Gray0, Children = new Drawable[] { - overlayBackground = new Box - { - RelativeSizeAxes = Axes.Both, - }, + new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.PlayfieldBackgroundLeft), _ => new PlayfieldBackgroundLeft()), new InputDrum(controlPoints) { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Scale = new Vector2(0.9f), - Margin = new MarginPadding { Right = 20 } - }, - new Box - { - Anchor = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - Width = 10, - Colour = Framework.Graphics.Colour.ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.6f), Color4.Black.Opacity(0)), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, }, } }, - new Container + mascot = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Mascot), _ => Empty()) { - Name = "Border", - RelativeSizeAxes = Axes.Both, - Masking = true, - MaskingSmoothness = 0, - BorderThickness = 2, - AlwaysPresent = true, - Children = new[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - } - } + Origin = Anchor.BottomLeft, + Anchor = Anchor.TopLeft, + RelativePositionAxes = Axes.Y, + RelativeSizeAxes = Axes.None, + Y = 0.2f }, - topLevelHitContainer = new Container + topLevelHitContainer = new ProxyContainer { Name = "Top level hit objects", RelativeSizeAxes = Axes.Both, - } + }, + drumRollHitContainer.CreateProxy(), }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + protected override void Update() { - overlayBackgroundContainer.BorderColour = colours.Gray0; - overlayBackground.Colour = colours.Gray1; + base.Update(); - backgroundContainer.BorderColour = colours.Gray1; - background.Colour = colours.Gray0; + // Padding is required to be updated for elements which are based on "absolute" X sized elements. + // This is basically allowing for correct alignment as relative pieces move around them. + rightArea.Padding = new MarginPadding { Left = leftArea.DrawWidth }; + hitTargetOffsetContent.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 }; + + mascot.Scale = new Vector2(DrawHeight / DEFAULT_HEIGHT); } public override void Add(DrawableHitObject h) { h.OnNewResult += OnNewResult; - base.Add(h); switch (h) @@ -223,7 +178,6 @@ namespace osu.Game.Rulesets.Taiko.UI { if (!DisplayJudgements.Value) return; - if (!judgedObject.DisplayResult) return; @@ -234,6 +188,15 @@ namespace osu.Game.Rulesets.Taiko.UI hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).MainObject)?.VisualiseSecondHit(); break; + case TaikoDrumRollTickJudgement _: + if (!result.IsHit) + break; + + var drawableTick = (DrawableDrumRollTick)judgedObject; + + addDrumRollHit(drawableTick); + break; + default: judgementContainer.Add(new DrawableTaikoJudgement(result, judgedObject) { @@ -243,18 +206,30 @@ namespace osu.Game.Rulesets.Taiko.UI X = result.IsHit ? judgedObject.Position.X : 0, }); - if (!result.IsHit) - break; - - bool isRim = judgedObject.HitObject is RimHit; - - hitExplosionContainer.Add(new HitExplosion(judgedObject, isRim)); - - if (judgedObject.HitObject.Kiai) - kiaiExplosionContainer.Add(new KiaiHitExplosion(judgedObject, isRim)); - + var type = (judgedObject.HitObject as Hit)?.Type ?? HitType.Centre; + addExplosion(judgedObject, result.Type, type); break; } } + + private void addDrumRollHit(DrawableDrumRollTick drawableTick) => + drumRollHitContainer.Add(new DrawableFlyingHit(drawableTick)); + + private void addExplosion(DrawableHitObject drawableObject, HitResult result, HitType type) + { + hitExplosionContainer.Add(new HitExplosion(drawableObject, result)); + if (drawableObject.HitObject.Kiai) + kiaiExplosionContainer.Add(new KiaiHitExplosion(drawableObject, type)); + } + + private class ProxyContainer : LifetimeManagementContainer + { + public new MarginPadding Padding + { + set => base.Padding = value; + } + + public void Add(Drawable proxy) => AddInternal(proxy); + } } } diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs index 84464b199e..1041456020 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.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.Game.Rulesets.UI; using osuTK; @@ -12,18 +13,16 @@ namespace osu.Game.Rulesets.Taiko.UI private const float default_relative_height = TaikoPlayfield.DEFAULT_HEIGHT / 768; private const float default_aspect = 16f / 9f; - public TaikoPlayfieldAdjustmentContainer() - { - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - } - protected override void Update() { base.Update(); - float aspectAdjust = MathHelper.Clamp(Parent.ChildSize.X / Parent.ChildSize.Y, 0.4f, 4) / default_aspect; + float aspectAdjust = Math.Clamp(Parent.ChildSize.X / Parent.ChildSize.Y, 0.4f, 4) / default_aspect; Size = new Vector2(1, default_relative_height * aspectAdjust); + + // Position the taiko playfield exactly one playfield from the top of the screen. + RelativePositionAxes = Axes.Y; + Y = Size.Y; } } } diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoReplayRecorder.cs b/osu.Game.Rulesets.Taiko/UI/TaikoReplayRecorder.cs new file mode 100644 index 0000000000..1859dabf03 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/TaikoReplayRecorder.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.Collections.Generic; +using osu.Game.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Taiko.Replays; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Taiko.UI +{ + public class TaikoReplayRecorder : ReplayRecorder + { + public TaikoReplayRecorder(Replay replay) + : base(replay) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) => + new TaikoReplayFrame(Time.Current, actions.ToArray()); + } +} diff --git a/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj b/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj index 656ebcc7c2..ebed8c6d7c 100644 --- a/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj +++ b/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj @@ -1,9 +1,7 @@  - - netstandard2.0 + netstandard2.1 Library - AnyCPU true bash the drum. to the beat. diff --git a/osu.Game.Tests.Android/Properties/AndroidManifest.xml b/osu.Game.Tests.Android/Properties/AndroidManifest.xml index bb996dc5ca..4a63f0c357 100644 --- a/osu.Game.Tests.Android/Properties/AndroidManifest.xml +++ b/osu.Game.Tests.Android/Properties/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj index c2dd194e09..c44ed69c4d 100644 --- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj +++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj @@ -24,6 +24,7 @@ %(RecursiveDir)%(Filename)%(Extension) + %(RecursiveDir)%(Filename)%(Extension) @@ -68,10 +69,5 @@ osu.Game - - - 2.0.0 - - \ No newline at end of file diff --git a/osu.Game.Tests.iOS/Application.cs b/osu.Game.Tests.iOS/Application.cs index d96a3e27a4..9533b90131 100644 --- a/osu.Game.Tests.iOS/Application.cs +++ b/osu.Game.Tests.iOS/Application.cs @@ -5,7 +5,7 @@ using UIKit; namespace osu.Game.Tests.iOS { - public class Application + public static class Application { public static void Main(string[] args) { diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj index ea5ab699f3..ca68369ebb 100644 --- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj +++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj @@ -1,6 +1,5 @@ - + - Debug iPhoneSimulator @@ -13,14 +12,6 @@ - - libbass.a - PreserveNewest - - - libbass_fx.a - PreserveNewest - Linker.xml @@ -56,5 +47,4 @@ - \ No newline at end of file diff --git a/osu.Game.Tests/Beatmaps/BeatmapDifficultyManagerTest.cs b/osu.Game.Tests/Beatmaps/BeatmapDifficultyManagerTest.cs new file mode 100644 index 0000000000..70503bec7a --- /dev/null +++ b/osu.Game.Tests/Beatmaps/BeatmapDifficultyManagerTest.cs @@ -0,0 +1,56 @@ +// 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; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Tests.Beatmaps +{ + [TestFixture] + public class BeatmapDifficultyManagerTest + { + [Test] + public void TestKeyEqualsWithDifferentModInstances() + { + var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHardRock(), new OsuModHidden() }); + var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHardRock(), new OsuModHidden() }); + + Assert.That(key1, Is.EqualTo(key2)); + } + + [Test] + public void TestKeyEqualsWithDifferentModOrder() + { + var key1 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHardRock(), new OsuModHidden() }); + var key2 = new BeatmapDifficultyCache.DifficultyCacheLookup(new BeatmapInfo { ID = 1234 }, new RulesetInfo { ID = 0 }, new Mod[] { new OsuModHidden(), new OsuModHardRock() }); + + Assert.That(key1, Is.EqualTo(key2)); + } + + [TestCase(1.3, DifficultyRating.Easy)] + [TestCase(1.993, DifficultyRating.Easy)] + [TestCase(1.998, DifficultyRating.Normal)] + [TestCase(2.4, DifficultyRating.Normal)] + [TestCase(2.693, DifficultyRating.Normal)] + [TestCase(2.698, DifficultyRating.Hard)] + [TestCase(3.5, DifficultyRating.Hard)] + [TestCase(3.993, DifficultyRating.Hard)] + [TestCase(3.997, DifficultyRating.Insane)] + [TestCase(5.0, DifficultyRating.Insane)] + [TestCase(5.292, DifficultyRating.Insane)] + [TestCase(5.297, DifficultyRating.Expert)] + [TestCase(6.2, DifficultyRating.Expert)] + [TestCase(6.493, DifficultyRating.Expert)] + [TestCase(6.498, DifficultyRating.ExpertPlus)] + [TestCase(8.3, DifficultyRating.ExpertPlus)] + public void TestDifficultyRatingMapping(double starRating, DifficultyRating expectedBracket) + { + var actualBracket = BeatmapDifficultyCache.GetDifficultyRating(starRating); + + Assert.AreEqual(expectedBracket, actualBracket); + } + } +} diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index d087251e7e..4b9e9dd88c 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -13,6 +13,8 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects.Types; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Timing; +using osu.Game.IO; +using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -30,13 +32,9 @@ namespace osu.Game.Tests.Beatmaps.Formats public void TestDecodeBeatmapVersion() { using (var resStream = TestResources.OpenResource("beatmap-version.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var decoder = Decoder.GetDecoder(stream); - - stream.BaseStream.Position = 0; - stream.DiscardBufferedData(); - var working = new TestWorkingBeatmap(decoder.Decode(stream)); Assert.AreEqual(6, working.BeatmapInfo.BeatmapVersion); @@ -51,7 +49,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var beatmap = decoder.Decode(stream); var beatmapInfo = beatmap.BeatmapInfo; @@ -75,7 +73,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder(); using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var beatmapInfo = decoder.Decode(stream).BeatmapInfo; @@ -101,7 +99,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder(); using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var beatmap = decoder.Decode(stream); var beatmapInfo = beatmap.BeatmapInfo; @@ -126,7 +124,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder(); using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var difficulty = decoder.Decode(stream).BeatmapInfo.BaseDifficulty; @@ -145,7 +143,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var beatmap = decoder.Decode(stream); var metadata = beatmap.Metadata; @@ -164,15 +162,15 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var beatmap = decoder.Decode(stream); var controlPoints = beatmap.ControlPointInfo; Assert.AreEqual(4, controlPoints.TimingPoints.Count); - Assert.AreEqual(42, controlPoints.DifficultyPoints.Count); - Assert.AreEqual(42, controlPoints.SamplePoints.Count); - Assert.AreEqual(42, controlPoints.EffectPoints.Count); + Assert.AreEqual(5, controlPoints.DifficultyPoints.Count); + Assert.AreEqual(34, controlPoints.SamplePoints.Count); + Assert.AreEqual(8, controlPoints.EffectPoints.Count); var timingPoint = controlPoints.TimingPointAt(0); Assert.AreEqual(956, timingPoint.Time); @@ -194,7 +192,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual(1.0, difficultyPoint.SpeedMultiplier); difficultyPoint = controlPoints.DifficultyPointAt(48428); - Assert.AreEqual(48428, difficultyPoint.Time); + Assert.AreEqual(0, difficultyPoint.Time); Assert.AreEqual(1.0, difficultyPoint.SpeedMultiplier); difficultyPoint = controlPoints.DifficultyPointAt(116999); @@ -227,7 +225,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.IsFalse(effectPoint.OmitFirstBarLine); effectPoint = controlPoints.EffectPointAt(119637); - Assert.AreEqual(119637, effectPoint.Time); + Assert.AreEqual(95901, effectPoint.Time); Assert.IsFalse(effectPoint.KiaiMode); Assert.IsFalse(effectPoint.OmitFirstBarLine); } @@ -239,10 +237,15 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; using (var resStream = TestResources.OpenResource("overlapping-control-points.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var controlPoints = decoder.Decode(stream).ControlPointInfo; + Assert.That(controlPoints.TimingPoints.Count, Is.EqualTo(4)); + Assert.That(controlPoints.DifficultyPoints.Count, Is.EqualTo(3)); + Assert.That(controlPoints.EffectPoints.Count, Is.EqualTo(3)); + Assert.That(controlPoints.SamplePoints.Count, Is.EqualTo(3)); + Assert.That(controlPoints.DifficultyPointAt(500).SpeedMultiplier, Is.EqualTo(1.5).Within(0.1)); Assert.That(controlPoints.DifficultyPointAt(1500).SpeedMultiplier, Is.EqualTo(1.5).Within(0.1)); Assert.That(controlPoints.DifficultyPointAt(2500).SpeedMultiplier, Is.EqualTo(0.75).Within(0.1)); @@ -265,13 +268,28 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestTimingPointResetsSpeedMultiplier() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("timingpoint-speedmultiplier-reset.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var controlPoints = decoder.Decode(stream).ControlPointInfo; + + Assert.That(controlPoints.DifficultyPointAt(0).SpeedMultiplier, Is.EqualTo(0.5).Within(0.1)); + Assert.That(controlPoints.DifficultyPointAt(2000).SpeedMultiplier, Is.EqualTo(1).Within(0.1)); + } + } + [Test] public void TestDecodeBeatmapColours() { var decoder = new LegacySkinDecoder(); using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var comboColors = decoder.Decode(stream).ComboColours; @@ -297,11 +315,11 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder(); using (var resStream = TestResources.OpenResource("hitobject-combo-offset.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var beatmap = decoder.Decode(stream); - var converted = new OsuBeatmapConverter(beatmap).Convert(); + var converted = new OsuBeatmapConverter(beatmap, new OsuRuleset()).Convert(); new OsuBeatmapProcessor(converted).PreProcess(); new OsuBeatmapProcessor(converted).PostProcess(); @@ -320,11 +338,11 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder(); using (var resStream = TestResources.OpenResource("hitobject-combo-offset.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var beatmap = decoder.Decode(stream); - var converted = new CatchBeatmapConverter(beatmap).Convert(); + var converted = new CatchBeatmapConverter(beatmap, new CatchRuleset()).Convert(); new CatchBeatmapProcessor(converted).PreProcess(); new CatchBeatmapProcessor(converted).PostProcess(); @@ -343,11 +361,11 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var hitObjects = decoder.Decode(stream).HitObjects; - var curveData = hitObjects[0] as IHasCurve; + var curveData = hitObjects[0] as IHasPathWithRepeats; var positionData = hitObjects[0] as IHasPosition; Assert.IsNotNull(positionData); @@ -365,26 +383,43 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestDecodeControlPointDifficultyChange() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("controlpoint-difficulty-multiplier.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var controlPointInfo = decoder.Decode(stream).ControlPointInfo; + + Assert.That(controlPointInfo.DifficultyPointAt(5).SpeedMultiplier, Is.EqualTo(1)); + Assert.That(controlPointInfo.DifficultyPointAt(1000).SpeedMultiplier, Is.EqualTo(10)); + Assert.That(controlPointInfo.DifficultyPointAt(2000).SpeedMultiplier, Is.EqualTo(1.8518518518518519d)); + Assert.That(controlPointInfo.DifficultyPointAt(3000).SpeedMultiplier, Is.EqualTo(0.5)); + } + } + [Test] public void TestDecodeControlPointCustomSampleBank() { var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; using (var resStream = TestResources.OpenResource("controlpoint-custom-samplebank.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var hitObjects = decoder.Decode(stream).HitObjects; - Assert.AreEqual("normal-hitnormal", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); - Assert.AreEqual("normal-hitnormal", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); - Assert.AreEqual("normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); - Assert.AreEqual("normal-hitnormal", getTestableSampleInfo(hitObjects[3]).LookupNames.First()); + Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); + Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); + Assert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); + Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[3]).LookupNames.First()); // The control point at the end time of the slider should be applied - Assert.AreEqual("soft-hitnormal8", getTestableSampleInfo(hitObjects[4]).LookupNames.First()); + Assert.AreEqual("Gameplay/soft-hitnormal8", getTestableSampleInfo(hitObjects[4]).LookupNames.First()); } - HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.SampleControlPoint.ApplyTo(hitObject.Samples[0]); + static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.SampleControlPoint.ApplyTo(hitObject.Samples[0]); } [Test] @@ -393,16 +428,16 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; using (var resStream = TestResources.OpenResource("hitobject-custom-samplebank.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var hitObjects = decoder.Decode(stream).HitObjects; - Assert.AreEqual("normal-hitnormal", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); - Assert.AreEqual("normal-hitnormal2", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); - Assert.AreEqual("normal-hitnormal3", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); + Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); + Assert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); + Assert.AreEqual("Gameplay/normal-hitnormal3", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); } - HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.SampleControlPoint.ApplyTo(hitObject.Samples[0]); + static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.SampleControlPoint.ApplyTo(hitObject.Samples[0]); } [Test] @@ -411,18 +446,18 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; using (var resStream = TestResources.OpenResource("hitobject-file-samples.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var hitObjects = decoder.Decode(stream).HitObjects; Assert.AreEqual("hit_1.wav", getTestableSampleInfo(hitObjects[0]).LookupNames.First()); Assert.AreEqual("hit_2.wav", getTestableSampleInfo(hitObjects[1]).LookupNames.First()); - Assert.AreEqual("normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); + Assert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First()); Assert.AreEqual("hit_1.wav", getTestableSampleInfo(hitObjects[3]).LookupNames.First()); Assert.AreEqual(70, getTestableSampleInfo(hitObjects[3]).Volume); } - HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.SampleControlPoint.ApplyTo(hitObject.Samples[0]); + static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.SampleControlPoint.ApplyTo(hitObject.Samples[0]); } [Test] @@ -431,7 +466,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; using (var resStream = TestResources.OpenResource("slider-samples.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var hitObjects = decoder.Decode(stream).HitObjects; @@ -475,12 +510,204 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; using (var resStream = TestResources.OpenResource("hitobject-no-addition-bank.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var hitObjects = decoder.Decode(stream).HitObjects; Assert.AreEqual(hitObjects[0].Samples[0].Bank, hitObjects[0].Samples[1].Bank); } } + + [Test] + public void TestInvalidEventStillPasses() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var badResStream = TestResources.OpenResource("invalid-events.osu")) + using (var badStream = new LineBufferedReader(badResStream)) + { + Assert.DoesNotThrow(() => decoder.Decode(badStream)); + } + } + + [Test] + public void TestFallbackDecoderForCorruptedHeader() + { + Decoder decoder = null; + Beatmap beatmap = null; + + using (var resStream = TestResources.OpenResource("corrupted-header.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); + Assert.IsInstanceOf(decoder); + Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream)); + Assert.IsNotNull(beatmap); + Assert.AreEqual("Beatmap with corrupted header", beatmap.Metadata.Title); + Assert.AreEqual("Evil Hacker", beatmap.Metadata.AuthorString); + } + } + + [Test] + public void TestFallbackDecoderForMissingHeader() + { + Decoder decoder = null; + Beatmap beatmap = null; + + using (var resStream = TestResources.OpenResource("missing-header.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); + Assert.IsInstanceOf(decoder); + Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream)); + Assert.IsNotNull(beatmap); + Assert.AreEqual("Beatmap with no header", beatmap.Metadata.Title); + Assert.AreEqual("Incredibly Evil Hacker", beatmap.Metadata.AuthorString); + } + } + + [Test] + public void TestDecodeFileWithEmptyLinesAtStart() + { + Decoder decoder = null; + Beatmap beatmap = null; + + using (var resStream = TestResources.OpenResource("empty-lines-at-start.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); + Assert.IsInstanceOf(decoder); + Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream)); + Assert.IsNotNull(beatmap); + Assert.AreEqual("Empty lines at start", beatmap.Metadata.Title); + Assert.AreEqual("Edge Case Hunter", beatmap.Metadata.AuthorString); + } + } + + [Test] + public void TestDecodeFileWithEmptyLinesAndNoHeader() + { + Decoder decoder = null; + Beatmap beatmap = null; + + using (var resStream = TestResources.OpenResource("empty-line-instead-of-header.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); + Assert.IsInstanceOf(decoder); + Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream)); + Assert.IsNotNull(beatmap); + Assert.AreEqual("The dog ate the file header", beatmap.Metadata.Title); + Assert.AreEqual("Why does this keep happening", beatmap.Metadata.AuthorString); + } + } + + [Test] + public void TestDecodeFileWithContentImmediatelyAfterHeader() + { + Decoder decoder = null; + Beatmap beatmap = null; + + using (var resStream = TestResources.OpenResource("no-empty-line-after-header.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); + Assert.IsInstanceOf(decoder); + Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream)); + Assert.IsNotNull(beatmap); + Assert.AreEqual("No empty line delimiting header from contents", beatmap.Metadata.Title); + Assert.AreEqual("Edge Case Hunter", beatmap.Metadata.AuthorString); + } + } + + [Test] + public void TestDecodeEmptyFile() + { + using (var resStream = new MemoryStream()) + using (var stream = new LineBufferedReader(resStream)) + { + Assert.Throws(() => Decoder.GetDecoder(stream)); + } + } + + [Test] + public void TestAllowFallbackDecoderOverwrite() + { + Decoder decoder = null; + + using (var resStream = TestResources.OpenResource("corrupted-header.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); + Assert.IsInstanceOf(decoder); + } + + Assert.DoesNotThrow(LegacyDifficultyCalculatorBeatmapDecoder.Register); + + using (var resStream = TestResources.OpenResource("corrupted-header.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder(stream)); + Assert.IsInstanceOf(decoder); + } + } + + [Test] + public void TestMultiSegmentSliders() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("multi-segment-slider.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var decoded = decoder.Decode(stream); + + // Multi-segment + var first = ((IHasPath)decoded.HitObjects[0]).Path; + + Assert.That(first.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); + Assert.That(first.ControlPoints[0].Type.Value, Is.EqualTo(PathType.PerfectCurve)); + Assert.That(first.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(161, -244))); + Assert.That(first.ControlPoints[1].Type.Value, Is.EqualTo(null)); + + Assert.That(first.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(376, -3))); + Assert.That(first.ControlPoints[2].Type.Value, Is.EqualTo(PathType.Bezier)); + Assert.That(first.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(68, 15))); + Assert.That(first.ControlPoints[3].Type.Value, Is.EqualTo(null)); + Assert.That(first.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(259, -132))); + Assert.That(first.ControlPoints[4].Type.Value, Is.EqualTo(null)); + Assert.That(first.ControlPoints[5].Position.Value, Is.EqualTo(new Vector2(92, -107))); + Assert.That(first.ControlPoints[5].Type.Value, Is.EqualTo(null)); + + // Single-segment + var second = ((IHasPath)decoded.HitObjects[1]).Path; + + Assert.That(second.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); + Assert.That(second.ControlPoints[0].Type.Value, Is.EqualTo(PathType.PerfectCurve)); + Assert.That(second.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(161, -244))); + Assert.That(second.ControlPoints[1].Type.Value, Is.EqualTo(null)); + Assert.That(second.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(376, -3))); + Assert.That(second.ControlPoints[2].Type.Value, Is.EqualTo(null)); + + // Implicit multi-segment + var third = ((IHasPath)decoded.HitObjects[2]).Path; + + Assert.That(third.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); + Assert.That(third.ControlPoints[0].Type.Value, Is.EqualTo(PathType.Bezier)); + Assert.That(third.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(0, 192))); + Assert.That(third.ControlPoints[1].Type.Value, Is.EqualTo(null)); + Assert.That(third.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(224, 192))); + Assert.That(third.ControlPoints[2].Type.Value, Is.EqualTo(null)); + + Assert.That(third.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(224, 0))); + Assert.That(third.ControlPoints[3].Type.Value, Is.EqualTo(PathType.Bezier)); + Assert.That(third.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(224, -192))); + Assert.That(third.ControlPoints[4].Type.Value, Is.EqualTo(null)); + Assert.That(third.ControlPoints[5].Position.Value, Is.EqualTo(new Vector2(480, -192))); + 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)); + } + } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs new file mode 100644 index 0000000000..0784109158 --- /dev/null +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -0,0 +1,142 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using NUnit.Framework; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.IO.Serialization; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; +using osu.Game.Skinning; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Beatmaps.Formats +{ + [TestFixture] + public class LegacyBeatmapEncoderTest + { + private static readonly DllResourceStore beatmaps_resource_store = TestResources.GetStore(); + + private static IEnumerable allBeatmaps = beatmaps_resource_store.GetAvailableResources().Where(res => res.EndsWith(".osu", StringComparison.Ordinal)); + + [TestCaseSource(nameof(allBeatmaps))] + public void TestEncodeDecodeStability(string name) + { + var decoded = decodeFromLegacy(beatmaps_resource_store.GetStream(name), name); + var decodedAfterEncode = decodeFromLegacy(encodeToLegacy(decoded), name); + + sort(decoded.beatmap); + sort(decodedAfterEncode.beatmap); + + Assert.That(decodedAfterEncode.beatmap.Serialize(), Is.EqualTo(decoded.beatmap.Serialize())); + Assert.IsTrue(areComboColoursEqual(decodedAfterEncode.beatmapSkin.Configuration, decoded.beatmapSkin.Configuration)); + } + + private bool areComboColoursEqual(IHasComboColours a, IHasComboColours b) + { + // equal to null, no need to SequenceEqual + if (a.ComboColours == null && b.ComboColours == null) + return true; + + if (a.ComboColours == null || b.ComboColours == null) + return false; + + return a.ComboColours.SequenceEqual(b.ComboColours); + } + + private void sort(IBeatmap beatmap) + { + // Sort control points to ensure a sane ordering, as they may be parsed in different orders. This works because each group contains only uniquely-typed control points. + foreach (var g in beatmap.ControlPointInfo.Groups) + { + ArrayList.Adapter((IList)g.ControlPoints).Sort( + Comparer.Create((c1, c2) => string.Compare(c1.GetType().ToString(), c2.GetType().ToString(), StringComparison.Ordinal))); + } + } + + private (IBeatmap beatmap, TestLegacySkin beatmapSkin) decodeFromLegacy(Stream stream, string name) + { + using (var reader = new LineBufferedReader(stream)) + { + var beatmap = new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(reader); + var beatmapSkin = new TestLegacySkin(beatmaps_resource_store, name); + return (convert(beatmap), beatmapSkin); + } + } + + private class TestLegacySkin : LegacySkin + { + public TestLegacySkin(IResourceStore storage, string fileName) + : base(new SkinInfo { Name = "Test Skin", Creator = "Craftplacer" }, storage, null, fileName) + { + } + } + + private Stream encodeToLegacy((IBeatmap beatmap, ISkin beatmapSkin) fullBeatmap) + { + var (beatmap, beatmapSkin) = fullBeatmap; + var stream = new MemoryStream(); + + using (var writer = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(beatmap, beatmapSkin).Encode(writer); + + stream.Position = 0; + + return stream; + } + + private IBeatmap convert(IBeatmap beatmap) + { + switch (beatmap.BeatmapInfo.RulesetID) + { + case 0: + beatmap.BeatmapInfo.Ruleset = new OsuRuleset().RulesetInfo; + break; + + case 1: + beatmap.BeatmapInfo.Ruleset = new TaikoRuleset().RulesetInfo; + break; + + case 2: + beatmap.BeatmapInfo.Ruleset = new CatchRuleset().RulesetInfo; + break; + + case 3: + beatmap.BeatmapInfo.Ruleset = new ManiaRuleset().RulesetInfo; + break; + } + + return new TestWorkingBeatmap(beatmap).GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset); + } + + private class TestWorkingBeatmap : WorkingBeatmap + { + private readonly IBeatmap beatmap; + + public TestWorkingBeatmap(IBeatmap beatmap) + : base(beatmap.BeatmapInfo, null) + { + this.beatmap = beatmap; + } + + protected override IBeatmap GetBeatmap() => beatmap; + + protected override Texture GetBackground() => throw new NotImplementedException(); + + protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + } + } +} diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyDecoderTest.cs index b4d219456c..335a6aeeb0 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyDecoderTest.cs @@ -2,9 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.IO; using NUnit.Framework; using osu.Game.Beatmaps.Formats; +using osu.Game.IO; using osu.Game.Tests.Resources; namespace osu.Game.Tests.Beatmaps.Formats @@ -18,7 +18,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LineLoggingDecoder(14); using (var resStream = TestResources.OpenResource("comments.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { decoder.Decode(stream); diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs new file mode 100644 index 0000000000..9c71466489 --- /dev/null +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko; +using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Beatmaps.Formats +{ + [TestFixture] + public class LegacyScoreDecoderTest + { + [Test] + public void TestDecodeManiaReplay() + { + var decoder = new TestLegacyScoreDecoder(); + + using (var resourceStream = TestResources.OpenResource("Replays/mania-replay.osr")) + { + var score = decoder.Parse(resourceStream); + + Assert.AreEqual(3, score.ScoreInfo.Ruleset.ID); + + Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.Great]); + Assert.AreEqual(1, score.ScoreInfo.Statistics[HitResult.Good]); + + Assert.AreEqual(829_931, score.ScoreInfo.TotalScore); + Assert.AreEqual(3, score.ScoreInfo.MaxCombo); + Assert.IsTrue(Precision.AlmostEquals(0.8889, score.ScoreInfo.Accuracy, 0.0001)); + Assert.AreEqual(ScoreRank.B, score.ScoreInfo.Rank); + + Assert.That(score.Replay.Frames, Is.Not.Empty); + } + } + + private class TestLegacyScoreDecoder : LegacyScoreDecoder + { + private static readonly Dictionary rulesets = new Ruleset[] + { + new OsuRuleset(), + new TaikoRuleset(), + new CatchRuleset(), + new ManiaRuleset() + }.ToDictionary(ruleset => ((ILegacyRuleset)ruleset).LegacyID); + + protected override Ruleset GetRuleset(int rulesetId) => rulesets[rulesetId]; + + protected override WorkingBeatmap GetBeatmap(string md5Hash) => new TestWorkingBeatmap(new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + MD5Hash = md5Hash, + Ruleset = new OsuRuleset().RulesetInfo, + BaseDifficulty = new BeatmapDifficulty() + } + }); + } + } +} diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs index 971518909d..9ebedb3c80 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.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.IO; using System.Linq; using NUnit.Framework; using osuTK; using osu.Framework.Graphics; using osu.Game.Beatmaps.Formats; +using osu.Game.IO; using osu.Game.Storyboards; using osu.Game.Tests.Resources; @@ -21,44 +21,51 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyStoryboardDecoder(); using (var resStream = TestResources.OpenResource("Himeringo - Yotsuya-san ni Yoroshiku (RLC) [Winber1's Extreme].osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var storyboard = decoder.Decode(stream); Assert.IsTrue(storyboard.HasDrawable); - Assert.AreEqual(4, storyboard.Layers.Count()); + Assert.AreEqual(6, storyboard.Layers.Count()); StoryboardLayer background = storyboard.Layers.FirstOrDefault(l => l.Depth == 3); Assert.IsNotNull(background); Assert.AreEqual(16, background.Elements.Count); - Assert.IsTrue(background.EnabledWhenFailing); - Assert.IsTrue(background.EnabledWhenPassing); + Assert.IsTrue(background.VisibleWhenFailing); + Assert.IsTrue(background.VisibleWhenPassing); Assert.AreEqual("Background", background.Name); StoryboardLayer fail = storyboard.Layers.FirstOrDefault(l => l.Depth == 2); Assert.IsNotNull(fail); Assert.AreEqual(0, fail.Elements.Count); - Assert.IsTrue(fail.EnabledWhenFailing); - Assert.IsFalse(fail.EnabledWhenPassing); + Assert.IsTrue(fail.VisibleWhenFailing); + Assert.IsFalse(fail.VisibleWhenPassing); Assert.AreEqual("Fail", fail.Name); StoryboardLayer pass = storyboard.Layers.FirstOrDefault(l => l.Depth == 1); Assert.IsNotNull(pass); Assert.AreEqual(0, pass.Elements.Count); - Assert.IsFalse(pass.EnabledWhenFailing); - Assert.IsTrue(pass.EnabledWhenPassing); + Assert.IsFalse(pass.VisibleWhenFailing); + Assert.IsTrue(pass.VisibleWhenPassing); Assert.AreEqual("Pass", pass.Name); StoryboardLayer foreground = storyboard.Layers.FirstOrDefault(l => l.Depth == 0); Assert.IsNotNull(foreground); Assert.AreEqual(151, foreground.Elements.Count); - Assert.IsTrue(foreground.EnabledWhenFailing); - Assert.IsTrue(foreground.EnabledWhenPassing); + Assert.IsTrue(foreground.VisibleWhenFailing); + Assert.IsTrue(foreground.VisibleWhenPassing); Assert.AreEqual("Foreground", foreground.Name); + StoryboardLayer overlay = storyboard.Layers.FirstOrDefault(l => l.Depth == int.MinValue); + Assert.IsNotNull(overlay); + Assert.IsEmpty(overlay.Elements); + Assert.IsTrue(overlay.VisibleWhenFailing); + Assert.IsTrue(overlay.VisibleWhenPassing); + Assert.AreEqual("Overlay", overlay.Name); + int spriteCount = background.Elements.Count(x => x.GetType() == typeof(StoryboardSprite)); int animationCount = background.Elements.Count(x => x.GetType() == typeof(StoryboardAnimation)); - int sampleCount = background.Elements.Count(x => x.GetType() == typeof(StoryboardSample)); + int sampleCount = background.Elements.Count(x => x.GetType() == typeof(StoryboardSampleInfo)); Assert.AreEqual(15, spriteCount); Assert.AreEqual(1, animationCount); @@ -71,7 +78,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.AreEqual(new Vector2(320, 240), sprite.InitialPosition); Assert.IsTrue(sprite.IsDrawable); Assert.AreEqual(Anchor.Centre, sprite.Origin); - Assert.AreEqual("SB/black.jpg", sprite.Path); + Assert.AreEqual("SB/lyric/ja-21.png", sprite.Path); var animation = background.Elements.OfType().First(); Assert.NotNull(animation); @@ -94,12 +101,12 @@ namespace osu.Game.Tests.Beatmaps.Formats var decoder = new LegacyStoryboardDecoder(); using (var resStream = TestResources.OpenResource("variable-with-suffix.osb")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var storyboard = decoder.Decode(stream); StoryboardLayer background = storyboard.Layers.Single(l => l.Depth == 3); - Assert.AreEqual(123456, ((StoryboardSprite)background.Elements.Single()).InitialPosition.X); + Assert.AreEqual(3456, ((StoryboardSprite)background.Elements.Single()).InitialPosition.X); } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs index a725c58462..e97c83e2c2 100644 --- a/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/OsuJsonDecoderTest.cs @@ -8,9 +8,12 @@ using NUnit.Framework; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; +using osu.Game.IO; using osu.Game.IO.Serialization; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Scoring; using osu.Game.Tests.Resources; using osuTK; @@ -90,15 +93,48 @@ namespace osu.Game.Tests.Beatmaps.Formats } [Test] - public void TestDecodeHitObjects() + public void TestDecodePostConverted() { - var beatmap = decodeAsJson(normal); + var converted = new OsuBeatmapConverter(decodeAsJson(normal), new OsuRuleset()).Convert(); - var curveData = beatmap.HitObjects[0] as IHasCurve; + var processor = new OsuBeatmapProcessor(converted); + + processor.PreProcess(); + foreach (var o in converted.HitObjects) + o.ApplyDefaults(converted.ControlPointInfo, converted.BeatmapInfo.BaseDifficulty); + processor.PostProcess(); + + var beatmap = converted.Serialize().Deserialize(); + + var curveData = beatmap.HitObjects[0] as IHasPathWithRepeats; var positionData = beatmap.HitObjects[0] as IHasPosition; Assert.IsNotNull(positionData); Assert.IsNotNull(curveData); + Assert.AreEqual(90, curveData.Path.Distance); + Assert.AreEqual(new Vector2(192, 168), positionData.Position); + Assert.AreEqual(956, beatmap.HitObjects[0].StartTime); + Assert.IsTrue(beatmap.HitObjects[0].Samples.Any(s => s.Name == HitSampleInfo.HIT_NORMAL)); + + positionData = beatmap.HitObjects[1] as IHasPosition; + + Assert.IsNotNull(positionData); + Assert.AreEqual(new Vector2(304, 56), positionData.Position); + Assert.AreEqual(1285, beatmap.HitObjects[1].StartTime); + Assert.IsTrue(beatmap.HitObjects[1].Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP)); + } + + [Test] + public void TestDecodeHitObjects() + { + var beatmap = decodeAsJson(normal); + + var curveData = beatmap.HitObjects[0] as IHasPathWithRepeats; + var positionData = beatmap.HitObjects[0] as IHasPosition; + + Assert.IsNotNull(positionData); + Assert.IsNotNull(curveData); + Assert.AreEqual(90, curveData.Path.Distance); Assert.AreEqual(new Vector2(192, 168), positionData.Position); Assert.AreEqual(956, beatmap.HitObjects[0].StartTime); Assert.IsTrue(beatmap.HitObjects[0].Samples.Any(s => s.Name == HitSampleInfo.HIT_NORMAL)); @@ -126,6 +162,31 @@ namespace osu.Game.Tests.Beatmaps.Formats .Assert(); } + [Test] + public void TestGetJsonDecoder() + { + Decoder decoder; + + using (var stream = TestResources.OpenResource(normal)) + using (var sr = new LineBufferedReader(stream)) + { + var legacyDecoded = new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(sr); + + using (var memStream = new MemoryStream()) + using (var memWriter = new StreamWriter(memStream)) + using (var memReader = new LineBufferedReader(memStream)) + { + memWriter.Write(legacyDecoded.Serialize()); + memWriter.Flush(); + + memStream.Position = 0; + decoder = Decoder.GetDecoder(memReader); + } + } + + Assert.IsInstanceOf(typeof(JsonBeatmapDecoder), decoder); + } + /// /// Reads a .osu file first with a , serializes the resulting to JSON /// and then deserializes the result back into a through an . @@ -148,13 +209,13 @@ namespace osu.Game.Tests.Beatmaps.Formats private Beatmap decode(string filename, out Beatmap jsonDecoded) { using (var stream = TestResources.OpenResource(filename)) - using (var sr = new StreamReader(stream)) + using (var sr = new LineBufferedReader(stream)) { var legacyDecoded = new LegacyBeatmapDecoder { ApplyOffsets = false }.Decode(sr); using (var ms = new MemoryStream()) using (var sw = new StreamWriter(ms)) - using (var sr2 = new StreamReader(ms)) + using (var sr2 = new LineBufferedReader(ms)) { sw.Write(legacyDecoded.Serialize()); sw.Flush(); diff --git a/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs b/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs index b3863bcf44..669acc3202 100644 --- a/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/ParsingTest.cs @@ -59,7 +59,7 @@ namespace osu.Game.Tests.Beatmaps.Formats { try { - var _ = int.Parse(input); + _ = int.Parse(input); } catch (Exception e) { diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs index ad0ed00989..b941313103 100644 --- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs @@ -11,26 +11,34 @@ using NUnit.Framework; using osu.Framework.Platform; using osu.Game.IPC; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.IO; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Tests.Resources; +using osu.Game.Users; +using SharpCompress.Archives; using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Writers.Zip; +using FileInfo = System.IO.FileInfo; namespace osu.Game.Tests.Beatmaps.IO { [TestFixture] - public class ImportBeatmapTest + public class ImportBeatmapTest : ImportTest { [Test] public async Task TestImportWhenClosed() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportWhenClosed")) + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) { try { - await LoadOszIntoOsu(loadOsu(host)); + await LoadOszIntoOsu(LoadOsuIntoHost(host)); } finally { @@ -42,12 +50,12 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportThenDelete() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportThenDelete")) + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) { try { - var osu = loadOsu(host); + var osu = LoadOsuIntoHost(host); var imported = await LoadOszIntoOsu(osu); @@ -63,12 +71,12 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportThenImport() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportThenImport")) + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) { try { - var osu = loadOsu(host); + var osu = LoadOsuIntoHost(host); var imported = await LoadOszIntoOsu(osu); var importedSecondTime = await LoadOszIntoOsu(osu); @@ -87,11 +95,213 @@ namespace osu.Game.Tests.Beatmaps.IO } } + [Test] + public async Task TestImportThenImportWithReZip() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoOsu(osu); + + string hashBefore = hashFile(temp); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + // zip files differ because different compression or encoder. + Assert.AreNotEqual(hashBefore, hashFile(temp)); + + var importedSecondTime = await osu.Dependencies.Get().Import(temp); + + ensureLoaded(osu); + + // but contents doesn't, so existing should still be used. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + } + finally + { + Directory.Delete(extractedFolder, true); + } + } + finally + { + host.Exit(); + } + } + } + + private string hashFile(string filename) + { + using (var s = File.OpenRead(filename)) + return s.ComputeMD5Hash(); + } + + [Test] + public async Task TestImportThenImportWithChangedFile() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoOsu(osu); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // arbitrary write to non-hashed file + using (var sw = new FileInfo(Directory.GetFiles(extractedFolder, "*.mp3").First()).AppendText()) + await sw.WriteLineAsync("text"); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await osu.Dependencies.Get().Import(temp); + + ensureLoaded(osu); + + // check the newly "imported" beatmap is not the original. + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Beatmaps.First().ID); + } + finally + { + Directory.Delete(extractedFolder, true); + } + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportThenImportWithDifferentFilename() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoOsu(osu); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // change filename + var firstFile = new FileInfo(Directory.GetFiles(extractedFolder).First()); + firstFile.MoveTo(Path.Combine(firstFile.DirectoryName, $"{firstFile.Name}-changed{firstFile.Extension}")); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await osu.Dependencies.Get().Import(temp); + + ensureLoaded(osu); + + // check the newly "imported" beatmap is not the original. + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Beatmaps.First().ID); + } + finally + { + Directory.Delete(extractedFolder, true); + } + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportCorruptThenImport() + { + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var imported = await LoadOszIntoOsu(osu); + + var firstFile = imported.Files.First(); + + var files = osu.Dependencies.Get(); + + long originalLength; + using (var stream = files.Storage.GetStream(firstFile.FileInfo.StoragePath)) + originalLength = stream.Length; + + using (var stream = files.Storage.GetStream(firstFile.FileInfo.StoragePath, FileAccess.Write, FileMode.Create)) + stream.WriteByte(0); + + var importedSecondTime = await LoadOszIntoOsu(osu); + + using (var stream = files.Storage.GetStream(firstFile.FileInfo.StoragePath)) + Assert.AreEqual(stream.Length, originalLength, "Corruption was not fixed on second import"); + + // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + + checkBeatmapSetCount(osu, 1); + checkSingleReferencedFileCount(osu, 18); + } + finally + { + host.Exit(); + } + } + } + [Test] public async Task TestRollbackOnFailure() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestRollbackOnFailure")) + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) { try { @@ -104,12 +314,12 @@ namespace osu.Game.Tests.Beatmaps.IO Interlocked.Increment(ref loggedExceptionCount); }; - var osu = loadOsu(host); + var osu = LoadOsuIntoHost(host); var manager = osu.Dependencies.Get(); // ReSharper disable once AccessToModifiedClosure - manager.ItemAdded += _ => Interlocked.Increment(ref itemAddRemoveFireCount); - manager.ItemRemoved += _ => Interlocked.Increment(ref itemAddRemoveFireCount); + manager.ItemUpdated.BindValueChanged(_ => Interlocked.Increment(ref itemAddRemoveFireCount)); + manager.ItemRemoved.BindValueChanged(_ => Interlocked.Increment(ref itemAddRemoveFireCount)); var imported = await LoadOszIntoOsu(osu); @@ -118,7 +328,7 @@ namespace osu.Game.Tests.Beatmaps.IO imported.Hash += "-changed"; manager.Update(imported); - Assert.AreEqual(0, itemAddRemoveFireCount -= 2); + Assert.AreEqual(0, itemAddRemoveFireCount -= 1); checkBeatmapSetCount(osu, 1); checkBeatmapCount(osu, 12); @@ -126,8 +336,8 @@ namespace osu.Game.Tests.Beatmaps.IO var breakTemp = TestResources.GetTestBeatmapForImport(); - MemoryStream brokenOsu = new MemoryStream(new byte[] { 1, 3, 3, 7 }); - MemoryStream brokenOsz = new MemoryStream(File.ReadAllBytes(breakTemp)); + MemoryStream brokenOsu = new MemoryStream(); + MemoryStream brokenOsz = new MemoryStream(await File.ReadAllBytesAsync(breakTemp)); File.Delete(breakTemp); @@ -135,7 +345,7 @@ namespace osu.Game.Tests.Beatmaps.IO using (var zip = ZipArchive.Open(brokenOsz)) { zip.AddEntry("broken.osu", brokenOsu, false); - zip.SaveTo(outStream, SharpCompress.Common.CompressionType.Deflate); + zip.SaveTo(outStream, CompressionType.Deflate); } // this will trigger purging of the existing beatmap (online set id match) but should rollback due to broken osu. @@ -164,46 +374,15 @@ namespace osu.Game.Tests.Beatmaps.IO } } - [Test] - public async Task TestImportThenImportDifferentHash() - { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportThenImportDifferentHash")) - { - try - { - var osu = loadOsu(host); - var manager = osu.Dependencies.Get(); - - var imported = await LoadOszIntoOsu(osu); - - imported.Hash += "-changed"; - manager.Update(imported); - - var importedSecondTime = await LoadOszIntoOsu(osu); - - Assert.IsTrue(imported.ID != importedSecondTime.ID); - Assert.IsTrue(imported.Beatmaps.First().ID < importedSecondTime.Beatmaps.First().ID); - - // only one beatmap will exist as the online set ID matched, causing purging of the first import. - checkBeatmapSetCount(osu, 1); - } - finally - { - host.Exit(); - } - } - } - [Test] public async Task TestImportThenDeleteThenImport() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportThenDeleteThenImport")) + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) { try { - var osu = loadOsu(host); + var osu = LoadOsuIntoHost(host); var imported = await LoadOszIntoOsu(osu); @@ -226,12 +405,12 @@ namespace osu.Game.Tests.Beatmaps.IO [TestCase(false)] public async Task TestImportThenDeleteThenImportWithOnlineIDMismatch(bool set) { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"TestImportThenDeleteThenImport-{set}")) + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}-{set}")) { try { - var osu = loadOsu(host); + var osu = LoadOsuIntoHost(host); var imported = await LoadOszIntoOsu(osu); @@ -260,12 +439,12 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportWithDuplicateBeatmapIDs() { - //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportWithDuplicateBeatmapID")) + // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) { try { - var osu = loadOsu(host); + var osu = LoadOsuIntoHost(host); var metadata = new BeatmapMetadata { @@ -317,15 +496,15 @@ namespace osu.Game.Tests.Beatmaps.IO [Ignore("Binding IPC on Appveyor isn't working (port in use). Need to figure out why")] public void TestImportOverIPC() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("host", true)) - using (HeadlessGameHost client = new CleanRunHeadlessGameHost("client", true)) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}-host", true)) + using (HeadlessGameHost client = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}-client", true)) { try { Assert.IsTrue(host.IsPrimaryInstance); Assert.IsFalse(client.IsPrimaryInstance); - var osu = loadOsu(host); + var osu = LoadOsuIntoHost(host); var temp = TestResources.GetTestBeatmapForImport(); @@ -347,11 +526,11 @@ namespace osu.Game.Tests.Beatmaps.IO [Test] public async Task TestImportWhenFileOpen() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportWhenFileOpen")) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) { try { - var osu = loadOsu(host); + var osu = LoadOsuIntoHost(host); var temp = TestResources.GetTestBeatmapForImport(); using (File.OpenRead(temp)) await osu.Dependencies.Get().Import(temp); @@ -366,21 +545,289 @@ namespace osu.Game.Tests.Beatmaps.IO } } - public static async Task LoadOszIntoOsu(OsuGameBase osu, string path = null) + [Test] + public async Task TestImportWithDuplicateHashes() { - var temp = path ?? TestResources.GetTestBeatmapForImport(); + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.AddEntry("duplicate.osu", Directory.GetFiles(extractedFolder, "*.osu").First()); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + await osu.Dependencies.Get().Import(temp); + + ensureLoaded(osu); + } + finally + { + Directory.Delete(extractedFolder, true); + } + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportNestedStructure() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + string subfolder = Path.Combine(extractedFolder, "subfolder"); + + Directory.CreateDirectory(subfolder); + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(subfolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var imported = await osu.Dependencies.Get().Import(temp); + + ensureLoaded(osu); + + Assert.IsFalse(imported.Files.Any(f => f.Filename.Contains("subfolder")), "Files contain common subfolder"); + } + finally + { + Directory.Delete(extractedFolder, true); + } + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportWithIgnoredDirectoryInArchive() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + string dataFolder = Path.Combine(extractedFolder, "actual_data"); + string resourceForkFolder = Path.Combine(extractedFolder, "__MACOSX"); + string resourceForkFilePath = Path.Combine(resourceForkFolder, ".extracted"); + + Directory.CreateDirectory(dataFolder); + Directory.CreateDirectory(resourceForkFolder); + + using (var resourceForkFile = File.CreateText(resourceForkFilePath)) + { + await resourceForkFile.WriteLineAsync("adding content so that it's not empty"); + } + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(dataFolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var imported = await osu.Dependencies.Get().Import(temp); + + ensureLoaded(osu); + + Assert.IsFalse(imported.Files.Any(f => f.Filename.Contains("__MACOSX")), "Files contain resource fork folder, which should be ignored"); + Assert.IsFalse(imported.Files.Any(f => f.Filename.Contains("actual_data")), "Files contain common subfolder"); + } + finally + { + Directory.Delete(extractedFolder, true); + } + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestUpdateBeatmapInfo() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + var manager = osu.Dependencies.Get(); + + var temp = TestResources.GetTestBeatmapForImport(); + await osu.Dependencies.Get().Import(temp); + + // Update via the beatmap, not the beatmap info, to ensure correct linking + BeatmapSetInfo setToUpdate = manager.GetAllUsableBeatmapSets()[0]; + Beatmap beatmapToUpdate = (Beatmap)manager.GetWorkingBeatmap(setToUpdate.Beatmaps.First(b => b.RulesetID == 0)).Beatmap; + beatmapToUpdate.BeatmapInfo.Version = "updated"; + + manager.Update(setToUpdate); + + BeatmapInfo updatedInfo = manager.QueryBeatmap(b => b.ID == beatmapToUpdate.BeatmapInfo.ID); + Assert.That(updatedInfo.Version, Is.EqualTo("updated")); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestUpdateBeatmapFile() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + var manager = osu.Dependencies.Get(); + + var temp = TestResources.GetTestBeatmapForImport(); + await osu.Dependencies.Get().Import(temp); + + BeatmapSetInfo setToUpdate = manager.GetAllUsableBeatmapSets()[0]; + + var beatmapInfo = setToUpdate.Beatmaps.First(b => b.RulesetID == 0); + Beatmap beatmapToUpdate = (Beatmap)manager.GetWorkingBeatmap(setToUpdate.Beatmaps.First(b => b.RulesetID == 0)).Beatmap; + BeatmapSetFileInfo fileToUpdate = setToUpdate.Files.First(f => beatmapToUpdate.BeatmapInfo.Path.Contains(f.Filename)); + + string oldMd5Hash = beatmapToUpdate.BeatmapInfo.MD5Hash; + + beatmapToUpdate.HitObjects.Clear(); + beatmapToUpdate.HitObjects.Add(new HitCircle { StartTime = 5000 }); + + manager.Save(beatmapInfo, beatmapToUpdate); + + // Check that the old file reference has been removed + Assert.That(manager.QueryBeatmapSet(s => s.ID == setToUpdate.ID).Files.All(f => f.ID != fileToUpdate.ID)); + + // Check that the new file is referenced correctly by attempting a retrieval + Beatmap updatedBeatmap = (Beatmap)manager.GetWorkingBeatmap(manager.QueryBeatmap(b => b.ID == beatmapToUpdate.BeatmapInfo.ID)).Beatmap; + Assert.That(updatedBeatmap.HitObjects.Count, Is.EqualTo(1)); + Assert.That(updatedBeatmap.HitObjects[0].StartTime, Is.EqualTo(5000)); + Assert.That(updatedBeatmap.BeatmapInfo.MD5Hash, Is.Not.EqualTo(oldMd5Hash)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestCreateNewEmptyBeatmap() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + var manager = osu.Dependencies.Get(); + + var working = manager.CreateNew(new OsuRuleset().RulesetInfo, User.SYSTEM_USER); + + manager.Save(working.BeatmapInfo, working.Beatmap); + + var retrievedSet = manager.GetAllUsableBeatmapSets()[0]; + + // Check that the new file is referenced correctly by attempting a retrieval + Beatmap updatedBeatmap = (Beatmap)manager.GetWorkingBeatmap(retrievedSet.Beatmaps[0]).Beatmap; + Assert.That(updatedBeatmap.HitObjects.Count, Is.EqualTo(0)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestCreateNewBeatmapWithObject() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + var manager = osu.Dependencies.Get(); + + var working = manager.CreateNew(new OsuRuleset().RulesetInfo, User.SYSTEM_USER); + + ((Beatmap)working.Beatmap).HitObjects.Add(new HitCircle { StartTime = 5000 }); + + manager.Save(working.BeatmapInfo, working.Beatmap); + + var retrievedSet = manager.GetAllUsableBeatmapSets()[0]; + + // Check that the new file is referenced correctly by attempting a retrieval + Beatmap updatedBeatmap = (Beatmap)manager.GetWorkingBeatmap(retrievedSet.Beatmaps[0]).Beatmap; + Assert.That(updatedBeatmap.HitObjects.Count, Is.EqualTo(1)); + Assert.That(updatedBeatmap.HitObjects[0].StartTime, Is.EqualTo(5000)); + } + finally + { + host.Exit(); + } + } + } + + public static async Task LoadOszIntoOsu(OsuGameBase osu, string path = null, bool virtualTrack = false) + { + var temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack); var manager = osu.Dependencies.Get(); - await manager.Import(temp); - - var imported = manager.GetAllUsableBeatmapSets(); + var importedSet = await manager.Import(temp); ensureLoaded(osu); waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); - return imported.LastOrDefault(); + return manager.GetAllUsableBeatmapSets().Find(beatmapSet => beatmapSet.ID == importedSet.ID); } private void deleteBeatmapSet(BeatmapSetInfo imported, OsuGameBase osu) @@ -414,14 +861,6 @@ namespace osu.Game.Tests.Beatmaps.IO Assert.AreEqual(expected, osu.Dependencies.Get().QueryFiles(f => f.ReferenceCount == 1).Count()); } - private OsuGameBase loadOsu(GameHost host) - { - var osu = new OsuGameBase(); - Task.Run(() => host.Run(osu)); - waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); - return osu; - } - private static void ensureLoaded(OsuGameBase osu, int timeout = 60000) { IEnumerable resultSets = null; @@ -429,12 +868,12 @@ namespace osu.Game.Tests.Beatmaps.IO waitForOrAssert(() => (resultSets = store.QueryBeatmapSets(s => s.OnlineBeatmapSetID == 241526)).Any(), @"BeatmapSet did not import to the database in allocated time.", timeout); - //ensure we were stored to beatmap database backing... + // ensure we were stored to beatmap database backing... Assert.IsTrue(resultSets.Count() == 1, $@"Incorrect result count found ({resultSets.Count()} but should be 1)."); IEnumerable queryBeatmaps() => store.QueryBeatmaps(s => s.BeatmapSet.OnlineBeatmapSetID == 241526 && s.BaseDifficultyID > 0); IEnumerable queryBeatmapSets() => store.QueryBeatmapSets(s => s.OnlineBeatmapSetID == 241526); - //if we don't re-check here, the set will be inserted but the beatmaps won't be present yet. + // if we don't re-check here, the set will be inserted but the beatmaps won't be present yet. waitForOrAssert(() => queryBeatmaps().Count() == 12, @"Beatmaps did not import to the database in allocated time", timeout); waitForOrAssert(() => queryBeatmapSets().Count() == 1, diff --git a/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs b/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs new file mode 100644 index 0000000000..25517ad615 --- /dev/null +++ b/osu.Game.Tests/Beatmaps/IO/LineBufferedReaderTest.cs @@ -0,0 +1,125 @@ +// 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.Text; +using NUnit.Framework; +using osu.Game.IO; + +namespace osu.Game.Tests.Beatmaps.IO +{ + [TestFixture] + public class LineBufferedReaderTest + { + [Test] + public void TestReadLineByLine() + { + const string contents = "line 1\rline 2\nline 3"; + + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) + using (var bufferedReader = new LineBufferedReader(stream)) + { + Assert.AreEqual("line 1", bufferedReader.ReadLine()); + Assert.AreEqual("line 2", bufferedReader.ReadLine()); + Assert.AreEqual("line 3", bufferedReader.ReadLine()); + Assert.IsNull(bufferedReader.ReadLine()); + } + } + + [Test] + public void TestPeekLineOnce() + { + const string contents = "line 1\r\npeek this\nline 3"; + + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) + using (var bufferedReader = new LineBufferedReader(stream)) + { + Assert.AreEqual("line 1", bufferedReader.ReadLine()); + Assert.AreEqual("peek this", bufferedReader.PeekLine()); + Assert.AreEqual("peek this", bufferedReader.ReadLine()); + Assert.AreEqual("line 3", bufferedReader.ReadLine()); + Assert.IsNull(bufferedReader.ReadLine()); + } + } + + [Test] + public void TestPeekLineMultipleTimes() + { + const string contents = "peek this once\nline 2\rpeek this a lot"; + + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) + using (var bufferedReader = new LineBufferedReader(stream)) + { + Assert.AreEqual("peek this once", bufferedReader.PeekLine()); + Assert.AreEqual("peek this once", bufferedReader.ReadLine()); + Assert.AreEqual("line 2", bufferedReader.ReadLine()); + Assert.AreEqual("peek this a lot", bufferedReader.PeekLine()); + Assert.AreEqual("peek this a lot", bufferedReader.PeekLine()); + Assert.AreEqual("peek this a lot", bufferedReader.PeekLine()); + Assert.AreEqual("peek this a lot", bufferedReader.ReadLine()); + Assert.IsNull(bufferedReader.ReadLine()); + } + } + + [Test] + public void TestPeekLineAtEndOfStream() + { + const string contents = "first line\r\nsecond line"; + + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) + using (var bufferedReader = new LineBufferedReader(stream)) + { + Assert.AreEqual("first line", bufferedReader.ReadLine()); + Assert.AreEqual("second line", bufferedReader.ReadLine()); + Assert.IsNull(bufferedReader.PeekLine()); + Assert.IsNull(bufferedReader.ReadLine()); + Assert.IsNull(bufferedReader.PeekLine()); + } + } + + [Test] + public void TestPeekReadLineOnEmptyStream() + { + using (var stream = new MemoryStream()) + using (var bufferedReader = new LineBufferedReader(stream)) + { + Assert.IsNull(bufferedReader.PeekLine()); + Assert.IsNull(bufferedReader.ReadLine()); + Assert.IsNull(bufferedReader.ReadLine()); + Assert.IsNull(bufferedReader.PeekLine()); + } + } + + [Test] + public void TestReadToEndNoPeeks() + { + const string contents = "first line\r\nsecond line"; + + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) + using (var bufferedReader = new LineBufferedReader(stream)) + { + Assert.AreEqual(contents, bufferedReader.ReadToEnd()); + } + } + + [Test] + public void TestReadToEndAfterReadsAndPeeks() + { + const string contents = "this line is gone\rthis one shouldn't be\r\nthese ones\ndefinitely not"; + + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(contents))) + using (var bufferedReader = new LineBufferedReader(stream)) + { + Assert.AreEqual("this line is gone", bufferedReader.ReadLine()); + Assert.AreEqual("this one shouldn't be", bufferedReader.PeekLine()); + + var endingLines = bufferedReader.ReadToEnd().Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + Assert.AreEqual(3, endingLines.Length); + Assert.AreEqual("this one shouldn't be", endingLines[0]); + Assert.AreEqual("these ones", endingLines[1]); + Assert.AreEqual("definitely not", endingLines[2]); + } + } + } +} diff --git a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs index 37e0565df0..022b2c1a59 100644 --- a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Tests.Resources; using osu.Game.Beatmaps.Formats; +using osu.Game.IO; using osu.Game.IO.Archives; namespace osu.Game.Tests.Beatmaps.IO @@ -50,7 +51,7 @@ namespace osu.Game.Tests.Beatmaps.IO Beatmap beatmap; - using (var stream = new StreamReader(reader.GetStream("Soleily - Renatus (Deif) [Platter].osu"))) + using (var stream = new LineBufferedReader(reader.GetStream("Soleily - Renatus (Deif) [Platter].osu"))) beatmap = Decoder.GetDecoder(stream).Decode(stream); var meta = beatmap.Metadata; diff --git a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs new file mode 100644 index 0000000000..6c8133660f --- /dev/null +++ b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs @@ -0,0 +1,116 @@ +// 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.Game.Rulesets.Objects; + +namespace osu.Game.Tests.Beatmaps +{ + [TestFixture] + public class SliderEventGenerationTest + { + private const double start_time = 0; + private const double span_duration = 1000; + + [Test] + public void TestSingleSpan() + { + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, null, default).ToArray(); + + Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); + Assert.That(events[0].Time, Is.EqualTo(start_time)); + + Assert.That(events[1].Type, Is.EqualTo(SliderEventType.Tick)); + Assert.That(events[1].Time, Is.EqualTo(span_duration / 2)); + + Assert.That(events[3].Type, Is.EqualTo(SliderEventType.Tail)); + Assert.That(events[3].Time, Is.EqualTo(span_duration)); + } + + [Test] + public void TestRepeat() + { + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2, null, default).ToArray(); + + Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); + Assert.That(events[0].Time, Is.EqualTo(start_time)); + + Assert.That(events[1].Type, Is.EqualTo(SliderEventType.Tick)); + Assert.That(events[1].Time, Is.EqualTo(span_duration / 2)); + + Assert.That(events[2].Type, Is.EqualTo(SliderEventType.Repeat)); + Assert.That(events[2].Time, Is.EqualTo(span_duration)); + + Assert.That(events[3].Type, Is.EqualTo(SliderEventType.Tick)); + Assert.That(events[3].Time, Is.EqualTo(span_duration + span_duration / 2)); + + Assert.That(events[5].Type, Is.EqualTo(SliderEventType.Tail)); + Assert.That(events[5].Time, Is.EqualTo(2 * span_duration)); + } + + [Test] + public void TestNonEvenTicks() + { + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2, null, default).ToArray(); + + Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); + Assert.That(events[0].Time, Is.EqualTo(start_time)); + + Assert.That(events[1].Type, Is.EqualTo(SliderEventType.Tick)); + Assert.That(events[1].Time, Is.EqualTo(300)); + + Assert.That(events[2].Type, Is.EqualTo(SliderEventType.Tick)); + Assert.That(events[2].Time, Is.EqualTo(600)); + + Assert.That(events[3].Type, Is.EqualTo(SliderEventType.Tick)); + Assert.That(events[3].Time, Is.EqualTo(900)); + + Assert.That(events[4].Type, Is.EqualTo(SliderEventType.Repeat)); + Assert.That(events[4].Time, Is.EqualTo(span_duration)); + + Assert.That(events[5].Type, Is.EqualTo(SliderEventType.Tick)); + Assert.That(events[5].Time, Is.EqualTo(1100)); + + Assert.That(events[6].Type, Is.EqualTo(SliderEventType.Tick)); + Assert.That(events[6].Time, Is.EqualTo(1400)); + + Assert.That(events[7].Type, Is.EqualTo(SliderEventType.Tick)); + Assert.That(events[7].Time, Is.EqualTo(1700)); + + Assert.That(events[9].Type, Is.EqualTo(SliderEventType.Tail)); + Assert.That(events[9].Time, Is.EqualTo(2 * span_duration)); + } + + [Test] + public void TestLegacyLastTickOffset() + { + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, 100, default).ToArray(); + + Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LegacyLastTick)); + Assert.That(events[2].Time, Is.EqualTo(900)); + } + + [Test] + public void TestMinimumTickDistance() + { + const double velocity = 5; + const double min_distance = velocity * 10; + + var events = SliderEventGenerator.Generate(start_time, span_duration, velocity, velocity, span_duration, 2, 0, default).ToArray(); + + Assert.Multiple(() => + { + int tickIndex = -1; + + while (++tickIndex < events.Length) + { + if (events[tickIndex].Type != SliderEventType.Tick) + continue; + + Assert.That(events[tickIndex].Time, Is.LessThan(span_duration - min_distance).Or.GreaterThan(span_duration + min_distance)); + } + }); + } + } +} diff --git a/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs b/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs new file mode 100644 index 0000000000..bf5b517603 --- /dev/null +++ b/osu.Game.Tests/Beatmaps/TestSceneEditorBeatmap.cs @@ -0,0 +1,238 @@ +// 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.Testing; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Beatmaps +{ + [HeadlessTest] + public class TestSceneEditorBeatmap : EditorClockTestScene + { + /// + /// Tests that the addition event is correctly invoked after a hitobject is added. + /// + [Test] + public void TestHitObjectAddEvent() + { + var hitCircle = new HitCircle(); + + HitObject addedObject = null; + EditorBeatmap editorBeatmap = null; + + AddStep("add beatmap", () => + { + Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + editorBeatmap.HitObjectAdded += h => addedObject = h; + }); + + AddStep("add hitobject", () => editorBeatmap.Add(hitCircle)); + AddAssert("received add event", () => addedObject == hitCircle); + } + + /// + /// Tests that the removal event is correctly invoked after a hitobject is removed. + /// + [Test] + public void HitObjectRemoveEvent() + { + var hitCircle = new HitCircle(); + HitObject removedObject = null; + EditorBeatmap editorBeatmap = null; + AddStep("add beatmap", () => + { + Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap { HitObjects = { hitCircle } }); + editorBeatmap.HitObjectRemoved += h => removedObject = h; + }); + AddStep("remove hitobject", () => editorBeatmap.Remove(editorBeatmap.HitObjects.First())); + AddAssert("received remove event", () => removedObject == hitCircle); + } + + /// + /// Tests that the changed event is correctly invoked after the start time of a hitobject is changed. + /// This tests for hitobjects which were already present before the editor beatmap was constructed. + /// + [Test] + public void TestInitialHitObjectStartTimeChangeEvent() + { + var hitCircle = new HitCircle(); + + HitObject changedObject = null; + + AddStep("add beatmap", () => + { + EditorBeatmap editorBeatmap; + + Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap { HitObjects = { hitCircle } }); + editorBeatmap.HitObjectUpdated += h => changedObject = h; + }); + + AddStep("change start time", () => hitCircle.StartTime = 1000); + AddAssert("received change event", () => changedObject == hitCircle); + } + + /// + /// Tests that the changed event is correctly invoked after the start time of a hitobject is changed. + /// This tests for hitobjects which were added to an existing editor beatmap. + /// + [Test] + public void TestAddedHitObjectStartTimeChangeEvent() + { + EditorBeatmap editorBeatmap = null; + HitObject changedObject = null; + + AddStep("add beatmap", () => + { + Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + editorBeatmap.HitObjectUpdated += h => changedObject = h; + }); + + var hitCircle = new HitCircle(); + + AddStep("add object", () => editorBeatmap.Add(hitCircle)); + AddAssert("event not received", () => changedObject == null); + + AddStep("change start time", () => hitCircle.StartTime = 1000); + AddAssert("event received", () => changedObject == hitCircle); + } + + /// + /// Tests that the channged event is not invoked after a hitobject is removed from the beatmap/ + /// + [Test] + public void TestRemovedHitObjectStartTimeChangeEvent() + { + var hitCircle = new HitCircle(); + var editorBeatmap = new EditorBeatmap(new OsuBeatmap { HitObjects = { hitCircle } }); + + HitObject changedObject = null; + editorBeatmap.HitObjectUpdated += h => changedObject = h; + + editorBeatmap.Remove(hitCircle); + Assert.That(changedObject, Is.Null); + + hitCircle.StartTime = 1000; + Assert.That(changedObject, Is.Null); + } + + /// + /// Tests that an added hitobject is correctly inserted to preserve the sorting order of the beatmap. + /// + [Test] + public void TestAddHitObjectInMiddle() + { + var editorBeatmap = new EditorBeatmap(new OsuBeatmap + { + HitObjects = + { + new HitCircle(), + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 2000 }, + } + }); + + var hitCircle = new HitCircle { StartTime = 1000 }; + editorBeatmap.Add(hitCircle); + Assert.That(editorBeatmap.HitObjects.Count(h => h == hitCircle), Is.EqualTo(1)); + Assert.That(Array.IndexOf(editorBeatmap.HitObjects.ToArray(), hitCircle), Is.EqualTo(3)); + } + + /// + /// Tests that the beatmap remains correctly sorted after the start time of a hitobject is changed. + /// + [Test] + public void TestResortWhenStartTimeChanged() + { + var hitCircle = new HitCircle { StartTime = 1000 }; + + var editorBeatmap = new EditorBeatmap(new OsuBeatmap + { + HitObjects = + { + new HitCircle(), + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1000 }, + hitCircle, + new HitCircle { StartTime = 2000 }, + } + }); + + hitCircle.StartTime = 0; + Assert.That(editorBeatmap.HitObjects.Count(h => h == hitCircle), Is.EqualTo(1)); + Assert.That(Array.IndexOf(editorBeatmap.HitObjects.ToArray(), hitCircle), Is.EqualTo(1)); + } + + /// + /// Tests that multiple hitobjects are updated simultaneously. + /// + [Test] + public void TestMultipleHitObjectUpdate() + { + var updatedObjects = new List(); + var allHitObjects = new List(); + EditorBeatmap editorBeatmap = null; + + AddStep("add beatmap", () => + { + updatedObjects.Clear(); + + Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + + for (int i = 0; i < 10; i++) + { + var h = new HitCircle(); + editorBeatmap.Add(h); + allHitObjects.Add(h); + } + }); + + AddStep("change all start times", () => + { + editorBeatmap.HitObjectUpdated += h => updatedObjects.Add(h); + + for (int i = 0; i < 10; i++) + allHitObjects[i].StartTime += 10; + }); + + // Distinct ensures that all hitobjects have been updated once, debounce is tested below. + AddAssert("all hitobjects updated", () => updatedObjects.Distinct().Count() == 10); + } + + /// + /// Tests that hitobject updates are debounced when they happen too soon. + /// + [Test] + public void TestDebouncedUpdate() + { + var updatedObjects = new List(); + EditorBeatmap editorBeatmap = null; + + AddStep("add beatmap", () => + { + updatedObjects.Clear(); + + Child = editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + editorBeatmap.Add(new HitCircle()); + }); + + AddStep("change start time twice", () => + { + editorBeatmap.HitObjectUpdated += h => updatedObjects.Add(h); + + editorBeatmap.HitObjects[0].StartTime = 10; + editorBeatmap.HitObjects[0].StartTime = 20; + }); + + AddAssert("only updated once", () => updatedObjects.Count == 1); + } + } +} diff --git a/osu.Game.Tests/Beatmaps/ToStringFormattingTest.cs b/osu.Game.Tests/Beatmaps/ToStringFormattingTest.cs new file mode 100644 index 0000000000..c477bbd9cf --- /dev/null +++ b/osu.Game.Tests/Beatmaps/ToStringFormattingTest.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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Users; + +namespace osu.Game.Tests.Beatmaps +{ + [TestFixture] + public class ToStringFormattingTest + { + [Test] + public void TestArtistTitle() + { + var beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Artist = "artist", + Title = "title" + } + }; + + Assert.That(beatmap.ToString(), Is.EqualTo("artist - title")); + } + + [Test] + public void TestArtistTitleCreator() + { + var beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Artist = "artist", + Title = "title", + Author = new User { Username = "creator" } + } + }; + + Assert.That(beatmap.ToString(), Is.EqualTo("artist - title (creator)")); + } + + [Test] + public void TestArtistTitleCreatorDifficulty() + { + var beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Artist = "artist", + Title = "title", + Author = new User { Username = "creator" } + }, + Version = "difficulty" + }; + + Assert.That(beatmap.ToString(), Is.EqualTo("artist - title (creator) [difficulty]")); + } + } +} diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index 0d6ed67767..600c820ce1 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -119,6 +119,53 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(11, result.Links[0].Length); } + [Test] + public void TestOldFormatLinkWithBalancedBrackets() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a (tricky (one))[https://osu.ppy.sh]!" }); + + Assert.AreEqual("This is a tricky (one)!", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(12, result.Links[0].Length); + } + + [Test] + public void TestOldFormatLinkWithEscapedBrackets() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is (another loose bracket \\))[https://osu.ppy.sh]." }); + + Assert.AreEqual("This is another loose bracket ).", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(8, result.Links[0].Index); + Assert.AreEqual(23, result.Links[0].Length); + } + + [Test] + public void TestOldFormatWithBackslashes() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This link (should end with a backslash \\)[https://osu.ppy.sh]." }); + Assert.AreEqual("This link should end with a backslash \\.", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(29, result.Links[0].Length); + } + + [Test] + public void TestOldFormatLinkWithEscapedAndBalancedBrackets() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a (\\)super\\(\\( tricky (one))[https://osu.ppy.sh]!" }); + + Assert.AreEqual("This is a )super(( tricky (one)!", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(21, result.Links[0].Length); + } + [Test] public void TestNewFormatLink() { @@ -131,6 +178,42 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(11, result.Links[0].Length); } + [Test] + public void TestNewFormatLinkWithEscapedBrackets() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [https://osu.ppy.sh nasty link with escaped brackets: \\] and \\[]" }); + + Assert.AreEqual("This is a nasty link with escaped brackets: ] and [", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(41, result.Links[0].Length); + } + + [Test] + public void TestNewFormatLinkWithBackslashesInside() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [https://osu.ppy.sh link \\ with \\ backslashes \\]" }); + + Assert.AreEqual("This is a link \\ with \\ backslashes \\", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(27, result.Links[0].Length); + } + + [Test] + public void TestNewFormatLinkWithEscapedAndBalancedBrackets() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [https://osu.ppy.sh [link [with \\] too many brackets \\[ ]]]" }); + + Assert.AreEqual("This is a [link [with ] too many brackets [ ]]", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(36, result.Links[0].Length); + } + [Test] public void TestMarkdownFormatLink() { @@ -143,6 +226,143 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(11, result.Links[0].Length); } + [Test] + public void TestMarkdownFormatLinkWithBalancedBrackets() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [tricky [one]](https://osu.ppy.sh)!" }); + + Assert.AreEqual("This is a tricky [one]!", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(12, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatLinkWithEscapedBrackets() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is [another loose bracket \\]](https://osu.ppy.sh)." }); + + Assert.AreEqual("This is another loose bracket ].", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(8, result.Links[0].Index); + Assert.AreEqual(23, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatWithBackslashes() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This link [should end with a backslash \\](https://osu.ppy.sh)." }); + Assert.AreEqual("This link should end with a backslash \\.", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(29, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatLinkWithEscapedAndBalancedBrackets() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "This is a [\\]super\\[\\[ tricky [one]](https://osu.ppy.sh)!" }); + + Assert.AreEqual("This is a ]super[[ tricky [one]!", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(10, result.Links[0].Index); + Assert.AreEqual(21, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatLinkWithInlineTitle() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [this link format](https://osu.ppy.sh \"osu!\") before..." }); + + Assert.AreEqual("I haven't seen this link format before...", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(15, result.Links[0].Index); + Assert.AreEqual(16, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatLinkWithInlineTitleAndEscapedQuotes() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [this link format](https://osu.ppy.sh \"inner quote \\\" just to confuse \") before..." }); + + Assert.AreEqual("I haven't seen this link format before...", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(15, result.Links[0].Index); + Assert.AreEqual(16, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatLinkWithUrlInTextAndInlineTitle() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [https://osu.ppy.sh](https://osu.ppy.sh \"https://osu.ppy.sh\") before..." }); + + Assert.AreEqual("I haven't seen https://osu.ppy.sh before...", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(15, result.Links[0].Index); + Assert.AreEqual(18, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatLinkWithUrlAndTextInTitle() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [oh no, text here! https://osu.ppy.sh](https://osu.ppy.sh) before..." }); + + Assert.AreEqual("I haven't seen oh no, text here! https://osu.ppy.sh before...", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(15, result.Links[0].Index); + Assert.AreEqual(36, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatLinkWithMisleadingUrlInText() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "I haven't seen [https://google.com](https://osu.ppy.sh) before..." }); + + Assert.AreEqual("I haven't seen https://google.com before...", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://osu.ppy.sh", result.Links[0].Url); + Assert.AreEqual(15, result.Links[0].Index); + Assert.AreEqual(18, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatLinkThatContractsIntoLargerLink() + { + Message result = MessageFormatter.FormatMessage(new Message { Content = "super broken https://[osu.ppy](https://reddit.com).sh/" }); + + Assert.AreEqual("super broken https://osu.ppy.sh/", result.DisplayContent); + Assert.AreEqual(1, result.Links.Count); + Assert.AreEqual("https://reddit.com", result.Links[0].Url); + Assert.AreEqual(21, result.Links[0].Index); + Assert.AreEqual(7, result.Links[0].Length); + } + + [Test] + public void TestMarkdownFormatLinkDirectlyNextToRawLink() + { + // the raw link has a port at the end of it, so that the raw link regex terminates at the port and doesn't consume display text from the formatted one + Message result = MessageFormatter.FormatMessage(new Message { Content = "https://localhost:8080[https://osu.ppy.sh](https://osu.ppy.sh) should be two links" }); + + Assert.AreEqual("https://localhost:8080https://osu.ppy.sh should be two links", result.DisplayContent); + Assert.AreEqual(2, result.Links.Count); + + Assert.AreEqual("https://localhost:8080", result.Links[0].Url); + Assert.AreEqual(0, result.Links[0].Index); + Assert.AreEqual(22, result.Links[0].Length); + + Assert.AreEqual("https://osu.ppy.sh", result.Links[1].Url); + Assert.AreEqual(22, result.Links[1].Index); + Assert.AreEqual(18, result.Links[1].Length); + } + [Test] public void TestChannelLink() { @@ -208,22 +428,27 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(5, result.Links.Count); Link f = result.Links.Find(l => l.Url == "https://osu.ppy.sh/wiki/wiki links"); + Assert.That(f, Is.Not.Null); Assert.AreEqual(44, f.Index); Assert.AreEqual(10, f.Length); f = result.Links.Find(l => l.Url == "http://www.simple-test.com"); + Assert.That(f, Is.Not.Null); Assert.AreEqual(10, f.Index); Assert.AreEqual(11, f.Length); f = result.Links.Find(l => l.Url == "http://google.com"); + Assert.That(f, Is.Not.Null); Assert.AreEqual(97, f.Index); Assert.AreEqual(4, f.Length); f = result.Links.Find(l => l.Url == "https://osu.ppy.sh"); + Assert.That(f, Is.Not.Null); Assert.AreEqual(78, f.Index); Assert.AreEqual(18, f.Length); f = result.Links.Find(l => l.Url == "\uD83D\uDE12"); + Assert.That(f, Is.Not.Null); Assert.AreEqual(101, f.Index); Assert.AreEqual(3, f.Length); } diff --git a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs new file mode 100644 index 0000000000..a8ee1bcc2e --- /dev/null +++ b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.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.IO; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Platform; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Collections.IO +{ + [TestFixture] + public class ImportCollectionsTest : ImportTest + { + [Test] + public async Task TestImportEmptyDatabase() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host); + + await osu.CollectionManager.Import(new MemoryStream()); + + Assert.That(osu.CollectionManager.Collections.Count, Is.Zero); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportWithNoBeatmaps() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host); + + await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); + + Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + + Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); + Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.Zero); + + Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second")); + Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.Zero); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportWithBeatmaps() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host, true); + + await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); + + Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + + Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); + Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.EqualTo(1)); + + Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second")); + Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.EqualTo(12)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportMalformedDatabase() + { + bool exceptionThrown = false; + UnhandledExceptionEventHandler setException = (_, __) => exceptionThrown = true; + + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + AppDomain.CurrentDomain.UnhandledException += setException; + + var osu = LoadOsuIntoHost(host, true); + + using (var ms = new MemoryStream()) + { + using (var bw = new BinaryWriter(ms, Encoding.UTF8, true)) + { + for (int i = 0; i < 10000; i++) + bw.Write((byte)i); + } + + ms.Seek(0, SeekOrigin.Begin); + + await osu.CollectionManager.Import(ms); + } + + Assert.That(host.UpdateThread.Running, Is.True); + Assert.That(exceptionThrown, Is.False); + Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(0)); + } + finally + { + host.Exit(); + AppDomain.CurrentDomain.UnhandledException -= setException; + } + } + } + + [Test] + public async Task TestSaveAndReload() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host, true); + + await osu.CollectionManager.Import(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]); + osu.CollectionManager.Collections[1].Beatmaps.RemoveAt(0); + + // Rename the second collecction. + osu.CollectionManager.Collections[1].Name.Value = "Another"; + } + finally + { + host.Exit(); + } + } + + using (HeadlessGameHost host = new HeadlessGameHost("TestSaveAndReload")) + { + try + { + var osu = LoadOsuIntoHost(host, true); + + Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + + Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); + Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.EqualTo(2)); + + Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Another")); + Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.EqualTo(11)); + } + finally + { + host.Exit(); + } + } + } + } +} diff --git a/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs b/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs new file mode 100644 index 0000000000..b7a41ffd1c --- /dev/null +++ b/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs @@ -0,0 +1,145 @@ +// 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.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class EditorChangeHandlerTest + { + private int stateChangedFired; + + [SetUp] + public void SetUp() + { + stateChangedFired = 0; + } + + [Test] + public void TestSaveRestoreState() + { + var (handler, beatmap) = createChangeHandler(); + + Assert.That(handler.CanUndo.Value, Is.False); + Assert.That(handler.CanRedo.Value, Is.False); + + addArbitraryChange(beatmap); + handler.SaveState(); + + Assert.That(stateChangedFired, Is.EqualTo(1)); + + Assert.That(handler.CanUndo.Value, Is.True); + Assert.That(handler.CanRedo.Value, Is.False); + + handler.RestoreState(-1); + + Assert.That(handler.CanUndo.Value, Is.False); + Assert.That(handler.CanRedo.Value, Is.True); + + Assert.That(stateChangedFired, Is.EqualTo(2)); + } + + [Test] + public void TestSaveSameStateDoesNotSave() + { + var (handler, beatmap) = createChangeHandler(); + + Assert.That(handler.CanUndo.Value, Is.False); + Assert.That(handler.CanRedo.Value, Is.False); + + addArbitraryChange(beatmap); + handler.SaveState(); + + Assert.That(handler.CanUndo.Value, Is.True); + Assert.That(handler.CanRedo.Value, Is.False); + Assert.That(stateChangedFired, Is.EqualTo(1)); + + string hash = handler.CurrentStateHash; + + // save a save without making any changes + handler.SaveState(); + + Assert.That(hash, Is.EqualTo(handler.CurrentStateHash)); + Assert.That(stateChangedFired, Is.EqualTo(1)); + + handler.RestoreState(-1); + + Assert.That(hash, Is.Not.EqualTo(handler.CurrentStateHash)); + + // we should only be able to restore once even though we saved twice. + Assert.That(handler.CanUndo.Value, Is.False); + Assert.That(handler.CanRedo.Value, Is.True); + Assert.That(stateChangedFired, Is.EqualTo(2)); + } + + [Test] + public void TestMaxStatesSaved() + { + var (handler, beatmap) = createChangeHandler(); + + Assert.That(handler.CanUndo.Value, Is.False); + + for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++) + { + Assert.That(stateChangedFired, Is.EqualTo(i)); + + addArbitraryChange(beatmap); + handler.SaveState(); + } + + Assert.That(handler.CanUndo.Value, Is.True); + + for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++) + { + Assert.That(handler.CanUndo.Value, Is.True); + handler.RestoreState(-1); + } + + Assert.That(handler.CanUndo.Value, Is.False); + } + + [Test] + public void TestMaxStatesExceeded() + { + var (handler, beatmap) = createChangeHandler(); + + Assert.That(handler.CanUndo.Value, Is.False); + + for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES * 2; i++) + { + addArbitraryChange(beatmap); + handler.SaveState(); + } + + Assert.That(handler.CanUndo.Value, Is.True); + + for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++) + { + Assert.That(handler.CanUndo.Value, Is.True); + handler.RestoreState(-1); + } + + Assert.That(handler.CanUndo.Value, Is.False); + } + + private (EditorChangeHandler, EditorBeatmap) createChangeHandler() + { + var beatmap = new EditorBeatmap(new Beatmap()); + + var changeHandler = new EditorChangeHandler(beatmap); + + changeHandler.OnStateChange += () => stateChangedFired++; + return (changeHandler, beatmap); + } + + private void addArbitraryChange(EditorBeatmap beatmap) + { + beatmap.Add(new HitCircle { StartTime = RNG.Next(0, 100000) }); + } + } +} diff --git a/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs b/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs new file mode 100644 index 0000000000..bb56131b04 --- /dev/null +++ b/osu.Game.Tests/Editing/LegacyEditorBeatmapPatcherTest.cs @@ -0,0 +1,376 @@ +// 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.Text; +using NUnit.Framework; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osuTK; +using Decoder = osu.Game.Beatmaps.Formats.Decoder; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class LegacyEditorBeatmapPatcherTest + { + private LegacyEditorBeatmapPatcher patcher; + private EditorBeatmap current; + + [SetUp] + public void Setup() + { + patcher = new LegacyEditorBeatmapPatcher(current = new EditorBeatmap(new OsuBeatmap + { + BeatmapInfo = + { + Ruleset = new OsuRuleset().RulesetInfo + } + })); + } + + [Test] + public void TestPatchNoObjectChanges() + { + runTest(new OsuBeatmap()); + } + + [Test] + public void TestAddHitObject() + { + var patch = new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 1000, NewCombo = true } + } + }; + + runTest(patch); + } + + [Test] + public void TestInsertHitObject() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 1000, NewCombo = true }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[0], + new HitCircle { StartTime = 2000 }, + (OsuHitObject)current.HitObjects[1], + } + }; + + runTest(patch); + } + + [Test] + public void TestDeleteHitObject() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 1000, NewCombo = true }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[0], + (OsuHitObject)current.HitObjects[2], + } + }; + + runTest(patch); + } + + [Test] + public void TestChangeStartTime() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 1000, NewCombo = true }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 500, NewCombo = true }, + (OsuHitObject)current.HitObjects[1], + (OsuHitObject)current.HitObjects[2], + } + }; + + runTest(patch); + } + + [Test] + public void TestChangeSample() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 1000, NewCombo = true }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[0], + new HitCircle { StartTime = 2000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH } } }, + (OsuHitObject)current.HitObjects[2], + } + }; + + runTest(patch); + } + + [Test] + public void TestChangeSliderPath() + { + current.AddRange(new OsuHitObject[] + { + new HitCircle { StartTime = 1000, NewCombo = true }, + new Slider + { + StartTime = 2000, + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero), + new PathControlPoint(Vector2.One), + new PathControlPoint(new Vector2(2), PathType.Bezier), + new PathControlPoint(new Vector2(3)), + }, 50) + }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[0], + new Slider + { + StartTime = 2000, + Path = new SliderPath(new[] + { + new PathControlPoint(Vector2.Zero, PathType.Bezier), + new PathControlPoint(new Vector2(4)), + new PathControlPoint(new Vector2(5)), + }, 100) + }, + (OsuHitObject)current.HitObjects[2], + } + }; + + runTest(patch); + } + + [Test] + public void TestAddMultipleHitObjects() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 1000, NewCombo = true }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 3000 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 500, NewCombo = true }, + (OsuHitObject)current.HitObjects[0], + new HitCircle { StartTime = 1500 }, + (OsuHitObject)current.HitObjects[1], + new HitCircle { StartTime = 2250 }, + new HitCircle { StartTime = 2500 }, + (OsuHitObject)current.HitObjects[2], + new HitCircle { StartTime = 3500 }, + } + }; + + runTest(patch); + } + + [Test] + public void TestDeleteMultipleHitObjects() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 500, NewCombo = true }, + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1500 }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 2250 }, + new HitCircle { StartTime = 2500 }, + new HitCircle { StartTime = 3000 }, + new HitCircle { StartTime = 3500 }, + }); + + var patchedFirst = (HitCircle)current.HitObjects[1]; + patchedFirst.NewCombo = true; + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[1], + (OsuHitObject)current.HitObjects[3], + (OsuHitObject)current.HitObjects[6], + } + }; + + runTest(patch); + } + + [Test] + public void TestChangeSamplesOfMultipleHitObjects() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 500, NewCombo = true }, + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1500 }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 2250 }, + new HitCircle { StartTime = 2500 }, + new HitCircle { StartTime = 3000 }, + new HitCircle { StartTime = 3500 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + (OsuHitObject)current.HitObjects[0], + new HitCircle { StartTime = 1000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_FINISH } } }, + (OsuHitObject)current.HitObjects[2], + (OsuHitObject)current.HitObjects[3], + new HitCircle { StartTime = 2250, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_WHISTLE } } }, + (OsuHitObject)current.HitObjects[5], + new HitCircle { StartTime = 3000, Samples = { new HitSampleInfo { Name = HitSampleInfo.HIT_CLAP } } }, + (OsuHitObject)current.HitObjects[7], + } + }; + + runTest(patch); + } + + [Test] + public void TestAddAndDeleteHitObjects() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 500, NewCombo = true }, + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1500 }, + new HitCircle { StartTime = 2000 }, + new HitCircle { StartTime = 2250 }, + new HitCircle { StartTime = 2500 }, + new HitCircle { StartTime = 3000 }, + new HitCircle { StartTime = 3500 }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 750, NewCombo = true }, + (OsuHitObject)current.HitObjects[1], + (OsuHitObject)current.HitObjects[4], + (OsuHitObject)current.HitObjects[5], + new HitCircle { StartTime = 2650 }, + new HitCircle { StartTime = 2750 }, + new HitCircle { StartTime = 4000 }, + } + }; + + runTest(patch); + } + + [Test] + public void TestChangeHitObjectAtSameTime() + { + current.AddRange(new[] + { + new HitCircle { StartTime = 500, Position = new Vector2(50), NewCombo = true }, + new HitCircle { StartTime = 500, Position = new Vector2(100), NewCombo = true }, + new HitCircle { StartTime = 500, Position = new Vector2(150), NewCombo = true }, + new HitCircle { StartTime = 500, Position = new Vector2(200), NewCombo = true }, + }); + + var patch = new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 500, Position = new Vector2(150), NewCombo = true }, + new HitCircle { StartTime = 500, Position = new Vector2(100), NewCombo = true }, + new HitCircle { StartTime = 500, Position = new Vector2(50), NewCombo = true }, + new HitCircle { StartTime = 500, Position = new Vector2(200), NewCombo = true }, + } + }; + + runTest(patch); + } + + private void runTest(IBeatmap patch) + { + // Due to the method of testing, "patch" comes in without having been decoded via a beatmap decoder. + // This causes issues because the decoder adds various default properties (e.g. new combo on first object, default samples). + // To resolve "patch" into a sane state it is encoded and then re-decoded. + patch = decode(encode(patch)); + + // Apply the patch. + patcher.Patch(encode(current), encode(patch)); + + // Convert beatmaps to strings for assertion purposes. + string currentStr = Encoding.ASCII.GetString(encode(current)); + string patchStr = Encoding.ASCII.GetString(encode(patch)); + + Assert.That(currentStr, Is.EqualTo(patchStr)); + } + + private byte[] encode(IBeatmap beatmap) + { + using (var encoded = new MemoryStream()) + { + using (var sw = new StreamWriter(encoded)) + new LegacyBeatmapEncoder(beatmap, null).Encode(sw); + + return encoded.ToArray(); + } + } + + private IBeatmap decode(byte[] state) + { + using (var stream = new MemoryStream(state)) + using (var reader = new LineBufferedReader(stream)) + return Decoder.GetDecoder(reader).Decode(reader); + } + } +} diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs new file mode 100644 index 0000000000..bd34eaff63 --- /dev/null +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -0,0 +1,225 @@ +// 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.Testing; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Editing +{ + [HeadlessTest] + public class TestSceneHitObjectComposerDistanceSnapping : EditorClockTestScene + { + private TestHitObjectComposer composer; + + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + private readonly EditorBeatmap editorBeatmap; + + protected override Container Content { get; } + + public TestSceneHitObjectComposerDistanceSnapping() + { + base.Content.Add(new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + editorBeatmap = new EditorBeatmap(new OsuBeatmap()), + Content = new Container + { + RelativeSizeAxes = Axes.Both, + } + }, + }); + } + + [SetUp] + public void Setup() => Schedule(() => + { + Children = new Drawable[] + { + composer = new TestHitObjectComposer() + }; + + BeatDivisor.Value = 1; + + composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 1; + composer.EditorBeatmap.ControlPointInfo.Clear(); + + composer.EditorBeatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 1 }); + composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 }); + }); + + [TestCase(1)] + [TestCase(2)] + public void TestSliderMultiplier(float multiplier) + { + AddStep($"set multiplier = {multiplier}", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = multiplier); + + assertSnapDistance(100 * multiplier); + } + + [TestCase(1)] + [TestCase(2)] + public void TestSpeedMultiplier(float multiplier) + { + AddStep($"set multiplier = {multiplier}", () => + { + composer.EditorBeatmap.ControlPointInfo.Clear(); + composer.EditorBeatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = multiplier }); + }); + + assertSnapDistance(100 * multiplier); + } + + [TestCase(1)] + [TestCase(2)] + public void TestBeatDivisor(int divisor) + { + AddStep($"set divisor = {divisor}", () => BeatDivisor.Value = divisor); + + assertSnapDistance(100f / divisor); + } + + [Test] + public void TestConvertDurationToDistance() + { + assertDurationToDistance(500, 50); + assertDurationToDistance(1000, 100); + + AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2); + + assertDurationToDistance(500, 100); + assertDurationToDistance(1000, 200); + + AddStep("set beat length = 500", () => + { + composer.EditorBeatmap.ControlPointInfo.Clear(); + composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + }); + + assertDurationToDistance(500, 200); + assertDurationToDistance(1000, 400); + } + + [Test] + public void TestConvertDistanceToDuration() + { + assertDistanceToDuration(50, 500); + assertDistanceToDuration(100, 1000); + + AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2); + + assertDistanceToDuration(100, 500); + assertDistanceToDuration(200, 1000); + + AddStep("set beat length = 500", () => + { + composer.EditorBeatmap.ControlPointInfo.Clear(); + composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + }); + + assertDistanceToDuration(200, 500); + assertDistanceToDuration(400, 1000); + } + + [Test] + public void TestGetSnappedDurationFromDistance() + { + assertSnappedDuration(0, 0); + assertSnappedDuration(50, 1000); + assertSnappedDuration(100, 1000); + assertSnappedDuration(150, 2000); + assertSnappedDuration(200, 2000); + assertSnappedDuration(250, 3000); + + AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2); + + assertSnappedDuration(0, 0); + assertSnappedDuration(50, 0); + assertSnappedDuration(100, 1000); + assertSnappedDuration(150, 1000); + assertSnappedDuration(200, 1000); + assertSnappedDuration(250, 1000); + + AddStep("set beat length = 500", () => + { + composer.EditorBeatmap.ControlPointInfo.Clear(); + composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + }); + + assertSnappedDuration(50, 0); + assertSnappedDuration(100, 500); + assertSnappedDuration(150, 500); + assertSnappedDuration(200, 500); + assertSnappedDuration(250, 500); + assertSnappedDuration(400, 1000); + } + + [Test] + public void GetSnappedDistanceFromDistance() + { + assertSnappedDistance(50, 0); + assertSnappedDistance(100, 100); + assertSnappedDistance(150, 100); + assertSnappedDistance(200, 200); + assertSnappedDistance(250, 200); + + AddStep("set slider multiplier = 2", () => composer.EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2); + + assertSnappedDistance(50, 0); + assertSnappedDistance(100, 0); + assertSnappedDistance(150, 0); + assertSnappedDistance(200, 200); + assertSnappedDistance(250, 200); + + AddStep("set beat length = 500", () => + { + composer.EditorBeatmap.ControlPointInfo.Clear(); + composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + }); + + assertSnappedDistance(50, 0); + assertSnappedDistance(100, 0); + assertSnappedDistance(150, 0); + assertSnappedDistance(200, 200); + assertSnappedDistance(250, 200); + assertSnappedDistance(400, 400); + } + + private void assertSnapDistance(float expectedDistance) + => AddAssert($"distance is {expectedDistance}", () => composer.GetBeatSnapDistanceAt(0) == expectedDistance); + + private void assertDurationToDistance(double duration, float expectedDistance) + => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DurationToDistance(0, duration) == expectedDistance); + + private void assertDistanceToDuration(float distance, double expectedDuration) + => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceToDuration(0, distance) == expectedDuration); + + private void assertSnappedDuration(float distance, double expectedDuration) + => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.GetSnappedDurationFromDistance(0, distance) == expectedDuration); + + private void assertSnappedDistance(float distance, float expectedDistance) + => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.GetSnappedDistanceFromDistance(0, distance) == expectedDistance); + + private class TestHitObjectComposer : OsuHitObjectComposer + { + public new EditorBeatmap EditorBeatmap => base.EditorBeatmap; + + public TestHitObjectComposer() + : base(new OsuRuleset()) + { + } + } + } +} diff --git a/osu.Game.Tests/Editing/TransactionalCommitComponentTest.cs b/osu.Game.Tests/Editing/TransactionalCommitComponentTest.cs new file mode 100644 index 0000000000..4ce9115ec4 --- /dev/null +++ b/osu.Game.Tests/Editing/TransactionalCommitComponentTest.cs @@ -0,0 +1,100 @@ +// 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.Screens.Edit; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class TransactionalCommitComponentTest + { + private TestHandler handler; + + [SetUp] + public void SetUp() + { + handler = new TestHandler(); + } + + [Test] + public void TestCommitTransaction() + { + Assert.That(handler.StateUpdateCount, Is.EqualTo(0)); + + handler.BeginChange(); + Assert.That(handler.StateUpdateCount, Is.EqualTo(0)); + handler.EndChange(); + + Assert.That(handler.StateUpdateCount, Is.EqualTo(1)); + } + + [Test] + public void TestSaveOutsideOfTransactionTriggersUpdates() + { + Assert.That(handler.StateUpdateCount, Is.EqualTo(0)); + + handler.SaveState(); + Assert.That(handler.StateUpdateCount, Is.EqualTo(1)); + + handler.SaveState(); + Assert.That(handler.StateUpdateCount, Is.EqualTo(2)); + } + + [Test] + public void TestEventsFire() + { + int transactionBegan = 0; + int transactionEnded = 0; + int stateSaved = 0; + + handler.TransactionBegan += () => transactionBegan++; + handler.TransactionEnded += () => transactionEnded++; + handler.SaveStateTriggered += () => stateSaved++; + + handler.BeginChange(); + Assert.That(transactionBegan, Is.EqualTo(1)); + + handler.EndChange(); + Assert.That(transactionEnded, Is.EqualTo(1)); + + Assert.That(stateSaved, Is.EqualTo(0)); + handler.SaveState(); + Assert.That(stateSaved, Is.EqualTo(1)); + } + + [Test] + public void TestSaveDuringTransactionDoesntTriggerUpdate() + { + Assert.That(handler.StateUpdateCount, Is.EqualTo(0)); + + handler.BeginChange(); + + handler.SaveState(); + Assert.That(handler.StateUpdateCount, Is.EqualTo(0)); + + handler.EndChange(); + + Assert.That(handler.StateUpdateCount, Is.EqualTo(1)); + } + + [Test] + public void TestEndWithoutBeginThrows() + { + handler.BeginChange(); + handler.EndChange(); + Assert.That(() => handler.EndChange(), Throws.TypeOf()); + } + + private class TestHandler : TransactionalCommitComponent + { + public int StateUpdateCount { get; private set; } + + protected override void UpdateState() + { + StateUpdateCount++; + } + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs new file mode 100644 index 0000000000..7264083338 --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneDrainingHealthProcessor.cs @@ -0,0 +1,278 @@ +// 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; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneDrainingHealthProcessor : OsuTestScene + { + private HealthProcessor processor; + private ManualClock clock; + + [Test] + public void TestInitialHealthStartsAtOne() + { + createProcessor(createBeatmap(1000, 2000)); + + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthNotDrainedBeforeGameplayStart() + { + createProcessor(createBeatmap(1000, 2000)); + + setTime(100); + assertHealthEqualTo(1); + setTime(900); + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthDrainBetweenBreakAndObjects() + { + createProcessor(createBeatmap(0, 2000, new BreakPeriod(325, 375))); + + // 275 300 325 350 375 400 425 + // hitobjects o o + // break [-------------] + // no drain [---------------------------] + + setTime(285); + setHealth(1); + + setTime(295); + assertHealthNotEqualTo(1); + + setTime(305); + setHealth(1); + + setTime(315); + assertHealthEqualTo(1); + + setTime(365); + assertHealthEqualTo(1); + + setTime(395); + assertHealthEqualTo(1); + + setTime(425); + assertHealthNotEqualTo(1); + } + + [Test] + public void TestHealthDrainDuringMaximalBreak() + { + createProcessor(createBeatmap(0, 2000, new BreakPeriod(300, 400))); + + // 275 300 325 350 375 400 425 + // hitobjects o o + // break [---------------------------] + // no drain [---------------------------] + + setTime(285); + setHealth(1); + + setTime(295); + assertHealthNotEqualTo(1); + + setTime(305); + setHealth(1); + + setTime(395); + assertHealthEqualTo(1); + + setTime(425); + assertHealthNotEqualTo(1); + } + + [Test] + public void TestHealthNotDrainedAfterGameplayEnd() + { + createProcessor(createBeatmap(1000, 2000)); + setTime(2001); // After the hitobjects + setHealth(1); // Reset the current health for assertions to take place + + setTime(2100); + assertHealthEqualTo(1); + setTime(3000); + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthDrainedDuringGameplay() + { + createProcessor(createBeatmap(0, 1000)); + + setTime(500); + assertHealthNotEqualTo(1); + } + + [Test] + public void TestHealthGainedAfterRewind() + { + createProcessor(createBeatmap(0, 1000)); + setTime(500); + + setTime(0); + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthGainedOnHit() + { + Beatmap beatmap = createBeatmap(0, 1000); + + createProcessor(beatmap); + setTime(10); // Decrease health slightly + assertHealthNotEqualTo(1); + + AddStep("apply hit result", () => processor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new Judgement()) { Type = HitResult.Perfect })); + assertHealthEqualTo(1); + } + + [Test] + public void TestHealthRemovedOnRevert() + { + var beatmap = createBeatmap(0, 1000); + JudgementResult result = null; + + createProcessor(beatmap); + setTime(10); // Decrease health slightly + AddStep("apply hit result", () => processor.ApplyResult(result = new JudgementResult(beatmap.HitObjects[0], new Judgement()) { Type = HitResult.Perfect })); + + AddStep("revert hit result", () => processor.RevertResult(result)); + assertHealthNotEqualTo(1); + } + + [Test] + public void TestBonusObjectsExcludedFromDrain() + { + var beatmap = new Beatmap + { + BeatmapInfo = { BaseDifficulty = { DrainRate = 10 } }, + }; + + beatmap.HitObjects.Add(new JudgeableHitObject { StartTime = 0 }); + for (double time = 0; time < 5000; time += 100) + beatmap.HitObjects.Add(new JudgeableHitObject(HitResult.LargeBonus) { StartTime = time }); + beatmap.HitObjects.Add(new JudgeableHitObject { StartTime = 5000 }); + + createProcessor(beatmap); + setTime(4900); // Get close to the second combo-affecting object + assertHealthNotEqualTo(0); + } + + [Test] + public void TestSingleLongObjectDoesNotDrain() + { + var beatmap = new Beatmap + { + HitObjects = { new JudgeableLongHitObject() } + }; + + beatmap.HitObjects[0].ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + createProcessor(beatmap); + setTime(0); + assertHealthEqualTo(1); + + setTime(5000); + assertHealthEqualTo(1); + } + + private Beatmap createBeatmap(double startTime, double endTime, params BreakPeriod[] breaks) + { + var beatmap = new Beatmap + { + BeatmapInfo = { BaseDifficulty = { DrainRate = 10 } }, + }; + + for (double time = startTime; time <= endTime; time += 100) + { + beatmap.HitObjects.Add(new JudgeableHitObject { StartTime = time }); + } + + beatmap.Breaks.AddRange(breaks); + + return beatmap; + } + + private void createProcessor(Beatmap beatmap) => AddStep("create processor", () => + { + Child = processor = new DrainingHealthProcessor(beatmap.HitObjects[0].StartTime).With(d => + { + d.RelativeSizeAxes = Axes.Both; + d.Clock = new FramedClock(clock = new ManualClock()); + }); + + processor.ApplyBeatmap(beatmap); + }); + + private void setTime(double time) => AddStep($"set time = {time}", () => clock.CurrentTime = time); + + private void setHealth(double health) => AddStep($"set health = {health}", () => processor.Health.Value = health); + + private void assertHealthEqualTo(double value) + => AddAssert($"health = {value}", () => Precision.AlmostEquals(value, processor.Health.Value, 0.0001f)); + + private void assertHealthNotEqualTo(double value) + => AddAssert($"health != {value}", () => !Precision.AlmostEquals(value, processor.Health.Value, 0.0001f)); + + private class JudgeableHitObject : HitObject + { + private readonly HitResult maxResult; + + public JudgeableHitObject(HitResult maxResult = HitResult.Perfect) + { + this.maxResult = maxResult; + } + + public override Judgement CreateJudgement() => new TestJudgement(maxResult); + protected override HitWindows CreateHitWindows() => new HitWindows(); + + private class TestJudgement : Judgement + { + public override HitResult MaxResult { get; } + + public TestJudgement(HitResult maxResult) + { + MaxResult = maxResult; + } + } + } + + private class JudgeableLongHitObject : JudgeableHitObject, IHasDuration + { + public double EndTime => StartTime + Duration; + public double Duration { get; set; } = 5000; + + public JudgeableLongHitObject() + : base(HitResult.LargeBonus) + { + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + base.CreateNestedHitObjects(cancellationToken); + + AddNested(new JudgeableHitObject()); + } + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.cs b/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.cs new file mode 100644 index 0000000000..891537c4ad --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneGameplayClockContainer.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 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 new file mode 100644 index 0000000000..de46f9d1cf --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.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 NUnit.Framework; +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.Textures; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneHitObjectAccentColour : OsuTestScene + { + private Container skinContainer; + + [SetUp] + public void Setup() => Schedule(() => Child = skinContainer = new SkinProvidingContainer(new TestSkin())); + + [Test] + public void TestChangeComboIndexBeforeLoad() + { + TestDrawableHitObject hitObject = null; + + AddStep("set combo and add hitobject", () => + { + hitObject = new TestDrawableHitObject(); + hitObject.HitObject.ComboIndex = 1; + + skinContainer.Add(hitObject); + }); + + AddAssert("combo colour is green", () => hitObject.AccentColour.Value == Color4.Green); + } + + [Test] + public void TestChangeComboIndexDuringLoad() + { + TestDrawableHitObject hitObject = null; + + AddStep("add hitobject and set combo", () => + { + skinContainer.Add(hitObject = new TestDrawableHitObject()); + hitObject.HitObject.ComboIndex = 1; + }); + + AddAssert("combo colour is green", () => hitObject.AccentColour.Value == Color4.Green); + } + + [Test] + public void TestChangeComboIndexAfterLoad() + { + TestDrawableHitObject hitObject = null; + + AddStep("add hitobject", () => skinContainer.Add(hitObject = new TestDrawableHitObject())); + AddAssert("combo colour is red", () => hitObject.AccentColour.Value == Color4.Red); + + AddStep("change combo", () => hitObject.HitObject.ComboIndex = 1); + AddAssert("combo colour is green", () => hitObject.AccentColour.Value == Color4.Green); + } + + private class TestDrawableHitObject : DrawableHitObject + { + public TestDrawableHitObject() + : base(new TestHitObjectWithCombo()) + { + } + } + + private class TestHitObjectWithCombo : ConvertHitObject, IHasComboInformation + { + public bool NewCombo { get; set; } + public int ComboOffset => 0; + + public Bindable IndexInCurrentComboBindable { get; } = new Bindable(); + + public int IndexInCurrentCombo + { + get => IndexInCurrentComboBindable.Value; + set => IndexInCurrentComboBindable.Value = value; + } + + public Bindable ComboIndexBindable { get; } = new Bindable(); + + public int ComboIndex + { + get => ComboIndexBindable.Value; + set => ComboIndexBindable.Value = value; + } + + public Bindable LastInComboBindable { get; } = new Bindable(); + + public bool LastInCombo + { + get => LastInComboBindable.Value; + set => LastInComboBindable.Value = value; + } + } + + private class TestSkin : ISkin + { + public readonly List ComboColours = new List + { + Color4.Red, + Color4.Green + }; + + public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotImplementedException(); + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); + + public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + + public IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case GlobalSkinColours global: + switch (global) + { + case GlobalSkinColours.ComboColours: + return SkinUtils.As(new Bindable>(ComboColours)); + } + + break; + } + + throw new NotImplementedException(); + } + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectContainer.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectContainer.cs new file mode 100644 index 0000000000..f2bfccb6de --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectContainer.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 JetBrains.Annotations; +using NUnit.Framework; +using osu.Framework.Testing; +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 TestSceneHitObjectContainer : OsuTestScene + { + private HitObjectContainer container; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = container = new HitObjectContainer(); + }); + + [Test] + public void TestLateHitObjectIsAddedEarlierInList() + { + DrawableHitObject hitObject = null; + + AddStep("setup", () => container.Add(new TestDrawableHitObject(new HitObject { StartTime = 500 }))); + + AddStep("add late hitobject", () => container.Add(hitObject = new TestDrawableHitObject(new HitObject { StartTime = 1000 }))); + + AddAssert("hitobject index is 0", () => container.IndexOf(hitObject) == 0); + } + + [Test] + public void TestEarlyHitObjectIsAddedLaterInList() + { + DrawableHitObject hitObject = null; + + AddStep("setup", () => container.Add(new TestDrawableHitObject(new HitObject { StartTime = 500 }))); + + AddStep("add early hitobject", () => container.Add(hitObject = new TestDrawableHitObject(new HitObject()))); + + AddAssert("hitobject index is 0", () => container.IndexOf(hitObject) == 1); + } + + [Test] + public void TestHitObjectsResortedAfterStartTimeChange() + { + DrawableHitObject firstObject = null; + DrawableHitObject secondObject = null; + + AddStep("setup", () => + { + container.Add(firstObject = new TestDrawableHitObject(new HitObject())); + container.Add(secondObject = new TestDrawableHitObject(new HitObject { StartTime = 1000 })); + }); + + AddStep("move first object after second", () => firstObject.HitObject.StartTime = 2000); + + AddAssert("first object index is 1", () => container.IndexOf(firstObject) == 0); + AddAssert("second object index is 0", () => container.IndexOf(secondObject) == 1); + } + + private class TestDrawableHitObject : DrawableHitObject + { + public TestDrawableHitObject([NotNull] HitObject hitObject) + : base(hitObject) + { + } + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs new file mode 100644 index 0000000000..6b95931b21 --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectSamples.cs @@ -0,0 +1,252 @@ +// 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.IO.Stores; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using static osu.Game.Skinning.LegacySkinConfiguration; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneHitObjectSamples : HitObjectSampleTest + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + protected override IResourceStore Resources => TestResources.GetStore(); + + /// + /// Tests that a hitobject which provides no custom sample set retrieves samples from the user skin. + /// + [Test] + public void TestDefaultSampleFromUserSkin() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("hitobject-skin-sample.osu"); + + AssertUserLookup(expected_sample); + } + + /// + /// Tests that a hitobject which provides a sample set of 1 retrieves samples from the beatmap skin. + /// + [Test] + public void TestDefaultSampleFromBeatmap() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("hitobject-beatmap-sample.osu"); + + AssertBeatmapLookup(expected_sample); + } + + /// + /// Tests that a hitobject which provides a sample set of 1 retrieves samples from the user skin when the beatmap does not contain the sample. + /// + [Test] + public void TestDefaultSampleFromUserSkinFallback() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(null, expected_sample); + + CreateTestWithBeatmap("hitobject-beatmap-sample.osu"); + + AssertUserLookup(expected_sample); + } + + /// + /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the beatmap skin: + /// normal-hitnormal2 + /// normal-hitnormal + /// hitnormal + /// + [TestCase("normal-hitnormal2")] + [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] + public void TestDefaultCustomSampleFromBeatmap(string expectedSample) + { + SetupSkins(expectedSample, expectedSample); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample.osu"); + + AssertBeatmapLookup(expectedSample); + } + + /// + /// Tests that a hitobject which provides a custom sample set of 2 retrieves the following samples from the user skin + /// (ignoring the custom sample set index) when the beatmap skin does not contain the sample: + /// normal-hitnormal + /// hitnormal + /// + [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] + public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) + { + SetupSkins(string.Empty, expectedSample); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample.osu"); + + AssertUserLookup(expectedSample); + } + + /// + /// Tests that a hitobject which provides a custom sample set of 2 does not retrieve a normal-hitnormal2 sample from the user skin + /// if the beatmap skin does not contain the sample. + /// User skins in stable ignore the custom sample set index when performing lookups. + /// + [Test] + public void TestUserSkinLookupIgnoresSampleBank() + { + const string unwanted_sample = "normal-hitnormal2"; + + SetupSkins(string.Empty, unwanted_sample); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample.osu"); + + AssertNoLookup(unwanted_sample); + } + + /// + /// Tests that a hitobject which provides a sample file retrieves the sample file from the beatmap skin. + /// + [Test] + public void TestFileSampleFromBeatmap() + { + const string expected_sample = "hit_1.wav"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("file-beatmap-sample.osu"); + + AssertBeatmapLookup(expected_sample); + } + + /// + /// Tests that a default hitobject and control point causes . + /// + [Test] + public void TestControlPointSampleFromSkin() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("controlpoint-skin-sample.osu"); + + AssertUserLookup(expected_sample); + } + + /// + /// Tests that a control point that provides a custom sample set of 1 causes . + /// + [Test] + public void TestControlPointSampleFromBeatmap() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("controlpoint-beatmap-sample.osu"); + + AssertBeatmapLookup(expected_sample); + } + + /// + /// Tests that a control point that provides a custom sample of 2 causes . + /// + [TestCase("normal-hitnormal2")] + [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] + public void TestControlPointCustomSampleFromBeatmap(string sampleName) + { + SetupSkins(sampleName, sampleName); + + CreateTestWithBeatmap("controlpoint-beatmap-custom-sample.osu"); + + AssertBeatmapLookup(sampleName); + } + + /// + /// Tests that a hitobject's custom sample overrides the control point's. + /// + [Test] + public void TestHitObjectCustomSampleOverride() + { + const string expected_sample = "normal-hitnormal3"; + + SetupSkins(expected_sample, expected_sample); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample-override.osu"); + + AssertBeatmapLookup(expected_sample); + } + + /// + /// Tests that when a custom sample bank is used, both the normal and additional sounds will be looked up. + /// + [Test] + public void TestHitObjectCustomSampleBank() + { + string[] expectedSamples = + { + "normal-hitnormal2", + "normal-hitwhistle" // user skin lookups ignore custom sample set index + }; + + SetupSkins(expectedSamples[0], expectedSamples[1]); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample-bank.osu"); + + AssertBeatmapLookup(expectedSamples[0]); + AssertUserLookup(expectedSamples[1]); + } + + /// + /// Tests that when a custom sample bank is used, but is disabled, + /// only the additional sound will be looked up. + /// + [Test] + public void TestHitObjectCustomSampleBankWithoutLayered() + { + const string expected_sample = "normal-hitwhistle2"; + const string unwanted_sample = "normal-hitnormal2"; + + SetupSkins(expected_sample, unwanted_sample); + disableLayeredHitSounds(); + + CreateTestWithBeatmap("hitobject-beatmap-custom-sample-bank.osu"); + + AssertBeatmapLookup(expected_sample); + AssertNoLookup(unwanted_sample); + } + + /// + /// Tests that when a normal sample bank is used and is disabled, + /// the normal sound will be looked up anyway. + /// + [Test] + public void TestHitObjectNormalSampleBankWithoutLayered() + { + const string expected_sample = "normal-hitnormal"; + + SetupSkins(expected_sample, expected_sample); + disableLayeredHitSounds(); + + CreateTestWithBeatmap("hitobject-beatmap-sample.osu"); + + AssertBeatmapLookup(expected_sample); + } + + private void disableLayeredHitSounds() + => AddStep("set LayeredHitSounds to false", () => Skin.Configuration.ConfigDictionary[LegacySetting.LayeredHitSounds.ToString()] = "0"); + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs new file mode 100644 index 0000000000..432e3df95e --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.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 NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneScoreProcessor : OsuTestScene + { + [Test] + public void TestNoScoreIncreaseFromMiss() + { + var beatmap = new Beatmap { HitObjects = { new HitObject() } }; + + var scoreProcessor = new ScoreProcessor(); + scoreProcessor.ApplyBeatmap(beatmap); + + // Apply a miss judgement + scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new TestJudgement()) { Type = HitResult.Miss }); + + Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(0.0)); + } + + [Test] + public void TestOnlyBonusScore() + { + var beatmap = new Beatmap { HitObjects = { new HitObject() } }; + + var scoreProcessor = new ScoreProcessor(); + scoreProcessor.ApplyBeatmap(beatmap); + + // Apply a judgement + scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new TestJudgement(HitResult.LargeBonus)) { Type = HitResult.LargeBonus }); + + Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(Judgement.LARGE_BONUS_SCORE)); + } + + private class TestJudgement : Judgement + { + public override HitResult MaxResult { get; } + + public TestJudgement(HitResult maxResult = HitResult.Perfect) + { + MaxResult = maxResult; + } + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs new file mode 100644 index 0000000000..d46769a7c0 --- /dev/null +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -0,0 +1,180 @@ +// 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 NUnit.Framework; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Graphics.Audio; +using osu.Framework.IO.Stores; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Play; +using osu.Game.Skinning; +using osu.Game.Storyboards; +using osu.Game.Storyboards.Drawables; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Gameplay +{ + [HeadlessTest] + public class TestSceneStoryboardSamples : OsuTestScene + { + [Test] + public void TestRetrieveTopLevelSample() + { + ISkin skin = null; + SampleChannel channel = null; + + AddStep("create skin", () => skin = new TestSkin("test-sample", Audio)); + AddStep("retrieve sample", () => channel = skin.GetSample(new SampleInfo("test-sample"))); + + AddAssert("sample is non-null", () => channel != null); + } + + [Test] + public void TestRetrieveSampleInSubFolder() + { + ISkin skin = null; + SampleChannel channel = null; + + AddStep("create skin", () => skin = new TestSkin("folder/test-sample", Audio)); + AddStep("retrieve sample", () => channel = skin.GetSample(new SampleInfo("folder/test-sample"))); + + AddAssert("sample is non-null", () => channel != null); + } + + [Test] + public void TestSamplePlaybackAtZero() + { + GameplayClockContainer gameplayContainer = null; + DrawableStoryboardSample sample = null; + + AddStep("create container", () => + { + 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)) + { + Clock = gameplayContainer.GameplayClock + }); + }); + + AddStep("start time", () => gameplayContainer.Start()); + + AddUntilStep("sample playback succeeded", () => sample.LifetimeEnd < double.MaxValue); + } + + [TestCase(typeof(OsuModDoubleTime), 1.5)] + [TestCase(typeof(OsuModHalfTime), 0.75)] + [TestCase(typeof(ModWindUp), 1.5)] + [TestCase(typeof(ModWindDown), 0.75)] + [TestCase(typeof(OsuModDoubleTime), 2)] + [TestCase(typeof(OsuModHalfTime), 0.5)] + [TestCase(typeof(ModWindUp), 2)] + [TestCase(typeof(ModWindDown), 0.5)] + public void TestSamplePlaybackWithRateMods(Type expectedMod, double expectedRate) + { + GameplayClockContainer gameplayContainer = null; + TestDrawableStoryboardSample sample = null; + + Mod testedMod = Activator.CreateInstance(expectedMod) as Mod; + + switch (testedMod) + { + case ModRateAdjust m: + m.SpeedChange.Value = expectedRate; + break; + + case ModTimeRamp m: + m.InitialRate.Value = m.FinalRate.Value = expectedRate; + break; + } + + AddStep("setup storyboard sample", () => + { + Beatmap.Value = new TestCustomSkinWorkingBeatmap(new OsuRuleset().RulesetInfo, Audio); + SelectedMods.Value = new[] { testedMod }; + + var beatmapSkinSourceContainer = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin); + + Add(gameplayContainer = new GameplayClockContainer(Beatmap.Value, 0) + { + Child = beatmapSkinSourceContainer + }); + + beatmapSkinSourceContainer.Add(sample = new TestDrawableStoryboardSample(new StoryboardSampleInfo("test-sample", 1, 1)) + { + Clock = gameplayContainer.GameplayClock + }); + }); + + AddStep("start", () => gameplayContainer.Start()); + + AddAssert("sample playback rate matches mod rates", () => sample.ChildrenOfType().First().AggregateFrequency.Value == expectedRate); + } + + private class TestSkin : LegacySkin + { + public TestSkin(string resourceName, AudioManager audioManager) + : base(DefaultLegacySkin.Info, new TestResourceStore(resourceName), audioManager, "skin.ini") + { + } + } + + private class TestResourceStore : IResourceStore + { + private readonly string resourceName; + + public TestResourceStore(string resourceName) + { + this.resourceName = resourceName; + } + + public byte[] Get(string name) => name == resourceName ? TestResources.GetStore().Get("Resources/Samples/test-sample.mp3") : null; + + public Task GetAsync(string name) => name == resourceName ? TestResources.GetStore().GetAsync("Resources/Samples/test-sample.mp3") : null; + + public Stream GetStream(string name) => name == resourceName ? TestResources.GetStore().GetStream("Resources/Samples/test-sample.mp3") : null; + + public IEnumerable GetAvailableResources() => new[] { resourceName }; + + public void Dispose() + { + } + } + + private class TestCustomSkinWorkingBeatmap : ClockBackedTestWorkingBeatmap + { + private readonly AudioManager audio; + + public TestCustomSkinWorkingBeatmap(RulesetInfo ruleset, AudioManager audio) + : base(ruleset, null, audio) + { + this.audio = audio; + } + + protected override ISkin GetSkin() => new TestSkin("test-sample", audio); + } + + private class TestDrawableStoryboardSample : DrawableStoryboardSample + { + public TestDrawableStoryboardSample(StoryboardSampleInfo sampleInfo) + : base(sampleInfo) + { + } + } + } +} diff --git a/osu.Game.Tests/ImportTest.cs b/osu.Game.Tests/ImportTest.cs new file mode 100644 index 0000000000..ea351e0d45 --- /dev/null +++ b/osu.Game.Tests/ImportTest.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 System; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Platform; +using osu.Game.Collections; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests +{ + public abstract class ImportTest + { + protected virtual TestOsuGameBase LoadOsuIntoHost(GameHost host, bool withBeatmap = false) + { + var osu = new TestOsuGameBase(withBeatmap); + Task.Run(() => host.Run(osu)); + + waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); + + bool ready = false; + // wait for two update frames to be executed. this ensures that all components have had a change to run LoadComplete and hopefully avoid + // database access (GlobalActionContainer is one to do this). + host.UpdateThread.Scheduler.Add(() => host.UpdateThread.Scheduler.Add(() => ready = true)); + + waitForOrAssert(() => ready, @"osu! failed to start in a reasonable amount of time"); + + return osu; + } + + private void waitForOrAssert(Func result, string failureMessage, int timeout = 60000) + { + Task task = Task.Run(() => + { + while (!result()) Thread.Sleep(200); + }); + + Assert.IsTrue(task.Wait(timeout), failureMessage); + } + + public class TestOsuGameBase : OsuGameBase + { + public CollectionManager CollectionManager { get; private set; } + + private readonly bool withBeatmap; + + public TestOsuGameBase(bool withBeatmap) + { + this.withBeatmap = withBeatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + // Beatmap must be imported before the collection manager is loaded. + if (withBeatmap) + BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + + AddInternal(CollectionManager = new CollectionManager(Storage)); + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs b/osu.Game.Tests/NonVisual/BarLineGeneratorTest.cs new file mode 100644 index 0000000000..e663e1128e --- /dev/null +++ b/osu.Game.Tests/NonVisual/BarLineGeneratorTest.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.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Tests.NonVisual +{ + public class BarLineGeneratorTest + { + [Test] + public void TestRoundingErrorCompensation() + { + // The aim of this test is to make sure bar line generation compensates for floating-point errors. + // The premise of the test is that we have a single timing point that should result in bar lines + // that start at a time point that is a whole number every seventh beat. + + // The fact it's every seventh beat is important - it's a number indivisible by 2, which makes + // it susceptible to rounding inaccuracies. In fact this was originally spotted in cases of maps + // that met exactly this criteria. + + const int beat_length_numerator = 2000; + const int beat_length_denominator = 7; + const TimeSignatures signature = TimeSignatures.SimpleQuadruple; + + var beatmap = new Beatmap + { + HitObjects = new List + { + new HitObject { StartTime = 0 }, + new HitObject { StartTime = 120_000 } + }, + ControlPointInfo = new ControlPointInfo() + }; + + beatmap.ControlPointInfo.Add(0, new TimingControlPoint + { + BeatLength = (double)beat_length_numerator / beat_length_denominator, + TimeSignature = signature + }); + + var barLines = new BarLineGenerator(beatmap).BarLines; + + for (int i = 0; i * beat_length_denominator < barLines.Count; i++) + { + var barLine = barLines[i * beat_length_denominator]; + var expectedTime = beat_length_numerator * (int)signature * i; + + // every seventh bar's start time should be at least greater than the whole number we expect. + // It cannot be less, as that can affect overlapping scroll algorithms + // (the previous timing point might be chosen incorrectly if this is not the case) + Assert.GreaterOrEqual(barLine.StartTime, expectedTime); + + // on the other side, make sure we don't stray too far from the expected time either. + Assert.IsTrue(Precision.AlmostEquals(barLine.StartTime, expectedTime)); + + // check major/minor lines for good measure too + Assert.AreEqual(i % (int)signature == 0, barLine.Major); + } + } + + private class BarLine : IBarLine + { + public double StartTime { get; set; } + public bool Major { get; set; } + } + } +} diff --git a/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.cs new file mode 100644 index 0000000000..42a3b4cf43 --- /dev/null +++ b/osu.Game.Tests/NonVisual/BeatmapSetInfoEqualityTest.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 NUnit.Framework; +using osu.Game.Beatmaps; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class BeatmapSetInfoEqualityTest + { + [Test] + public void TestOnlineWithOnline() + { + var ourInfo = new BeatmapSetInfo { OnlineBeatmapSetID = 123 }; + var otherInfo = new BeatmapSetInfo { OnlineBeatmapSetID = 123 }; + + Assert.AreEqual(ourInfo, otherInfo); + } + + [Test] + public void TestDatabasedWithDatabased() + { + var ourInfo = new BeatmapSetInfo { ID = 123 }; + var otherInfo = new BeatmapSetInfo { ID = 123 }; + + Assert.AreEqual(ourInfo, otherInfo); + } + + [Test] + public void TestDatabasedWithOnline() + { + var ourInfo = new BeatmapSetInfo { ID = 123, OnlineBeatmapSetID = 12 }; + var otherInfo = new BeatmapSetInfo { OnlineBeatmapSetID = 12 }; + + Assert.AreEqual(ourInfo, otherInfo); + } + + [Test] + public void TestCheckNullID() + { + var ourInfo = new BeatmapSetInfo { Status = BeatmapSetOnlineStatus.Loved }; + var otherInfo = new BeatmapSetInfo { Status = BeatmapSetOnlineStatus.Approved }; + + Assert.AreNotEqual(ourInfo, otherInfo); + } + } +} diff --git a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs new file mode 100644 index 0000000000..90a487c0ac --- /dev/null +++ b/osu.Game.Tests/NonVisual/ControlPointInfoTest.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.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class ControlPointInfoTest + { + [Test] + public void TestAdd() + { + var cpi = new ControlPointInfo(); + + cpi.Add(0, new TimingControlPoint()); + cpi.Add(1000, new TimingControlPoint { BeatLength = 500 }); + + Assert.That(cpi.Groups.Count, Is.EqualTo(2)); + Assert.That(cpi.TimingPoints.Count, Is.EqualTo(2)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); + } + + [Test] + public void TestAddRedundantTiming() + { + var cpi = new ControlPointInfo(); + + cpi.Add(0, new TimingControlPoint()); // is *not* redundant, special exception for first timing point. + cpi.Add(1000, new TimingControlPoint()); // is also not redundant, due to change of offset + + Assert.That(cpi.Groups.Count, Is.EqualTo(2)); + Assert.That(cpi.TimingPoints.Count, Is.EqualTo(2)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); + + cpi.Add(1000, new TimingControlPoint()); // is redundant + + Assert.That(cpi.Groups.Count, Is.EqualTo(2)); + Assert.That(cpi.TimingPoints.Count, Is.EqualTo(2)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); + } + + [Test] + public void TestAddRedundantDifficulty() + { + var cpi = new ControlPointInfo(); + + cpi.Add(0, new DifficultyControlPoint()); // is redundant + cpi.Add(1000, new DifficultyControlPoint()); // is redundant + + Assert.That(cpi.Groups.Count, Is.EqualTo(0)); + Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(0)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(0)); + + cpi.Add(1000, new DifficultyControlPoint { SpeedMultiplier = 2 }); // is not redundant + + Assert.That(cpi.Groups.Count, Is.EqualTo(1)); + Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(1)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1)); + } + + [Test] + public void TestAddRedundantSample() + { + var cpi = new ControlPointInfo(); + + cpi.Add(0, new SampleControlPoint()); // is *not* redundant, special exception for first sample point + cpi.Add(1000, new SampleControlPoint()); // is redundant + + Assert.That(cpi.Groups.Count, Is.EqualTo(1)); + Assert.That(cpi.SamplePoints.Count, Is.EqualTo(1)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1)); + + cpi.Add(1000, new SampleControlPoint { SampleVolume = 50 }); // is not redundant + + Assert.That(cpi.Groups.Count, Is.EqualTo(2)); + Assert.That(cpi.SamplePoints.Count, Is.EqualTo(2)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); + } + + [Test] + public void TestAddRedundantEffect() + { + var cpi = new ControlPointInfo(); + + cpi.Add(0, new EffectControlPoint()); // is redundant + cpi.Add(1000, new EffectControlPoint()); // is redundant + + Assert.That(cpi.Groups.Count, Is.EqualTo(0)); + Assert.That(cpi.EffectPoints.Count, Is.EqualTo(0)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(0)); + + cpi.Add(1000, new EffectControlPoint { KiaiMode = true, OmitFirstBarLine = true }); // is not redundant + cpi.Add(1400, new EffectControlPoint { KiaiMode = true, OmitFirstBarLine = true }); // same settings, but is not redundant + + Assert.That(cpi.Groups.Count, Is.EqualTo(2)); + Assert.That(cpi.EffectPoints.Count, Is.EqualTo(2)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); + } + + [Test] + public void TestAddGroup() + { + var cpi = new ControlPointInfo(); + + var group = cpi.GroupAt(1000, true); + var group2 = cpi.GroupAt(1000, true); + + Assert.That(group, Is.EqualTo(group2)); + Assert.That(cpi.Groups.Count, Is.EqualTo(1)); + } + + [Test] + public void TestGroupAtLookupOnly() + { + var cpi = new ControlPointInfo(); + + var group = cpi.GroupAt(5000, true); + Assert.That(group, Is.Not.Null); + + Assert.That(cpi.Groups.Count, Is.EqualTo(1)); + Assert.That(cpi.GroupAt(1000), Is.Null); + Assert.That(cpi.GroupAt(5000), Is.Not.Null); + } + + [Test] + public void TestAddRemoveGroup() + { + var cpi = new ControlPointInfo(); + + var group = cpi.GroupAt(1000, true); + + Assert.That(cpi.Groups.Count, Is.EqualTo(1)); + + cpi.RemoveGroup(group); + + Assert.That(cpi.Groups.Count, Is.EqualTo(0)); + } + + [Test] + public void TestRemoveGroupAlsoRemovedControlPoints() + { + var cpi = new ControlPointInfo(); + + var group = cpi.GroupAt(1000, true); + + group.Add(new SampleControlPoint()); + + Assert.That(cpi.SamplePoints.Count, Is.EqualTo(1)); + + cpi.RemoveGroup(group); + + Assert.That(cpi.SamplePoints.Count, Is.EqualTo(0)); + } + + [Test] + public void TestAddControlPointToGroup() + { + var cpi = new ControlPointInfo(); + + var group = cpi.GroupAt(1000, true); + Assert.That(cpi.Groups.Count, Is.EqualTo(1)); + + // usually redundant, but adding to group forces it to be added + group.Add(new DifficultyControlPoint()); + + Assert.That(group.ControlPoints.Count, Is.EqualTo(1)); + Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(1)); + } + + [Test] + public void TestAddDuplicateControlPointToGroup() + { + var cpi = new ControlPointInfo(); + + var group = cpi.GroupAt(1000, true); + Assert.That(cpi.Groups.Count, Is.EqualTo(1)); + + group.Add(new DifficultyControlPoint()); + group.Add(new DifficultyControlPoint { SpeedMultiplier = 2 }); + + Assert.That(group.ControlPoints.Count, Is.EqualTo(1)); + Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(1)); + Assert.That(cpi.DifficultyPoints.First().SpeedMultiplier, Is.EqualTo(2)); + } + + [Test] + public void TestRemoveControlPointFromGroup() + { + var cpi = new ControlPointInfo(); + + var group = cpi.GroupAt(1000, true); + Assert.That(cpi.Groups.Count, Is.EqualTo(1)); + + var difficultyPoint = new DifficultyControlPoint(); + + group.Add(difficultyPoint); + group.Remove(difficultyPoint); + + Assert.That(group.ControlPoints.Count, Is.EqualTo(0)); + Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(0)); + Assert.That(cpi.AllControlPoints.Count, Is.EqualTo(0)); + } + + [Test] + public void TestOrdering() + { + var cpi = new ControlPointInfo(); + + cpi.Add(0, new TimingControlPoint()); + cpi.Add(1000, new TimingControlPoint { BeatLength = 500 }); + cpi.Add(10000, new TimingControlPoint { BeatLength = 200 }); + cpi.Add(5000, new TimingControlPoint { BeatLength = 100 }); + cpi.Add(3000, new DifficultyControlPoint { SpeedMultiplier = 2 }); + cpi.GroupAt(7000, true).Add(new DifficultyControlPoint { SpeedMultiplier = 4 }); + cpi.GroupAt(1000).Add(new SampleControlPoint { SampleVolume = 0 }); + cpi.GroupAt(8000, true).Add(new EffectControlPoint { KiaiMode = true }); + + Assert.That(cpi.AllControlPoints.Count, Is.EqualTo(8)); + + Assert.That(cpi.Groups, Is.Ordered.Ascending.By(nameof(ControlPointGroup.Time))); + + Assert.That(cpi.AllControlPoints, Is.Ordered.Ascending.By(nameof(ControlPoint.Time))); + Assert.That(cpi.TimingPoints, Is.Ordered.Ascending.By(nameof(ControlPoint.Time))); + } + + [Test] + public void TestClear() + { + var cpi = new ControlPointInfo(); + + cpi.Add(0, new TimingControlPoint()); + cpi.Add(1000, new TimingControlPoint { BeatLength = 500 }); + cpi.Add(10000, new TimingControlPoint { BeatLength = 200 }); + cpi.Add(5000, new TimingControlPoint { BeatLength = 100 }); + cpi.Add(3000, new DifficultyControlPoint { SpeedMultiplier = 2 }); + cpi.GroupAt(7000, true).Add(new DifficultyControlPoint { SpeedMultiplier = 4 }); + cpi.GroupAt(1000).Add(new SampleControlPoint { SampleVolume = 0 }); + cpi.GroupAt(8000, true).Add(new EffectControlPoint { KiaiMode = true }); + + cpi.Clear(); + + Assert.That(cpi.Groups.Count, Is.EqualTo(0)); + Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(0)); + Assert.That(cpi.AllControlPoints.Count, Is.EqualTo(0)); + } + } +} diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs new file mode 100644 index 0000000000..045246e5ed --- /dev/null +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -0,0 +1,307 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using NUnit.Framework; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Configuration; +using osu.Framework.Platform; +using osu.Game.Configuration; +using osu.Game.IO; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class CustomDataDirectoryTest : ImportTest + { + [Test] + public void TestDefaultDirectory() + { + using (var host = new CustomTestHeadlessGameHost()) + { + try + { + string defaultStorageLocation = getDefaultLocationFor(nameof(TestDefaultDirectory)); + + var osu = LoadOsuIntoHost(host); + var storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(defaultStorageLocation)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestCustomDirectory() + { + string customPath = prepareCustomPath(); + + using (var host = new CustomTestHeadlessGameHost()) + { + using (var storageConfig = new StorageConfigManager(host.InitialStorage)) + storageConfig.Set(StorageConfig.FullPath, customPath); + + try + { + var osu = LoadOsuIntoHost(host); + + // switch to DI'd storage + var storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(customPath)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestSubDirectoryLookup() + { + string customPath = prepareCustomPath(); + + using (var host = new CustomTestHeadlessGameHost()) + { + using (var storageConfig = new StorageConfigManager(host.InitialStorage)) + storageConfig.Set(StorageConfig.FullPath, customPath); + + try + { + var osu = LoadOsuIntoHost(host); + + // switch to DI'd storage + var storage = osu.Dependencies.Get(); + + string actualTestFile = Path.Combine(customPath, "rulesets", "test"); + + File.WriteAllText(actualTestFile, "test"); + + var rulesetStorage = storage.GetStorageForDirectory("rulesets"); + var lookupPath = rulesetStorage.GetFiles(".").Single(); + + Assert.That(lookupPath, Is.EqualTo("test")); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigration() + { + string customPath = prepareCustomPath(); + + using (var host = new CustomTestHeadlessGameHost()) + { + try + { + string defaultStorageLocation = getDefaultLocationFor(nameof(TestMigration)); + + var osu = LoadOsuIntoHost(host); + var storage = osu.Dependencies.Get(); + var osuStorage = storage as MigratableStorage; + + // Store the current storage's path. We'll need to refer to this for assertions in the original directory after the migration completes. + string originalDirectory = storage.GetFullPath("."); + + // ensure we perform a save + host.Dependencies.Get().Save(); + + // ensure we "use" cache + host.Storage.GetStorageForDirectory("cache"); + + // for testing nested files are not ignored (only top level) + host.Storage.GetStorageForDirectory("test-nested").GetStorageForDirectory("cache"); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(defaultStorageLocation)); + + osu.Migrate(customPath); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(customPath)); + + // ensure cache was not moved + Assert.That(Directory.Exists(Path.Combine(originalDirectory, "cache"))); + + // ensure nested cache was moved + Assert.That(!Directory.Exists(Path.Combine(originalDirectory, "test-nested", "cache"))); + Assert.That(storage.ExistsDirectory(Path.Combine("test-nested", "cache"))); + + Assert.That(osuStorage, Is.Not.Null); + + foreach (var file in osuStorage.IgnoreFiles) + { + Assert.That(File.Exists(Path.Combine(originalDirectory, file))); + Assert.That(storage.Exists(file), Is.False); + } + + foreach (var dir in osuStorage.IgnoreDirectories) + { + Assert.That(Directory.Exists(Path.Combine(originalDirectory, dir))); + Assert.That(storage.ExistsDirectory(dir), Is.False); + } + + Assert.That(new StreamReader(Path.Combine(originalDirectory, "storage.ini")).ReadToEnd().Contains($"FullPath = {customPath}")); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigrationBetweenTwoTargets() + { + string customPath = prepareCustomPath(); + string customPath2 = prepareCustomPath("-2"); + + using (var host = new CustomTestHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host); + + const string database_filename = "client.db"; + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.That(File.Exists(Path.Combine(customPath, database_filename))); + + Assert.DoesNotThrow(() => osu.Migrate(customPath2)); + Assert.That(File.Exists(Path.Combine(customPath2, database_filename))); + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.That(File.Exists(Path.Combine(customPath, database_filename))); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigrationToSameTargetFails() + { + string customPath = prepareCustomPath(); + + using (var host = new CustomTestHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host); + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + Assert.Throws(() => osu.Migrate(customPath)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigrationToNestedTargetFails() + { + string customPath = prepareCustomPath(); + + using (var host = new CustomTestHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host); + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + + string subFolder = Path.Combine(customPath, "sub"); + + if (Directory.Exists(subFolder)) + Directory.Delete(subFolder, true); + + Directory.CreateDirectory(subFolder); + + Assert.Throws(() => osu.Migrate(subFolder)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigrationToSeeminglyNestedTarget() + { + string customPath = prepareCustomPath(); + + using (var host = new CustomTestHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host); + + Assert.DoesNotThrow(() => osu.Migrate(customPath)); + + string seeminglySubFolder = customPath + "sub"; + + if (Directory.Exists(seeminglySubFolder)) + Directory.Delete(seeminglySubFolder, true); + + Directory.CreateDirectory(seeminglySubFolder); + + osu.Migrate(seeminglySubFolder); + } + finally + { + host.Exit(); + } + } + } + + private static string getDefaultLocationFor(string testTypeName) + { + string path = Path.Combine(RuntimeInfo.StartupDirectory, "headless", testTypeName); + + if (Directory.Exists(path)) + Directory.Delete(path, true); + + return path; + } + + private string prepareCustomPath(string suffix = "") + { + string path = Path.Combine(RuntimeInfo.StartupDirectory, $"custom-path{suffix}"); + + if (Directory.Exists(path)) + Directory.Delete(path, true); + + return path; + } + + public class CustomTestHeadlessGameHost : CleanRunHeadlessGameHost + { + public Storage InitialStorage { get; } + + public CustomTestHeadlessGameHost([CallerMemberName] string callingMethodName = @"") + : base(callingMethodName: callingMethodName) + { + string defaultStorageLocation = getDefaultLocationFor(callingMethodName); + + InitialStorage = new DesktopStorage(defaultStorageLocation, this); + InitialStorage.DeleteDirectory(string.Empty); + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 760a033aff..5c7adb3f49 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -94,6 +94,52 @@ namespace osu.Game.Tests.NonVisual Assert.IsTrue(combinations[2] is ModIncompatibleWithAofA); } + [Test] + public void TestMultiModFlattening() + { + var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModB(), new ModC())).CreateDifficultyAdjustmentModCombinations(); + + Assert.AreEqual(4, combinations.Length); + Assert.IsTrue(combinations[0] is ModNoMod); + Assert.IsTrue(combinations[1] is ModA); + Assert.IsTrue(combinations[2] is MultiMod); + Assert.IsTrue(combinations[3] is MultiMod); + + Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA); + Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB); + Assert.IsTrue(((MultiMod)combinations[2]).Mods[2] is ModC); + Assert.IsTrue(((MultiMod)combinations[3]).Mods[0] is ModB); + Assert.IsTrue(((MultiMod)combinations[3]).Mods[1] is ModC); + } + + [Test] + public void TestIncompatibleThroughMultiMod() + { + var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModB(), new ModIncompatibleWithA())).CreateDifficultyAdjustmentModCombinations(); + + Assert.AreEqual(3, combinations.Length); + Assert.IsTrue(combinations[0] is ModNoMod); + Assert.IsTrue(combinations[1] is ModA); + Assert.IsTrue(combinations[2] is MultiMod); + + Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModB); + Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModIncompatibleWithA); + } + + [Test] + public void TestIncompatibleWithSameInstanceViaMultiMod() + { + var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModA(), new ModB())).CreateDifficultyAdjustmentModCombinations(); + + Assert.AreEqual(3, combinations.Length); + Assert.IsTrue(combinations[0] is ModNoMod); + Assert.IsTrue(combinations[1] is ModA); + Assert.IsTrue(combinations[2] is MultiMod); + + Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA); + Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB); + } + private class ModA : Mod { public override string Name => nameof(ModA); @@ -112,6 +158,13 @@ namespace osu.Game.Tests.NonVisual public override Type[] IncompatibleMods => new[] { typeof(ModIncompatibleWithAAndB) }; } + private class ModC : Mod + { + public override string Name => nameof(ModC); + public override string Acronym => nameof(ModC); + public override double ScoreMultiplier => 1; + } + private class ModIncompatibleWithA : Mod { public override string Name => $"Incompatible With {nameof(ModA)}"; diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs new file mode 100644 index 0000000000..24a0a662ba --- /dev/null +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -0,0 +1,218 @@ +// 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; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Carousel; + +namespace osu.Game.Tests.NonVisual.Filtering +{ + [TestFixture] + public class FilterMatchingTest + { + private BeatmapInfo getExampleBeatmap() => new BeatmapInfo + { + Ruleset = new RulesetInfo { ID = 5 }, + StarDifficulty = 4.0d, + BaseDifficulty = new BeatmapDifficulty + { + ApproachRate = 5.0f, + DrainRate = 3.0f, + CircleSize = 2.0f, + }, + Metadata = new BeatmapMetadata + { + Artist = "The Artist", + ArtistUnicode = "check unicode too", + Title = "Title goes here", + TitleUnicode = "Title goes here", + AuthorString = "The Author", + Source = "unit tests", + Tags = "look for tags too", + }, + Version = "version as well", + Length = 2500, + BPM = 160, + BeatDivisor = 12, + Status = BeatmapSetOnlineStatus.Loved + }; + + [Test] + public void TestCriteriaMatchingNoRuleset() + { + var exampleBeatmapInfo = getExampleBeatmap(); + var criteria = new FilterCriteria(); + var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); + carouselItem.Filter(criteria); + Assert.IsFalse(carouselItem.Filtered.Value); + } + + [Test] + public void TestCriteriaMatchingSpecificRuleset() + { + var exampleBeatmapInfo = getExampleBeatmap(); + var criteria = new FilterCriteria + { + Ruleset = new RulesetInfo { ID = 6 } + }; + var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); + carouselItem.Filter(criteria); + Assert.IsTrue(carouselItem.Filtered.Value); + } + + [Test] + public void TestCriteriaMatchingConvertedBeatmaps() + { + var exampleBeatmapInfo = getExampleBeatmap(); + var criteria = new FilterCriteria + { + Ruleset = new RulesetInfo { ID = 6 }, + AllowConvertedBeatmaps = true + }; + var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); + carouselItem.Filter(criteria); + Assert.IsFalse(carouselItem.Filtered.Value); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void TestCriteriaMatchingRangeMin(bool inclusive) + { + var exampleBeatmapInfo = getExampleBeatmap(); + var criteria = new FilterCriteria + { + Ruleset = new RulesetInfo { ID = 6 }, + AllowConvertedBeatmaps = true, + ApproachRate = new FilterCriteria.OptionalRange + { + IsLowerInclusive = inclusive, + Min = 5.0f + } + }; + var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); + carouselItem.Filter(criteria); + Assert.AreEqual(!inclusive, carouselItem.Filtered.Value); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void TestCriteriaMatchingRangeMax(bool inclusive) + { + var exampleBeatmapInfo = getExampleBeatmap(); + var criteria = new FilterCriteria + { + Ruleset = new RulesetInfo { ID = 6 }, + AllowConvertedBeatmaps = true, + BPM = new FilterCriteria.OptionalRange + { + IsUpperInclusive = inclusive, + Max = 160d + } + }; + var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); + carouselItem.Filter(criteria); + Assert.AreEqual(!inclusive, carouselItem.Filtered.Value); + } + + [Test] + [TestCase("artist", false)] + [TestCase("artist title author", false)] + [TestCase("an artist", true)] + [TestCase("tags too", false)] + [TestCase("version", false)] + [TestCase("an auteur", true)] + public void TestCriteriaMatchingTerms(string terms, bool filtered) + { + var exampleBeatmapInfo = getExampleBeatmap(); + var criteria = new FilterCriteria + { + Ruleset = new RulesetInfo { ID = 6 }, + AllowConvertedBeatmaps = true, + SearchText = terms + }; + var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); + carouselItem.Filter(criteria); + Assert.AreEqual(filtered, carouselItem.Filtered.Value); + } + + [Test] + [TestCase("", false)] + [TestCase("The", false)] + [TestCase("THE", false)] + [TestCase("author", false)] + [TestCase("the author", false)] + [TestCase("the author AND then something else", true)] + [TestCase("unknown", true)] + public void TestCriteriaMatchingCreator(string creatorName, bool filtered) + { + var exampleBeatmapInfo = getExampleBeatmap(); + var criteria = new FilterCriteria + { + Creator = new FilterCriteria.OptionalTextFilter { SearchTerm = creatorName } + }; + var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); + carouselItem.Filter(criteria); + Assert.AreEqual(filtered, carouselItem.Filtered.Value); + } + + [Test] + [TestCase("", false)] + [TestCase("The", false)] + [TestCase("THE", false)] + [TestCase("artist", false)] + [TestCase("the artist", false)] + [TestCase("the artist AND then something else", true)] + [TestCase("unicode too", false)] + [TestCase("unknown", true)] + public void TestCriteriaMatchingArtist(string artistName, bool filtered) + { + var exampleBeatmapInfo = getExampleBeatmap(); + var criteria = new FilterCriteria + { + Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = artistName } + }; + var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); + carouselItem.Filter(criteria); + Assert.AreEqual(filtered, carouselItem.Filtered.Value); + } + + [Test] + [TestCase("", false)] + [TestCase("artist", false)] + [TestCase("unknown", true)] + public void TestCriteriaMatchingArtistWithNullUnicodeName(string artistName, bool filtered) + { + var exampleBeatmapInfo = getExampleBeatmap(); + exampleBeatmapInfo.Metadata.ArtistUnicode = null; + + var criteria = new FilterCriteria + { + Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = artistName } + }; + var carouselItem = new CarouselBeatmap(exampleBeatmapInfo); + carouselItem.Filter(criteria); + Assert.AreEqual(filtered, carouselItem.Filtered.Value); + } + + [TestCase("202010", true)] + [TestCase("20201010", false)] + [TestCase("153", true)] + [TestCase("1535", false)] + public void TestCriteriaMatchingBeatmapIDs(string query, bool filtered) + { + var beatmap = getExampleBeatmap(); + beatmap.OnlineBeatmapID = 20201010; + beatmap.BeatmapSet = new BeatmapSetInfo { OnlineBeatmapSetID = 1535 }; + + var criteria = new FilterCriteria { SearchText = query }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.AreEqual(filtered, carouselItem.Filtered.Value); + } + } +} diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs new file mode 100644 index 0000000000..d15682b1eb --- /dev/null +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.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; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Screens.Select; + +namespace osu.Game.Tests.NonVisual.Filtering +{ + [TestFixture] + public class FilterQueryParserTest + { + [Test] + public void TestApplyQueriesBareWords() + { + const string query = "looking for a beatmap"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("looking for a beatmap", filterCriteria.SearchText); + Assert.AreEqual(4, filterCriteria.SearchTerms.Length); + } + + /* + * The following tests have been written a bit strangely (they don't check exact + * bound equality with what the filter says). + * This is to account for floating-point arithmetic issues. + * For example, specifying a bpm<140 filter would previously match beatmaps with BPM + * of 139.99999, which would be displayed in the UI as 140. + * Due to this the tests check the last tick inside the range and the first tick + * outside of the range. + */ + + [Test] + public void TestApplyStarQueries() + { + const string query = "stars<4 easy"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("easy", filterCriteria.SearchText.Trim()); + Assert.AreEqual(1, filterCriteria.SearchTerms.Length); + Assert.IsNotNull(filterCriteria.StarDifficulty.Max); + Assert.Greater(filterCriteria.StarDifficulty.Max, 3.99d); + Assert.Less(filterCriteria.StarDifficulty.Max, 4.00d); + Assert.IsNull(filterCriteria.StarDifficulty.Min); + } + + [Test] + public void TestApplyApproachRateQueries() + { + const string query = "ar>=9 difficult"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("difficult", filterCriteria.SearchText.Trim()); + Assert.AreEqual(1, filterCriteria.SearchTerms.Length); + Assert.IsNotNull(filterCriteria.ApproachRate.Min); + Assert.Greater(filterCriteria.ApproachRate.Min, 8.9f); + Assert.Less(filterCriteria.ApproachRate.Min, 9.0f); + Assert.IsNull(filterCriteria.ApproachRate.Max); + } + + [Test] + public void TestApplyDrainRateQueriesByDrKeyword() + { + const string query = "dr>2 quite specific dr<:6"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("quite specific", filterCriteria.SearchText.Trim()); + Assert.AreEqual(2, filterCriteria.SearchTerms.Length); + Assert.Greater(filterCriteria.DrainRate.Min, 2.0f); + Assert.Less(filterCriteria.DrainRate.Min, 2.1f); + Assert.Greater(filterCriteria.DrainRate.Max, 6.0f); + Assert.Less(filterCriteria.DrainRate.Min, 6.1f); + } + + [Test] + public void TestApplyDrainRateQueriesByHpKeyword() + { + const string query = "hp>2 quite specific hp<=6"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("quite specific", filterCriteria.SearchText.Trim()); + Assert.AreEqual(2, filterCriteria.SearchTerms.Length); + Assert.Greater(filterCriteria.DrainRate.Min, 2.0f); + Assert.Less(filterCriteria.DrainRate.Min, 2.1f); + Assert.Greater(filterCriteria.DrainRate.Max, 6.0f); + Assert.Less(filterCriteria.DrainRate.Min, 6.1f); + } + + [Test] + public void TestApplyBPMQueries() + { + const string query = "bpm>:200 gotta go fast"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("gotta go fast", filterCriteria.SearchText.Trim()); + Assert.AreEqual(3, filterCriteria.SearchTerms.Length); + Assert.IsNotNull(filterCriteria.BPM.Min); + Assert.Greater(filterCriteria.BPM.Min, 199.99d); + Assert.Less(filterCriteria.BPM.Min, 200.00d); + Assert.IsNull(filterCriteria.BPM.Max); + } + + private static readonly object[] length_query_examples = + { + new object[] { "6ms", TimeSpan.FromMilliseconds(6), TimeSpan.FromMilliseconds(1) }, + new object[] { "23s", TimeSpan.FromSeconds(23), TimeSpan.FromSeconds(1) }, + new object[] { "9m", TimeSpan.FromMinutes(9), TimeSpan.FromMinutes(1) }, + new object[] { "0.25h", TimeSpan.FromHours(0.25), TimeSpan.FromHours(1) }, + new object[] { "70", TimeSpan.FromSeconds(70), TimeSpan.FromSeconds(1) }, + }; + + [Test] + [TestCaseSource(nameof(length_query_examples))] + public void TestApplyLengthQueries(string lengthQuery, TimeSpan expectedLength, TimeSpan scale) + { + string query = $"length={lengthQuery} time"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("time", filterCriteria.SearchText.Trim()); + Assert.AreEqual(1, filterCriteria.SearchTerms.Length); + Assert.AreEqual(expectedLength.TotalMilliseconds - scale.TotalMilliseconds / 2.0, filterCriteria.Length.Min); + Assert.AreEqual(expectedLength.TotalMilliseconds + scale.TotalMilliseconds / 2.0, filterCriteria.Length.Max); + } + + [Test] + public void TestApplyDivisorQueries() + { + const string query = "that's a time signature alright! divisor:12"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("that's a time signature alright!", filterCriteria.SearchText.Trim()); + Assert.AreEqual(5, filterCriteria.SearchTerms.Length); + Assert.AreEqual(12, filterCriteria.BeatDivisor.Min); + Assert.IsTrue(filterCriteria.BeatDivisor.IsLowerInclusive); + Assert.AreEqual(12, filterCriteria.BeatDivisor.Max); + Assert.IsTrue(filterCriteria.BeatDivisor.IsUpperInclusive); + } + + [Test] + public void TestApplyStatusQueries() + { + const string query = "I want the pp status=ranked"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("I want the pp", filterCriteria.SearchText.Trim()); + Assert.AreEqual(4, filterCriteria.SearchTerms.Length); + Assert.AreEqual(BeatmapSetOnlineStatus.Ranked, filterCriteria.OnlineStatus.Min); + Assert.IsTrue(filterCriteria.OnlineStatus.IsLowerInclusive); + Assert.AreEqual(BeatmapSetOnlineStatus.Ranked, filterCriteria.OnlineStatus.Max); + Assert.IsTrue(filterCriteria.OnlineStatus.IsUpperInclusive); + } + + [Test] + public void TestApplyCreatorQueries() + { + const string query = "beatmap specifically by creator=my_fav"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("beatmap specifically by", filterCriteria.SearchText.Trim()); + Assert.AreEqual(3, filterCriteria.SearchTerms.Length); + Assert.AreEqual("my_fav", filterCriteria.Creator.SearchTerm); + } + + [Test] + public void TestApplyArtistQueries() + { + const string query = "find me songs by artist=singer please"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("find me songs by please", filterCriteria.SearchText.Trim()); + Assert.AreEqual(5, filterCriteria.SearchTerms.Length); + Assert.AreEqual("singer", filterCriteria.Artist.SearchTerm); + } + + [Test] + public void TestApplyArtistQueriesWithSpaces() + { + const string query = "really like artist=\"name with space\" yes"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("really like yes", filterCriteria.SearchText.Trim()); + Assert.AreEqual(3, filterCriteria.SearchTerms.Length); + Assert.AreEqual("name with space", filterCriteria.Artist.SearchTerm); + } + + [Test] + public void TestApplyArtistQueriesOneDoubleQuote() + { + const string query = "weird artist=double\"quote"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("weird", filterCriteria.SearchText.Trim()); + Assert.AreEqual(1, filterCriteria.SearchTerms.Length); + Assert.AreEqual("double\"quote", filterCriteria.Artist.SearchTerm); + } + } +} diff --git a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs index 18cbd4e7c5..92a60663de 100644 --- a/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs +++ b/osu.Game.Tests/NonVisual/FramedReplayInputHandlerTest.cs @@ -46,12 +46,12 @@ namespace osu.Game.Tests.NonVisual confirmCurrentFrame(0); confirmNextFrame(1); - //if we hit the first frame perfectly, time should progress to it. + // 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. + // in between non-important frames should progress based on input. setTime(1200, 1200); confirmCurrentFrame(1); @@ -144,7 +144,7 @@ namespace osu.Game.Tests.NonVisual confirmCurrentFrame(2); confirmNextFrame(1); - //ensure each frame plays out until start + // ensure each frame plays out until start setTime(-500, 1000); confirmCurrentFrame(1); confirmNextFrame(0); @@ -225,8 +225,10 @@ namespace osu.Game.Tests.NonVisual 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"); } diff --git a/osu.Game.Tests/NonVisual/GameplayClockTest.cs b/osu.Game.Tests/NonVisual/GameplayClockTest.cs new file mode 100644 index 0000000000..3fd7c364b7 --- /dev/null +++ b/osu.Game.Tests/NonVisual/GameplayClockTest.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.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Timing; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class GameplayClockTest + { + [TestCase(0)] + [TestCase(1)] + public void TestTrueGameplayRateWithZeroAdjustment(double underlyingClockRate) + { + var framedClock = new FramedClock(new ManualClock { Rate = underlyingClockRate }); + var gameplayClock = new TestGameplayClock(framedClock); + + gameplayClock.MutableNonGameplayAdjustments.Add(new BindableDouble()); + + Assert.That(gameplayClock.TrueGameplayRate, Is.EqualTo(0)); + } + + private class TestGameplayClock : GameplayClock + { + public List> MutableNonGameplayAdjustments { get; } = new List>(); + + public override IEnumerable> NonGameplayAdjustments => MutableNonGameplayAdjustments; + + public TestGameplayClock(IFrameBasedClock underlyingClock) + : base(underlyingClock) + { + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs b/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.cs new file mode 100644 index 0000000000..a04415bc7f --- /dev/null +++ b/osu.Game.Tests/NonVisual/LimitedCapacityQueueTest.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; +using NUnit.Framework; +using osu.Game.Rulesets.Difficulty.Utils; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class LimitedCapacityQueueTest + { + private const int capacity = 3; + + private LimitedCapacityQueue queue; + + [SetUp] + public void SetUp() + { + queue = new LimitedCapacityQueue(capacity); + } + + [Test] + public void TestEmptyQueue() + { + Assert.AreEqual(0, queue.Count); + + Assert.Throws(() => _ = queue[0]); + + Assert.Throws(() => _ = queue.Dequeue()); + + int count = 0; + foreach (var _ in queue) + count++; + + Assert.AreEqual(0, count); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + public void TestBelowCapacity(int count) + { + for (int i = 0; i < count; ++i) + queue.Enqueue(i); + + Assert.AreEqual(count, queue.Count); + + for (int i = 0; i < count; ++i) + Assert.AreEqual(i, queue[i]); + + int j = 0; + foreach (var item in queue) + Assert.AreEqual(j++, item); + + for (int i = queue.Count; i < queue.Count + capacity; i++) + Assert.Throws(() => _ = queue[i]); + } + + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + public void TestEnqueueAtFullCapacity(int count) + { + for (int i = 0; i < count; ++i) + queue.Enqueue(i); + + Assert.AreEqual(capacity, queue.Count); + + for (int i = 0; i < queue.Count; ++i) + Assert.AreEqual(count - capacity + i, queue[i]); + + int j = count - capacity; + foreach (var item in queue) + Assert.AreEqual(j++, item); + + for (int i = queue.Count; i < queue.Count + capacity; i++) + Assert.Throws(() => _ = queue[i]); + } + + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + public void TestDequeueAtFullCapacity(int count) + { + for (int i = 0; i < count; ++i) + queue.Enqueue(i); + + for (int i = 0; i < capacity; ++i) + { + Assert.AreEqual(count - capacity + i, queue.Dequeue()); + Assert.AreEqual(2 - i, queue.Count); + } + + Assert.Throws(() => queue.Dequeue()); + } + + [Test] + public void TestClearQueue() + { + queue.Enqueue(3); + queue.Enqueue(5); + Assert.AreEqual(2, queue.Count); + + queue.Clear(); + Assert.AreEqual(0, queue.Count); + Assert.Throws(() => _ = queue[0]); + + queue.Enqueue(7); + Assert.AreEqual(1, queue.Count); + Assert.AreEqual(7, queue[0]); + Assert.Throws(() => _ = queue[1]); + + queue.Enqueue(9); + Assert.AreEqual(2, queue.Count); + Assert.AreEqual(9, queue[1]); + } + } +} diff --git a/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs b/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs index 1c78b63499..d5ac38008e 100644 --- a/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs +++ b/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs @@ -25,7 +25,7 @@ namespace osu.Game.Tests.NonVisual { Assert.AreEqual(0, stack.Count); - Assert.Throws(() => + Assert.Throws(() => { int unused = stack[0]; }); @@ -55,7 +55,7 @@ namespace osu.Game.Tests.NonVisual // e.g. indices 3, 4, 5, 6 (out of range) for (int i = stack.Count; i < stack.Count + capacity; i++) { - Assert.Throws(() => + Assert.Throws(() => { int unused = stack[i]; }); @@ -80,7 +80,7 @@ namespace osu.Game.Tests.NonVisual // e.g. indices 3, 4, 5, 6 (out of range) for (int i = stack.Count; i < stack.Count + capacity; i++) { - Assert.Throws(() => + Assert.Throws(() => { int unused = stack[i]; }); diff --git a/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs b/osu.Game.Tests/NonVisual/PeriodTrackerTest.cs new file mode 100644 index 0000000000..62c7732b66 --- /dev/null +++ b/osu.Game.Tests/NonVisual/PeriodTrackerTest.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; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Utils; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class PeriodTrackerTest + { + private static readonly Period[] single_period = { new Period(1.0, 2.0) }; + + private static readonly Period[] unordered_periods = + { + new Period(-9.1, -8.3), + new Period(-3.4, 2.1), + new Period(9.0, 50.0), + new Period(5.25, 10.50) + }; + + [Test] + public void TestCheckValueInsideSinglePeriod() + { + var tracker = new PeriodTracker(single_period); + + var period = single_period.Single(); + Assert.IsTrue(tracker.IsInAny(period.Start)); + Assert.IsTrue(tracker.IsInAny(getMidpoint(period))); + Assert.IsTrue(tracker.IsInAny(period.End)); + } + + [Test] + public void TestCheckValuesInsidePeriods() + { + var tracker = new PeriodTracker(unordered_periods); + + foreach (var period in unordered_periods) + Assert.IsTrue(tracker.IsInAny(getMidpoint(period))); + } + + [Test] + public void TestCheckValuesInRandomOrder() + { + var tracker = new PeriodTracker(unordered_periods); + + foreach (var period in unordered_periods.OrderBy(_ => RNG.Next())) + Assert.IsTrue(tracker.IsInAny(getMidpoint(period))); + } + + [Test] + public void TestCheckValuesOutOfPeriods() + { + var tracker = new PeriodTracker(new[] + { + new Period(1.0, 2.0), + new Period(3.0, 4.0) + }); + + Assert.IsFalse(tracker.IsInAny(0.9), "Time before first period is being considered inside"); + + Assert.IsFalse(tracker.IsInAny(2.1), "Time right after first period is being considered inside"); + Assert.IsFalse(tracker.IsInAny(2.9), "Time right before second period is being considered inside"); + + Assert.IsFalse(tracker.IsInAny(4.1), "Time after last period is being considered inside"); + } + + [Test] + public void TestReversedPeriodHandling() + { + Assert.Throws(() => + { + _ = new PeriodTracker(new[] + { + new Period(2.0, 1.0) + }); + }); + } + + private double getMidpoint(Period period) => period.Start + (period.End - period.Start) / 2; + } +} diff --git a/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.cs new file mode 100644 index 0000000000..ad6f01881b --- /dev/null +++ b/osu.Game.Tests/NonVisual/Ranking/UnstableRateTest.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 System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Ranking.Statistics; + +namespace osu.Game.Tests.NonVisual.Ranking +{ + [TestFixture] + public class UnstableRateTest + { + [Test] + public void TestDistributedHits() + { + var events = Enumerable.Range(-5, 11) + .Select(t => new HitEvent(t - 5, HitResult.Great, new HitObject(), null, null)); + + var unstableRate = new UnstableRate(events); + + Assert.IsTrue(Precision.AlmostEquals(unstableRate.Value, 10 * Math.Sqrt(10))); + } + + [Test] + public void TestMissesAndEmptyWindows() + { + var events = new[] + { + new HitEvent(-100, HitResult.Miss, new HitObject(), null, null), + new HitEvent(0, HitResult.Great, new HitObject(), null, null), + new HitEvent(200, HitResult.Meh, new HitObject { HitWindows = HitWindows.Empty }, null, null), + }; + + var unstableRate = new UnstableRate(events); + + Assert.AreEqual(0, unstableRate.Value); + } + } +} diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs new file mode 100644 index 0000000000..69e66942ab --- /dev/null +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs @@ -0,0 +1,110 @@ +// 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.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; +using osu.Game.Skinning; + +namespace osu.Game.Tests.NonVisual.Skinning +{ + [TestFixture] + public sealed class LegacySkinTextureFallbackTest + { + private static object[][] fallbackTestCases = + { + new object[] + { + // textures in store + new[] { "Gameplay/osu/followpoint@2x", "Gameplay/osu/followpoint" }, + // requested component + "Gameplay/osu/followpoint", + // returned texture name & scale + "Gameplay/osu/followpoint@2x", 2 + }, + new object[] + { + new[] { "Gameplay/osu/followpoint@2x" }, + "Gameplay/osu/followpoint", + "Gameplay/osu/followpoint@2x", 2 + }, + new object[] + { + new[] { "Gameplay/osu/followpoint" }, + "Gameplay/osu/followpoint", + "Gameplay/osu/followpoint", 1 + }, + new object[] + { + new[] { "Gameplay/osu/followpoint", "followpoint@2x" }, + "Gameplay/osu/followpoint", + "Gameplay/osu/followpoint", 1 + }, + new object[] + { + new[] { "followpoint@2x", "followpoint" }, + "Gameplay/osu/followpoint", + "followpoint@2x", 2 + }, + new object[] + { + new[] { "followpoint@2x" }, + "Gameplay/osu/followpoint", + "followpoint@2x", 2 + }, + new object[] + { + new[] { "followpoint" }, + "Gameplay/osu/followpoint", + "followpoint", 1 + }, + }; + + [TestCaseSource(nameof(fallbackTestCases))] + public void TestFallbackOrder(string[] filesInStore, string requestedComponent, string expectedTexture, float expectedScale) + { + var textureStore = new TestTextureStore(filesInStore); + var legacySkin = new TestLegacySkin(textureStore); + + var texture = legacySkin.GetTexture(requestedComponent); + + Assert.IsNotNull(texture); + Assert.AreEqual(textureStore.Textures[expectedTexture], texture); + Assert.AreEqual(expectedScale, texture.ScaleAdjust); + } + + [Test] + public void TestReturnNullOnFallbackFailure() + { + var textureStore = new TestTextureStore("sliderb", "hit100"); + var legacySkin = new TestLegacySkin(textureStore); + + var texture = legacySkin.GetTexture("Gameplay/osu/followpoint"); + + Assert.IsNull(texture); + } + + private class TestLegacySkin : LegacySkin + { + public TestLegacySkin(TextureStore textureStore) + : base(new SkinInfo(), null, null, string.Empty) + { + Textures = textureStore; + } + } + + private class TestTextureStore : TextureStore + { + public readonly Dictionary Textures; + + public TestTextureStore(params string[] fileNames) + { + Textures = fileNames.ToDictionary(fileName => fileName, fileName => new Texture(1, 1)); + } + + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) => Textures.GetValueOrDefault(name); + } + } +} diff --git a/osu.Game.Tests/NonVisual/StreamingFramedReplayInputHandlerTest.cs b/osu.Game.Tests/NonVisual/StreamingFramedReplayInputHandlerTest.cs new file mode 100644 index 0000000000..21ec29b10b --- /dev/null +++ b/osu.Game.Tests/NonVisual/StreamingFramedReplayInputHandlerTest.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 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/TestAPIModSerialization.cs b/osu.Game.Tests/Online/TestAPIModSerialization.cs new file mode 100644 index 0000000000..5948582d77 --- /dev/null +++ b/osu.Game.Tests/Online/TestAPIModSerialization.cs @@ -0,0 +1,139 @@ +// 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 NUnit.Framework; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Tests.Online +{ + [TestFixture] + public class TestAPIModSerialization + { + [Test] + public void TestAcronymIsPreserved() + { + var apiMod = new APIMod(new TestMod()); + + var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + + Assert.That(deserialized.Acronym, Is.EqualTo(apiMod.Acronym)); + } + + [Test] + public void TestRawSettingIsPreserved() + { + var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } }); + + var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + + Assert.That(deserialized.Settings, Contains.Key("test_setting").With.ContainValue(2.0)); + } + + [Test] + public void TestConvertedModHasCorrectSetting() + { + var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } }); + + var deserialized = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + var converted = (TestMod)deserialized.ToMod(new TestRuleset()); + + Assert.That(converted.TestSetting.Value, Is.EqualTo(2)); + } + + [Test] + public void TestDeserialiseTimeRampMod() + { + // Create the mod with values different from default. + var apiMod = new APIMod(new TestModTimeRamp + { + AdjustPitch = { Value = false }, + InitialRate = { Value = 1.25 }, + FinalRate = { Value = 0.25 } + }); + + var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(apiMod)); + 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)); + } + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => new Mod[] + { + new TestMod(), + new TestModTimeRamp(), + }; + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new System.NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => throw new System.NotImplementedException(); + + public override string Description { get; } = string.Empty; + public override string ShortName { get; } = string.Empty; + } + + private class TestMod : Mod + { + public override string Name => "Test Mod"; + public override string Acronym => "TM"; + public override double ScoreMultiplier => 1; + + [SettingSource("Test")] + public BindableNumber TestSetting { get; } = new BindableDouble + { + MinValue = 0, + MaxValue = 10, + Default = 5, + Precision = 0.01, + }; + } + + private class TestModTimeRamp : ModTimeRamp + { + public override string Name => "Test Mod"; + public override string Acronym => "TMTR"; + public override double ScoreMultiplier => 1; + + [SettingSource("Initial rate", "The starting speed of the track")] + public override BindableNumber InitialRate { get; } = new BindableDouble + { + MinValue = 1, + MaxValue = 2, + Default = 1.5, + Value = 1.5, + Precision = 0.01, + }; + + [SettingSource("Final rate", "The speed increase to ramp towards")] + public override BindableNumber FinalRate { get; } = new BindableDouble + { + MinValue = 0, + MaxValue = 1, + Default = 0.5, + Value = 0.5, + Precision = 0.01, + }; + + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public override BindableBool AdjustPitch { get; } = new BindableBool + { + Default = true, + Value = true + }; + } + } +} diff --git a/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs new file mode 100644 index 0000000000..42948c3731 --- /dev/null +++ b/osu.Game.Tests/Online/TestDummyAPIRequestHandling.cs @@ -0,0 +1,116 @@ +// 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.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Online +{ + [HeadlessTest] + public class TestDummyAPIRequestHandling : OsuTestScene + { + [Test] + public void TestGenericRequestHandling() + { + AddStep("register request handling", () => ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case CommentVoteRequest cRequest: + cRequest.TriggerSuccess(new CommentBundle()); + break; + } + }); + + CommentVoteRequest request = null; + CommentBundle response = null; + + AddStep("fire request", () => + { + response = null; + request = new CommentVoteRequest(1, CommentVoteAction.Vote); + request.Success += res => response = res; + API.Queue(request); + }); + + AddAssert("response event fired", () => response != null); + + AddAssert("request has response", () => request.Result == response); + } + + [Test] + public void TestQueueRequestHandling() + { + registerHandler(); + + LeaveChannelRequest request; + bool gotResponse = false; + + AddStep("fire request", () => + { + gotResponse = false; + request = new LeaveChannelRequest(new Channel()); + request.Success += () => gotResponse = true; + API.Queue(request); + }); + + AddAssert("response event fired", () => gotResponse); + } + + [Test] + public void TestPerformRequestHandling() + { + registerHandler(); + + LeaveChannelRequest request; + bool gotResponse = false; + + AddStep("fire request", () => + { + gotResponse = false; + request = new LeaveChannelRequest(new Channel()); + request.Success += () => gotResponse = true; + API.Perform(request); + }); + + AddAssert("response event fired", () => gotResponse); + } + + [Test] + public void TestPerformAsyncRequestHandling() + { + registerHandler(); + + LeaveChannelRequest request; + bool gotResponse = false; + + AddStep("fire request", () => + { + gotResponse = false; + request = new LeaveChannelRequest(new Channel()); + request.Success += () => gotResponse = true; + API.PerformAsync(request); + }); + + AddAssert("response event fired", () => gotResponse); + } + + private void registerHandler() + { + AddStep("register request handling", () => ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case LeaveChannelRequest cRequest: + cRequest.TriggerSuccess(); + break; + } + }); + } + } +} diff --git a/osu.Game.Tests/Online/TestSceneBeatmapManager.cs b/osu.Game.Tests/Online/TestSceneBeatmapManager.cs new file mode 100644 index 0000000000..0ae0186770 --- /dev/null +++ b/osu.Game.Tests/Online/TestSceneBeatmapManager.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Overlays.Notifications; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Online +{ + [HeadlessTest] + public class TestSceneBeatmapManager : OsuTestScene + { + private BeatmapManager beatmaps; + private ProgressNotification recentNotification; + + private static readonly BeatmapSetInfo test_model = new BeatmapSetInfo { OnlineBeatmapSetID = 1 }; + + [BackgroundDependencyLoader] + private void load(BeatmapManager beatmaps) + { + this.beatmaps = beatmaps; + + beatmaps.PostNotification = n => recentNotification = n as ProgressNotification; + } + + [Test] + public void TestCancelDownloadFromRequest() + { + AddStep("download beatmap", () => beatmaps.Download(test_model)); + + AddStep("cancel download from request", () => beatmaps.GetExistingDownload(test_model).Cancel()); + + AddUntilStep("is removed from download list", () => beatmaps.GetExistingDownload(test_model) == null); + AddAssert("is notification cancelled", () => recentNotification.State == ProgressNotificationState.Cancelled); + } + + [Test] + public void TestCancelDownloadFromNotification() + { + AddStep("download beatmap", () => beatmaps.Download(test_model)); + + AddStep("cancel download from notification", () => recentNotification.Close()); + + AddUntilStep("is removed from download list", () => beatmaps.GetExistingDownload(test_model) == null); + AddAssert("is notification cancelled", () => recentNotification.State == ProgressNotificationState.Cancelled); + } + } +} diff --git a/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus.osz b/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus.osz new file mode 100644 index 0000000000..987dbea6db Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus.osz differ diff --git a/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus_virtual.osz b/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus_virtual.osz new file mode 100644 index 0000000000..8a92423d35 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/241526 Soleily - Renatus_virtual.osz differ diff --git a/osu.Game.Tests/Resources/Archives/ogg-beatmap.osz b/osu.Game.Tests/Resources/Archives/ogg-beatmap.osz new file mode 100644 index 0000000000..f264a8dda2 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/ogg-beatmap.osz differ diff --git a/osu.Game.Tests/Resources/Archives/ogg-skin.osk b/osu.Game.Tests/Resources/Archives/ogg-skin.osk new file mode 100644 index 0000000000..d7379446aa Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/ogg-skin.osk differ diff --git a/osu.Game.Tests/Resources/Collections/collections.db b/osu.Game.Tests/Resources/Collections/collections.db new file mode 100644 index 0000000000..83e1c0f10a Binary files /dev/null and b/osu.Game.Tests/Resources/Collections/collections.db differ diff --git a/osu.Game.Tests/Resources/Replays/mania-replay.osr b/osu.Game.Tests/Resources/Replays/mania-replay.osr new file mode 100644 index 0000000000..da1a7bdd28 Binary files /dev/null and b/osu.Game.Tests/Resources/Replays/mania-replay.osr differ diff --git a/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-custom-sample.osu b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-custom-sample.osu new file mode 100644 index 0000000000..91dbc6a60e --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-custom-sample.osu @@ -0,0 +1,7 @@ +osu file format v14 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-sample.osu b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-sample.osu new file mode 100644 index 0000000000..3274820100 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/controlpoint-beatmap-sample.osu @@ -0,0 +1,7 @@ +osu file format v14 + +[TimingPoints] +0,300,4,0,1,100,1,0 + +[HitObjects] +444,320,1000,5,0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/controlpoint-skin-sample.osu b/osu.Game.Tests/Resources/SampleLookups/controlpoint-skin-sample.osu new file mode 100644 index 0000000000..c53ec465fb --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/controlpoint-skin-sample.osu @@ -0,0 +1,7 @@ +osu file format v14 + +[TimingPoints] +0,300,4,0,0,100,1,0 + +[HitObjects] +444,320,1000,5,0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/file-beatmap-sample.osu b/osu.Game.Tests/Resources/SampleLookups/file-beatmap-sample.osu new file mode 100644 index 0000000000..65b5ea8707 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/file-beatmap-sample.osu @@ -0,0 +1,4 @@ +osu file format v14 + +[HitObjects] +255,193,2170,1,0,0:0:0:0:hit_1.wav diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-bank.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-bank.osu new file mode 100644 index 0000000000..c50c921839 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-bank.osu @@ -0,0 +1,7 @@ +osu file format v14 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,2,0:0:0:0: diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-override.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-override.osu new file mode 100644 index 0000000000..13dc2faab1 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample-override.osu @@ -0,0 +1,7 @@ +osu file format v14 + +[TimingPoints] +0,300,4,0,2,100,1,0 + +[HitObjects] +444,320,1000,5,0,0:0:3:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample.osu new file mode 100644 index 0000000000..4ab672dbb0 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-custom-sample.osu @@ -0,0 +1,4 @@ +osu file format v14 + +[HitObjects] +444,320,1000,5,0,0:0:2:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-sample.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-sample.osu new file mode 100644 index 0000000000..33bc34949a --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-beatmap-sample.osu @@ -0,0 +1,4 @@ +osu file format v14 + +[HitObjects] +444,320,1000,5,0,0:0:1:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/SampleLookups/hitobject-skin-sample.osu b/osu.Game.Tests/Resources/SampleLookups/hitobject-skin-sample.osu new file mode 100644 index 0000000000..47f5b44c90 --- /dev/null +++ b/osu.Game.Tests/Resources/SampleLookups/hitobject-skin-sample.osu @@ -0,0 +1,4 @@ +osu file format v14 + +[HitObjects] +444,320,1000,5,0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/Samples/test-sample.mp3 b/osu.Game.Tests/Resources/Samples/test-sample.mp3 new file mode 100644 index 0000000000..f7c344f39a Binary files /dev/null and b/osu.Game.Tests/Resources/Samples/test-sample.mp3 differ diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index 9cb85a63bf..e882229570 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -9,20 +9,22 @@ namespace osu.Game.Tests.Resources { public static class TestResources { - public static Stream OpenResource(string name) => new DllResourceStore("osu.Game.Tests.dll").GetStream($"Resources/{name}"); + public static DllResourceStore GetStore() => new DllResourceStore(typeof(TestResources).Assembly); - public static Stream GetTestBeatmapStream() => new DllResourceStore("osu.Game.Resources.dll").GetStream("Beatmaps/241526 Soleily - Renatus.osz"); + public static Stream OpenResource(string name) => GetStore().GetStream($"Resources/{name}"); - public static string GetTestBeatmapForImport() + public static Stream GetTestBeatmapStream(bool virtualTrack = false) => OpenResource($"Archives/241526 Soleily - Renatus{(virtualTrack ? "_virtual" : "")}.osz"); + + public static string GetTestBeatmapForImport(bool virtualTrack = false) { - var temp = Path.GetTempFileName() + ".osz"; + var tempPath = Path.GetTempFileName() + ".osz"; - using (var stream = GetTestBeatmapStream()) - using (var newFile = File.Create(temp)) + using (var stream = GetTestBeatmapStream(virtualTrack)) + using (var newFile = File.Create(tempPath)) stream.CopyTo(newFile); - Assert.IsTrue(File.Exists(temp)); - return temp; + Assert.IsTrue(File.Exists(tempPath)); + return tempPath; } } } diff --git a/osu.Game.Tests/Resources/Textures/test-image.png b/osu.Game.Tests/Resources/Textures/test-image.png new file mode 100644 index 0000000000..5d0092edc8 Binary files /dev/null and b/osu.Game.Tests/Resources/Textures/test-image.png differ diff --git a/osu.Game.Tests/Resources/controlpoint-difficulty-multiplier.osu b/osu.Game.Tests/Resources/controlpoint-difficulty-multiplier.osu new file mode 100644 index 0000000000..5f06fc33c8 --- /dev/null +++ b/osu.Game.Tests/Resources/controlpoint-difficulty-multiplier.osu @@ -0,0 +1,8 @@ +osu file format v7 + +[TimingPoints] +0,100,4,2,0,100,1,0 +12,500,4,2,0,100,1,0 +1000,-10,4,2,0,100,0,0 +2000,-54,4,2,0,100,0,0 +3000,-200,4,2,0,100,0,0 diff --git a/osu.Game.Tests/Resources/corrupted-header.osu b/osu.Game.Tests/Resources/corrupted-header.osu new file mode 100644 index 0000000000..92701a4a7d --- /dev/null +++ b/osu.Game.Tests/Resources/corrupted-header.osu @@ -0,0 +1,5 @@ +ow computerosu file format v14 + +[Metadata] +Title: Beatmap with corrupted header +Creator: Evil Hacker diff --git a/osu.Game.Tests/Resources/empty-line-instead-of-header.osu b/osu.Game.Tests/Resources/empty-line-instead-of-header.osu new file mode 100644 index 0000000000..91ecf8d84a --- /dev/null +++ b/osu.Game.Tests/Resources/empty-line-instead-of-header.osu @@ -0,0 +1,5 @@ + + +[Metadata] +Title: The dog ate the file header +Creator: Why does this keep happening \ No newline at end of file diff --git a/osu.Game.Tests/Resources/empty-lines-at-start.osu b/osu.Game.Tests/Resources/empty-lines-at-start.osu new file mode 100644 index 0000000000..cb3b1761a2 --- /dev/null +++ b/osu.Game.Tests/Resources/empty-lines-at-start.osu @@ -0,0 +1,8 @@ + + + +osu file format v14 + +[Metadata] +Title: Empty lines at start +Creator: Edge Case Hunter \ No newline at end of file diff --git a/osu.Game.Tests/Resources/hitobject-combo-offset.osu b/osu.Game.Tests/Resources/hitobject-combo-offset.osu index c1f0dab8e9..d39a3e8548 100644 --- a/osu.Game.Tests/Resources/hitobject-combo-offset.osu +++ b/osu.Game.Tests/Resources/hitobject-combo-offset.osu @@ -5,27 +5,27 @@ osu file format v14 255,193,1000,49,0,0:0:0:0: // Combo index = 4 -// Slider with new combo followed by circle with no new combo +// Spinner with new combo followed by circle with no new combo 256,192,2000,12,0,2000,0:0:0:0: 255,193,3000,1,0,0:0:0:0: // Combo index = 5 -// Slider without new combo followed by circle with no new combo +// Spinner without new combo followed by circle with no new combo 256,192,4000,8,0,5000,0:0:0:0: 255,193,6000,1,0,0:0:0:0: // Combo index = 5 -// Slider without new combo followed by circle with new combo +// Spinner without new combo followed by circle with new combo 256,192,7000,8,0,8000,0:0:0:0: 255,193,9000,5,0,0:0:0:0: // Combo index = 6 -// Slider with new combo and offset (1) followed by circle with new combo and offset (3) +// Spinner with new combo and offset (1) followed by circle with new combo and offset (3) 256,192,10000,28,0,11000,0:0:0:0: 255,193,12000,53,0,0:0:0:0: // Combo index = 11 -// Slider with new combo and offset (2) followed by slider with no new combo followed by circle with no new combo +// Spinner with new combo and offset (2) followed by slider with no new combo followed by circle with no new combo 256,192,13000,44,0,14000,0:0:0:0: 256,192,15000,8,0,16000,0:0:0:0: 255,193,17000,1,0,0:0:0:0: diff --git a/osu.Game.Tests/Resources/invalid-events.osu b/osu.Game.Tests/Resources/invalid-events.osu new file mode 100644 index 0000000000..df86b26dba --- /dev/null +++ b/osu.Game.Tests/Resources/invalid-events.osu @@ -0,0 +1,14 @@ +osu file format v14 + +[Events] +bad,event,this,should,fail +//Background and Video events +0,0,"machinetop_background.jpg",0,0 +//Break Periods +2,122474,140135 +//Storyboard Layer 0 (Background) +this,is,also,bad +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Sound Samples diff --git a/osu.Game.Tests/Resources/mania-skin-colours.ini b/osu.Game.Tests/Resources/mania-skin-colours.ini new file mode 100644 index 0000000000..91d9696e0c --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-colours.ini @@ -0,0 +1,3 @@ +[Mania] +Keys: 4 +ColourBarline: 50,50,50,50 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/mania-skin-duplicate.ini b/osu.Game.Tests/Resources/mania-skin-duplicate.ini new file mode 100644 index 0000000000..2f4fa92c52 --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-duplicate.ini @@ -0,0 +1,9 @@ +[Mania] +Keys: 4 +ColumnWidth: 10,10,10,10 +HitPosition: 470 + +[Mania] +Keys: 4 +ColumnWidth: 20,20,20,20 +HitPosition: 460 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/mania-skin-extra-data.ini b/osu.Game.Tests/Resources/mania-skin-extra-data.ini new file mode 100644 index 0000000000..e538b5335a --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-extra-data.ini @@ -0,0 +1,4 @@ +[Mania] +Keys: 4 +ColumnWidth: 10,10,10,10,10,10,10 +HitPosition: 470 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/mania-skin-multiple.ini b/osu.Game.Tests/Resources/mania-skin-multiple.ini new file mode 100644 index 0000000000..247c7738a0 --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-multiple.ini @@ -0,0 +1,9 @@ +[Mania] +Keys: 4 +ColumnWidth: 10,10,10,10 +HitPosition: 470 + +[Mania] +Keys: 2 +ColumnWidth: 20,20 +HitPosition: 460 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/mania-skin-single.ini b/osu.Game.Tests/Resources/mania-skin-single.ini new file mode 100644 index 0000000000..3ae38fd75e --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-single.ini @@ -0,0 +1,4 @@ +[Mania] +Keys: 4 +ColumnWidth: 10,10,10,10 +HitPosition: 470 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/mania-skin-zero-minwidth.ini b/osu.Game.Tests/Resources/mania-skin-zero-minwidth.ini new file mode 100644 index 0000000000..fd22e2e299 --- /dev/null +++ b/osu.Game.Tests/Resources/mania-skin-zero-minwidth.ini @@ -0,0 +1,4 @@ +[Mania] +Keys: 4 +ColumnWidth: 10,10,10,10 +WidthForNoteHeightScale: 0 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/missing-header.osu b/osu.Game.Tests/Resources/missing-header.osu new file mode 100644 index 0000000000..95fac0d79b --- /dev/null +++ b/osu.Game.Tests/Resources/missing-header.osu @@ -0,0 +1,4 @@ +[Metadata] + +Title: Beatmap with no header +Creator: Incredibly Evil Hacker diff --git a/osu.Game.Tests/Resources/multi-segment-slider.osu b/osu.Game.Tests/Resources/multi-segment-slider.osu new file mode 100644 index 0000000000..6eabe640e4 --- /dev/null +++ b/osu.Game.Tests/Resources/multi-segment-slider.osu @@ -0,0 +1,11 @@ +osu file format v128 + +[HitObjects] +// Multi-segment +63,301,1000,6,0,P|224:57|B|439:298|131:316|322:169|155:194,1,1040,0|0,0:0|0:0,0:0:0:0: + +// Single-segment +63,301,2000,6,0,P|224:57|439:298,1,1040,0|0,0:0|0:0,0:0:0:0: + +// 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 diff --git a/osu.Game.Tests/Resources/no-empty-line-after-header.osu b/osu.Game.Tests/Resources/no-empty-line-after-header.osu new file mode 100644 index 0000000000..9db2b7c01c --- /dev/null +++ b/osu.Game.Tests/Resources/no-empty-line-after-header.osu @@ -0,0 +1,4 @@ +osu file format v14 +[Metadata] +Title: No empty line delimiting header from contents +Creator: Edge Case Hunter \ No newline at end of file diff --git a/osu.Game.Tests/Resources/old-skin/score-0.png b/osu.Game.Tests/Resources/old-skin/score-0.png new file mode 100644 index 0000000000..8304617d8c Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-0.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-1.png b/osu.Game.Tests/Resources/old-skin/score-1.png new file mode 100644 index 0000000000..c3b85eb873 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-1.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-2.png b/osu.Game.Tests/Resources/old-skin/score-2.png new file mode 100644 index 0000000000..7f65eb7ca7 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-2.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-3.png b/osu.Game.Tests/Resources/old-skin/score-3.png new file mode 100644 index 0000000000..82bec3babe Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-3.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-4.png b/osu.Game.Tests/Resources/old-skin/score-4.png new file mode 100644 index 0000000000..5e38c75a9d Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-4.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-5.png b/osu.Game.Tests/Resources/old-skin/score-5.png new file mode 100644 index 0000000000..a562d9f2ac Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-5.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-6.png b/osu.Game.Tests/Resources/old-skin/score-6.png new file mode 100644 index 0000000000..b4cf81f26e Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-6.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-7.png b/osu.Game.Tests/Resources/old-skin/score-7.png new file mode 100644 index 0000000000..a23f5379b2 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-7.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-8.png b/osu.Game.Tests/Resources/old-skin/score-8.png new file mode 100644 index 0000000000..430b18509d Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-8.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-9.png b/osu.Game.Tests/Resources/old-skin/score-9.png new file mode 100644 index 0000000000..add1202c31 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-9.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-comma.png b/osu.Game.Tests/Resources/old-skin/score-comma.png new file mode 100644 index 0000000000..f68d32957f Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-comma.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-dot.png b/osu.Game.Tests/Resources/old-skin/score-dot.png new file mode 100644 index 0000000000..80c39b8745 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-dot.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-percent.png b/osu.Game.Tests/Resources/old-skin/score-percent.png new file mode 100644 index 0000000000..fc750abc7e Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-percent.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-x.png b/osu.Game.Tests/Resources/old-skin/score-x.png new file mode 100644 index 0000000000..779773f8bd Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-x.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-bg.png b/osu.Game.Tests/Resources/old-skin/scorebar-bg.png new file mode 100644 index 0000000000..1e94f464ca Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-bg.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-0.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-0.png new file mode 100644 index 0000000000..1119ce289e Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-colour-0.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-1.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-1.png new file mode 100644 index 0000000000..7669474d8b Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-colour-1.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-2.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-2.png new file mode 100644 index 0000000000..70fdb4b146 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-colour-2.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-3.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-3.png new file mode 100644 index 0000000000..18ac6976c9 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-colour-3.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-ki.png b/osu.Game.Tests/Resources/old-skin/scorebar-ki.png new file mode 100644 index 0000000000..a030c5801e Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-ki.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-kidanger.png b/osu.Game.Tests/Resources/old-skin/scorebar-kidanger.png new file mode 100644 index 0000000000..ac5a2c5893 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-kidanger.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-kidanger2.png b/osu.Game.Tests/Resources/old-skin/scorebar-kidanger2.png new file mode 100644 index 0000000000..507be0463f Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-kidanger2.png differ diff --git a/osu.Game.Tests/Resources/old-skin/skin.ini b/osu.Game.Tests/Resources/old-skin/skin.ini new file mode 100644 index 0000000000..5369de24e9 --- /dev/null +++ b/osu.Game.Tests/Resources/old-skin/skin.ini @@ -0,0 +1,2 @@ +[General] +Version: 1.0 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/sample-beatmap-catch.osu b/osu.Game.Tests/Resources/sample-beatmap-catch.osu new file mode 100644 index 0000000000..09ef762e3e --- /dev/null +++ b/osu.Game.Tests/Resources/sample-beatmap-catch.osu @@ -0,0 +1,30 @@ +osu file format v14 + +[General] +SampleSet: Normal +StackLeniency: 0.7 +Mode: 2 + +[Difficulty] +HPDrainRate:3 +CircleSize:5 +OverallDifficulty:8 +ApproachRate:8 +SliderMultiplier:3.59999990463257 +SliderTickRate:2 + +[TimingPoints] +24,352.941176470588,4,1,1,100,1,0 +6376,-50,4,1,1,100,0,0 + +[HitObjects] +32,183,24,5,0,0:0:0:0: +106,123,200,1,10,0:0:0:0: +199,108,376,1,2,0:0:0:0: +305,105,553,5,4,0:0:0:0: +386,112,729,1,14,0:0:0:0: +486,197,906,5,12,0:0:0:0: +14,199,1082,2,0,L|473:198,1,449.999988079071 +14,199,1700,6,6,P|248:33|490:222,1,629.9999833107,0|8,0:0|0:0,0:0:0:0: +10,190,2494,2,8,B|252:29|254:335|468:167,1,449.999988079071,10|12,0:0|0:0,0:0:0:0: +256,192,3112,12,0,3906,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/sample-beatmap-mania.osu b/osu.Game.Tests/Resources/sample-beatmap-mania.osu new file mode 100644 index 0000000000..04d6a31ab6 --- /dev/null +++ b/osu.Game.Tests/Resources/sample-beatmap-mania.osu @@ -0,0 +1,39 @@ +osu file format v14 + +[General] +SampleSet: Normal +StackLeniency: 0.7 +Mode: 3 + +[Difficulty] +HPDrainRate:3 +CircleSize:5 +OverallDifficulty:8 +ApproachRate:8 +SliderMultiplier:3.59999990463257 +SliderTickRate:2 + +[TimingPoints] +24,352.941176470588,4,1,1,100,1,0 +6376,-50,4,1,1,100,0,0 + +[HitObjects] +51,192,24,1,0,0:0:0:0: +153,192,200,1,0,0:0:0:0: +358,192,376,1,0,0:0:0:0: +460,192,553,1,0,0:0:0:0: +460,192,729,128,0,1435:0:0:0:0: +358,192,906,128,0,1612:0:0:0:0: +256,192,1082,128,0,1788:0:0:0:0: +153,192,1259,128,0,1965:0:0:0:0: +51,192,1435,128,0,2141:0:0:0:0: +51,192,2318,1,12,0:0:0:0: +153,192,2318,1,4,0:0:0:0: +256,192,2318,1,6,0:0:0:0: +358,192,2318,1,14,0:0:0:0: +460,192,2318,1,0,0:0:0:0: +51,192,2494,128,0,2582:0:0:0:0: +153,192,2494,128,14,2582:0:0:0:0: +256,192,2494,128,6,2582:0:0:0:0: +358,192,2494,128,4,2582:0:0:0:0: +460,192,2494,128,12,2582:0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/sample-beatmap-osu.osu b/osu.Game.Tests/Resources/sample-beatmap-osu.osu new file mode 100644 index 0000000000..27c96077e6 --- /dev/null +++ b/osu.Game.Tests/Resources/sample-beatmap-osu.osu @@ -0,0 +1,32 @@ +osu file format v14 + +[General] +SampleSet: Normal +StackLeniency: 0.7 +Mode: 0 + +[Difficulty] +HPDrainRate:3 +CircleSize:5 +OverallDifficulty:8 +ApproachRate:8 +SliderMultiplier:3.59999990463257 +SliderTickRate:2 + +[TimingPoints] +24,352.941176470588,4,1,1,100,1,0 +6376,-50,4,1,1,100,0,0 + +[HitObjects] +98,69,24,1,0,0:0:0:0: +419,72,200,1,2,0:0:0:0: +81,314,376,1,6,0:0:0:0: +423,321,553,1,12,0:0:0:0: +86,192,729,2,0,P|459:193|460:193,1,359.999990463257 +86,192,1259,2,0,P|246:82|453:203,1,449.999988079071 +86,192,1876,2,0,B|256:30|257:313|464:177,1,359.999990463257 +86,55,2406,2,12,B|447:51|447:51|452:348|452:348|78:344,1,989.999973773957,14|2,0:0|0:0,0:0:0:0: +256,192,3553,12,0,4259,0:0:0:0: +67,57,4435,5,0,0:0:0:0: +440,52,4612,5,0,0:0:0:0: +86,181,4788,6,0,L|492:183,1,359.999990463257 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/sample-beatmap-taiko.osu b/osu.Game.Tests/Resources/sample-beatmap-taiko.osu new file mode 100644 index 0000000000..94b4288336 --- /dev/null +++ b/osu.Game.Tests/Resources/sample-beatmap-taiko.osu @@ -0,0 +1,42 @@ +osu file format v14 + +[General] +SampleSet: Normal +StackLeniency: 0.7 +Mode: 1 + +[Difficulty] +HPDrainRate:3 +CircleSize:5 +OverallDifficulty:8 +ApproachRate:8 +SliderMultiplier:3.59999990463257 +SliderTickRate:2 + +[TimingPoints] +24,352.941176470588,4,1,1,100,1,0 +6376,-50,4,1,1,100,0,0 + +[HitObjects] +231,129,24,1,0,0:0:0:0: +231,129,200,1,0,0:0:0:0: +231,129,376,1,0,0:0:0:0: +231,129,553,1,0,0:0:0:0: +231,129,729,1,0,0:0:0:0: +373,132,906,1,4,0:0:0:0: +373,132,1082,1,4,0:0:0:0: +373,132,1259,1,4,0:0:0:0: +373,132,1435,1,4,0:0:0:0: +231,129,1788,1,8,0:0:0:0: +231,129,1964,1,8,0:0:0:0: +231,129,2140,1,8,0:0:0:0: +231,129,2317,1,8,0:0:0:0: +231,129,2493,1,8,0:0:0:0: +373,132,2670,1,12,0:0:0:0: +373,132,2846,1,12,0:0:0:0: +373,132,3023,1,12,0:0:0:0: +373,132,3199,1,12,0:0:0:0: +51,189,3553,2,0,L|150:188,1,89.9999976158143 +52,191,3906,2,0,L|512:189,1,449.999988079071 +26,196,4612,2,4,L|501:195,1,449.999988079071 +17,242,5318,2,10,P|250:69|495:243,1,629.9999833107,0|8,0:0|0:0,0:0:0:0: \ No newline at end of file diff --git a/osu.Game.Tests/Resources/skin-20.ini b/osu.Game.Tests/Resources/skin-20.ini new file mode 100644 index 0000000000..947b56b2f9 --- /dev/null +++ b/osu.Game.Tests/Resources/skin-20.ini @@ -0,0 +1,2 @@ +[General] +Version: 2 \ No newline at end of file diff --git a/osu.Game.Tests/Resources/skin-latest.ini b/osu.Game.Tests/Resources/skin-latest.ini new file mode 100644 index 0000000000..32f500263f --- /dev/null +++ b/osu.Game.Tests/Resources/skin-latest.ini @@ -0,0 +1,2 @@ +[General] +Version: latest \ No newline at end of file diff --git a/osu.Game.Tests/Resources/skin.ini b/osu.Game.Tests/Resources/skin.ini index 0e5737b4ea..7f7f0b32a6 100644 --- a/osu.Game.Tests/Resources/skin.ini +++ b/osu.Game.Tests/Resources/skin.ini @@ -1,5 +1,6 @@ [General] Name: test skin +TestLookup: TestValue [Colours] Combo1 : 142,199,255 diff --git a/osu.Game.Tests/Resources/storyboard_no_video.osu b/osu.Game.Tests/Resources/storyboard_no_video.osu new file mode 100644 index 0000000000..25f1ff6361 --- /dev/null +++ b/osu.Game.Tests/Resources/storyboard_no_video.osu @@ -0,0 +1,31 @@ +osu file format v14 + +[Events] +//Background and Video events +0,0,"BG.jpg",0,0 +Video,0,"video.avi" +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Layer 4 (Overlay) +//Storyboard Sound Samples + +[TimingPoints] +1674,333.333333333333,4,2,1,70,1,0 +1674,-100,4,2,1,70,0,0 +3340,-100,4,2,1,70,0,0 +3507,-100,4,2,1,70,0,0 +3673,-100,4,2,1,70,0,0 + +[Colours] +Combo1 : 240,80,80 +Combo2 : 171,252,203 +Combo3 : 128,128,255 +Combo4 : 249,254,186 + +[HitObjects] +148,303,1674,5,6,3:2:0:0: +378,252,1840,1,0,0:0:0:0: +389,270,2340,5,2,0:1:0:0: diff --git a/osu.Game.Tests/Resources/timingpoint-speedmultiplier-reset.osu b/osu.Game.Tests/Resources/timingpoint-speedmultiplier-reset.osu new file mode 100644 index 0000000000..4512903c68 --- /dev/null +++ b/osu.Game.Tests/Resources/timingpoint-speedmultiplier-reset.osu @@ -0,0 +1,5 @@ +osu file format v14 + +[TimingPoints] +0,-200,4,1,0,100,0,0 +2000,100,1,1,0,100,1,0 diff --git a/osu.Game.Tests/Resources/variable-with-suffix.osb b/osu.Game.Tests/Resources/variable-with-suffix.osb index 5c9b46ca98..fd284eb055 100644 --- a/osu.Game.Tests/Resources/variable-with-suffix.osb +++ b/osu.Game.Tests/Resources/variable-with-suffix.osb @@ -1,5 +1,5 @@ [Variables] -$var=1234 +$var=34 [Events] Sprite,Background,TopCentre,"img.jpg",$var56,240 diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs new file mode 100644 index 0000000000..9f16312121 --- /dev/null +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -0,0 +1,315 @@ +// 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.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Rulesets.Scoring +{ + public class ScoreProcessorTest + { + private ScoreProcessor scoreProcessor; + private IBeatmap beatmap; + + [SetUp] + public void SetUp() + { + scoreProcessor = new ScoreProcessor(); + beatmap = new TestBeatmap(new RulesetInfo()) + { + HitObjects = new List + { + new HitCircle() + } + }; + } + + [TestCase(ScoringMode.Standardised, HitResult.Meh, 750_000)] + [TestCase(ScoringMode.Standardised, HitResult.Ok, 800_000)] + [TestCase(ScoringMode.Standardised, HitResult.Great, 1_000_000)] + [TestCase(ScoringMode.Classic, HitResult.Meh, 50)] + [TestCase(ScoringMode.Classic, HitResult.Ok, 100)] + [TestCase(ScoringMode.Classic, HitResult.Great, 300)] + public void TestSingleOsuHit(ScoringMode scoringMode, HitResult hitResult, int expectedScore) + { + scoreProcessor.Mode.Value = scoringMode; + scoreProcessor.ApplyBeatmap(beatmap); + + var judgementResult = new JudgementResult(beatmap.HitObjects.Single(), new OsuJudgement()) + { + Type = hitResult + }; + scoreProcessor.ApplyResult(judgementResult); + + Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value)); + } + + /// + /// Test to see that all s contribute to score portions in correct amounts. + /// + /// Scoring mode to test. + /// The that will be applied to selected hit objects. + /// The maximum achievable. + /// Expected score after all objects have been judged, rounded to the nearest integer. + /// + /// This test intentionally misses the 3rd hitobject to achieve lower than 75% accuracy and 50% max combo. + /// + /// For standardised scoring, is calculated using the following formula: + /// 1_000_000 * (((3 * ) / (4 * )) * 30% + (bestCombo / maxCombo) * 70%) + /// + /// + /// For classic scoring, is calculated using the following formula: + /// / * 936 + /// where 936 is simplified from: + /// 75% * 4 * 300 * (1 + 1/25) + /// + /// + [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.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.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.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) + public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore) + { + var minResult = new TestJudgement(hitResult).MinResult; + + IBeatmap fourObjectBeatmap = new TestBeatmap(new RulesetInfo()) + { + HitObjects = new List(Enumerable.Repeat(new TestHitObject(maxResult), 4)) + }; + scoreProcessor.Mode.Value = scoringMode; + scoreProcessor.ApplyBeatmap(fourObjectBeatmap); + + for (int i = 0; i < 4; i++) + { + var judgementResult = new JudgementResult(fourObjectBeatmap.HitObjects[i], new Judgement()) + { + Type = i == 2 ? minResult : hitResult + }; + scoreProcessor.ApplyResult(judgementResult); + } + + Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value, 0.5)); + } + + /// + /// This test uses a beatmap with four small ticks and one object with the of . + /// Its goal is to ensure that with the of , + /// small ticks contribute to the accuracy portion, but not the combo portion. + /// In contrast, does not have separate combo and accuracy portion (they are multiplied by each other). + /// + [TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, 978_571)] // (3 * 10 + 100) / (4 * 10 + 100) * 300_000 + (1 / 1) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, 914_286)] // (3 * 0 + 100) / (4 * 10 + 100) * 300_000 + (1 / 1) * 700_000 + [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, 279)] // (((3 * 10 + 100) / (4 * 10 + 100)) * 1 * 300) * (1 + 0 / 25) + [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, 214)] // (((3 * 0 + 100) / (4 * 10 + 100)) * 1 * 300) * (1 + 0 / 25) + public void TestSmallTicksAccuracy(ScoringMode scoringMode, HitResult hitResult, int expectedScore) + { + IEnumerable hitObjects = Enumerable + .Repeat(new TestHitObject(HitResult.SmallTickHit), 4) + .Append(new TestHitObject(HitResult.Ok)); + IBeatmap fiveObjectBeatmap = new TestBeatmap(new RulesetInfo()) + { + HitObjects = hitObjects.ToList() + }; + scoreProcessor.Mode.Value = scoringMode; + scoreProcessor.ApplyBeatmap(fiveObjectBeatmap); + + for (int i = 0; i < 4; i++) + { + var judgementResult = new JudgementResult(fiveObjectBeatmap.HitObjects[i], new Judgement()) + { + Type = i == 2 ? HitResult.SmallTickMiss : hitResult + }; + scoreProcessor.ApplyResult(judgementResult); + } + + var lastJudgementResult = new JudgementResult(fiveObjectBeatmap.HitObjects.Last(), new Judgement()) + { + Type = HitResult.Ok + }; + scoreProcessor.ApplyResult(lastJudgementResult); + + Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value, 0.5)); + } + + [Test] + public void TestEmptyBeatmap( + [Values(ScoringMode.Standardised, ScoringMode.Classic)] + ScoringMode scoringMode) + { + scoreProcessor.Mode.Value = scoringMode; + scoreProcessor.ApplyBeatmap(new TestBeatmap(new RulesetInfo())); + + Assert.IsTrue(Precision.AlmostEquals(0, scoreProcessor.TotalScore.Value)); + } + + [TestCase(HitResult.IgnoreHit, HitResult.IgnoreMiss)] + [TestCase(HitResult.Meh, HitResult.Miss)] + [TestCase(HitResult.Ok, HitResult.Miss)] + [TestCase(HitResult.Good, HitResult.Miss)] + [TestCase(HitResult.Great, HitResult.Miss)] + [TestCase(HitResult.Perfect, HitResult.Miss)] + [TestCase(HitResult.SmallTickHit, HitResult.SmallTickMiss)] + [TestCase(HitResult.LargeTickHit, HitResult.LargeTickMiss)] + [TestCase(HitResult.SmallBonus, HitResult.IgnoreMiss)] + [TestCase(HitResult.LargeBonus, HitResult.IgnoreMiss)] + public void TestMinResults(HitResult hitResult, HitResult expectedMinResult) + { + Assert.AreEqual(expectedMinResult, new TestJudgement(hitResult).MinResult); + } + + [TestCase(HitResult.None, false)] + [TestCase(HitResult.IgnoreMiss, false)] + [TestCase(HitResult.IgnoreHit, false)] + [TestCase(HitResult.Miss, true)] + [TestCase(HitResult.Meh, true)] + [TestCase(HitResult.Ok, true)] + [TestCase(HitResult.Good, true)] + [TestCase(HitResult.Great, true)] + [TestCase(HitResult.Perfect, true)] + [TestCase(HitResult.SmallTickMiss, false)] + [TestCase(HitResult.SmallTickHit, false)] + [TestCase(HitResult.LargeTickMiss, true)] + [TestCase(HitResult.LargeTickHit, true)] + [TestCase(HitResult.SmallBonus, false)] + [TestCase(HitResult.LargeBonus, false)] + public void TestAffectsCombo(HitResult hitResult, bool expectedReturnValue) + { + Assert.AreEqual(expectedReturnValue, hitResult.AffectsCombo()); + } + + [TestCase(HitResult.None, false)] + [TestCase(HitResult.IgnoreMiss, false)] + [TestCase(HitResult.IgnoreHit, false)] + [TestCase(HitResult.Miss, true)] + [TestCase(HitResult.Meh, true)] + [TestCase(HitResult.Ok, true)] + [TestCase(HitResult.Good, true)] + [TestCase(HitResult.Great, true)] + [TestCase(HitResult.Perfect, true)] + [TestCase(HitResult.SmallTickMiss, true)] + [TestCase(HitResult.SmallTickHit, true)] + [TestCase(HitResult.LargeTickMiss, true)] + [TestCase(HitResult.LargeTickHit, true)] + [TestCase(HitResult.SmallBonus, false)] + [TestCase(HitResult.LargeBonus, false)] + public void TestAffectsAccuracy(HitResult hitResult, bool expectedReturnValue) + { + Assert.AreEqual(expectedReturnValue, hitResult.AffectsAccuracy()); + } + + [TestCase(HitResult.None, false)] + [TestCase(HitResult.IgnoreMiss, false)] + [TestCase(HitResult.IgnoreHit, false)] + [TestCase(HitResult.Miss, false)] + [TestCase(HitResult.Meh, false)] + [TestCase(HitResult.Ok, false)] + [TestCase(HitResult.Good, false)] + [TestCase(HitResult.Great, false)] + [TestCase(HitResult.Perfect, false)] + [TestCase(HitResult.SmallTickMiss, false)] + [TestCase(HitResult.SmallTickHit, false)] + [TestCase(HitResult.LargeTickMiss, false)] + [TestCase(HitResult.LargeTickHit, false)] + [TestCase(HitResult.SmallBonus, true)] + [TestCase(HitResult.LargeBonus, true)] + public void TestIsBonus(HitResult hitResult, bool expectedReturnValue) + { + Assert.AreEqual(expectedReturnValue, hitResult.IsBonus()); + } + + [TestCase(HitResult.None, false)] + [TestCase(HitResult.IgnoreMiss, false)] + [TestCase(HitResult.IgnoreHit, true)] + [TestCase(HitResult.Miss, false)] + [TestCase(HitResult.Meh, true)] + [TestCase(HitResult.Ok, true)] + [TestCase(HitResult.Good, true)] + [TestCase(HitResult.Great, true)] + [TestCase(HitResult.Perfect, true)] + [TestCase(HitResult.SmallTickMiss, false)] + [TestCase(HitResult.SmallTickHit, true)] + [TestCase(HitResult.LargeTickMiss, false)] + [TestCase(HitResult.LargeTickHit, true)] + [TestCase(HitResult.SmallBonus, true)] + [TestCase(HitResult.LargeBonus, true)] + public void TestIsHit(HitResult hitResult, bool expectedReturnValue) + { + Assert.AreEqual(expectedReturnValue, hitResult.IsHit()); + } + + [TestCase(HitResult.None, false)] + [TestCase(HitResult.IgnoreMiss, false)] + [TestCase(HitResult.IgnoreHit, false)] + [TestCase(HitResult.Miss, true)] + [TestCase(HitResult.Meh, true)] + [TestCase(HitResult.Ok, true)] + [TestCase(HitResult.Good, true)] + [TestCase(HitResult.Great, true)] + [TestCase(HitResult.Perfect, true)] + [TestCase(HitResult.SmallTickMiss, true)] + [TestCase(HitResult.SmallTickHit, true)] + [TestCase(HitResult.LargeTickMiss, true)] + [TestCase(HitResult.LargeTickHit, true)] + [TestCase(HitResult.SmallBonus, true)] + [TestCase(HitResult.LargeBonus, true)] + public void TestIsScorable(HitResult hitResult, bool expectedReturnValue) + { + Assert.AreEqual(expectedReturnValue, hitResult.IsScorable()); + } + + private class TestJudgement : Judgement + { + public override HitResult MaxResult { get; } + + public TestJudgement(HitResult maxResult) + { + MaxResult = maxResult; + } + } + + private class TestHitObject : HitObject + { + private readonly HitResult maxResult; + + public override Judgement CreateJudgement() + { + return new TestJudgement(maxResult); + } + + public TestHitObject(HitResult maxResult) + { + this.maxResult = maxResult; + } + } + } +} diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs new file mode 100644 index 0000000000..33e3c7cb8c --- /dev/null +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.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; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; +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.Testing; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.UI; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Rulesets +{ + [HeadlessTest] + public class TestSceneDrawableRulesetDependencies : OsuTestScene + { + [Test] + public void TestDisposalDoesNotDisposeParentStores() + { + DrawableWithDependencies drawable = null; + TestTextureStore textureStore = null; + TestSampleStore sampleStore = null; + + AddStep("add dependencies", () => + { + Child = drawable = new DrawableWithDependencies(); + textureStore = drawable.ParentTextureStore; + sampleStore = drawable.ParentSampleStore; + }); + + AddStep("clear children", Clear); + AddUntilStep("wait for disposal", () => drawable.IsDisposed); + + AddStep("GC", () => + { + drawable = null; + + GC.Collect(); + GC.WaitForPendingFinalizers(); + }); + + AddAssert("parent texture store not disposed", () => !textureStore.IsDisposed); + AddAssert("parent sample store not disposed", () => !sampleStore.IsDisposed); + } + + private class DrawableWithDependencies : CompositeDrawable + { + public TestTextureStore ParentTextureStore { get; private set; } + public TestSampleStore ParentSampleStore { get; private set; } + + public DrawableWithDependencies() + { + InternalChild = new Box { RelativeSizeAxes = Axes.Both }; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + dependencies.CacheAs(ParentTextureStore = new TestTextureStore()); + dependencies.CacheAs(ParentSampleStore = new TestSampleStore()); + + return new DrawableRulesetDependencies(new OsuRuleset(), dependencies); + } + + public new bool IsDisposed { get; private set; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + IsDisposed = true; + } + } + + private class TestTextureStore : TextureStore + { + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) => null; + + public bool IsDisposed { get; private set; } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + IsDisposed = true; + } + } + + private class TestSampleStore : ISampleStore + { + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } + + public SampleChannel Get(string name) => null; + + public Task GetAsync(string name) => null; + + public Stream GetStream(string name) => null; + + public IEnumerable GetAvailableResources() => throw new NotImplementedException(); + + public BindableNumber Volume => throw new NotImplementedException(); + public BindableNumber Balance => throw new NotImplementedException(); + public BindableNumber Frequency => throw new NotImplementedException(); + public BindableNumber Tempo => throw new NotImplementedException(); + + public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotImplementedException(); + + public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotImplementedException(); + + public void RemoveAllAdjustments(AdjustableProperty type) => throw new NotImplementedException(); + + public IBindable AggregateVolume => throw new NotImplementedException(); + public IBindable AggregateBalance => throw new NotImplementedException(); + public IBindable AggregateFrequency => throw new NotImplementedException(); + public IBindable AggregateTempo => throw new NotImplementedException(); + + public int PlaybackConcurrency { get; set; } + } + } +} diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs index 4babb07213..7522aca5dc 100644 --- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs +++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs @@ -3,33 +3,33 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; -using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Platform; using osu.Game.Beatmaps; +using osu.Game.IO.Archives; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; -using osu.Game.Tests.Resources; using osu.Game.Users; namespace osu.Game.Tests.Scores.IO { - public class ImportScoreTest + public class ImportScoreTest : ImportTest { [Test] public async Task TestBasicImport() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestBasicImport")) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { - var osu = await loadOsu(host); + var osu = LoadOsuIntoHost(host, true); var toImport = new ScoreInfo { @@ -43,7 +43,7 @@ namespace osu.Game.Tests.Scores.IO OnlineScoreID = 12345, }; - var imported = await loadIntoOsu(osu, toImport); + var imported = await loadScoreIntoOsu(osu, toImport); Assert.AreEqual(toImport.Rank, imported.Rank); Assert.AreEqual(toImport.TotalScore, imported.TotalScore); @@ -64,18 +64,18 @@ namespace osu.Game.Tests.Scores.IO [Test] public async Task TestImportMods() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportMods")) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { - var osu = await loadOsu(host); + var osu = LoadOsuIntoHost(host, true); var toImport = new ScoreInfo { Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, }; - var imported = await loadIntoOsu(osu, toImport); + var imported = await loadScoreIntoOsu(osu, toImport); Assert.IsTrue(imported.Mods.Any(m => m is OsuModHardRock)); Assert.IsTrue(imported.Mods.Any(m => m is OsuModDoubleTime)); @@ -90,11 +90,11 @@ namespace osu.Game.Tests.Scores.IO [Test] public async Task TestImportStatistics() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportStatistics")) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) { try { - var osu = await loadOsu(host); + var osu = LoadOsuIntoHost(host, true); var toImport = new ScoreInfo { @@ -105,7 +105,7 @@ namespace osu.Game.Tests.Scores.IO } }; - var imported = await loadIntoOsu(osu, toImport); + var imported = await loadScoreIntoOsu(osu, toImport); Assert.AreEqual(toImport.Statistics[HitResult.Perfect], imported.Statistics[HitResult.Perfect]); Assert.AreEqual(toImport.Statistics[HitResult.Miss], imported.Statistics[HitResult.Miss]); @@ -117,44 +117,93 @@ namespace osu.Game.Tests.Scores.IO } } - private async Task loadIntoOsu(OsuGameBase osu, ScoreInfo score) + [Test] + public async Task TestImportWithDeletedBeatmapSet() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host, true); + + var toImport = new ScoreInfo + { + Hash = Guid.NewGuid().ToString(), + Statistics = new Dictionary + { + { HitResult.Perfect, 100 }, + { HitResult.Miss, 50 } + } + }; + + var imported = await loadScoreIntoOsu(osu, toImport); + + var beatmapManager = osu.Dependencies.Get(); + var scoreManager = osu.Dependencies.Get(); + + beatmapManager.Delete(beatmapManager.QueryBeatmapSet(s => s.Beatmaps.Any(b => b.ID == imported.Beatmap.ID))); + Assert.That(scoreManager.Query(s => s.ID == imported.ID).DeletePending, Is.EqualTo(true)); + + var secondImport = await loadScoreIntoOsu(osu, imported); + Assert.That(secondImport, Is.Null); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestOnlineScoreIsAvailableLocally() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = LoadOsuIntoHost(host, true); + + await loadScoreIntoOsu(osu, new ScoreInfo { OnlineScoreID = 2 }, new TestArchiveReader()); + + var scoreManager = osu.Dependencies.Get(); + + // Note: A new score reference is used here since the import process mutates the original object to set an ID + Assert.That(scoreManager.IsAvailableLocally(new ScoreInfo { OnlineScoreID = 2 })); + } + finally + { + host.Exit(); + } + } + } + + private async Task loadScoreIntoOsu(OsuGameBase osu, ScoreInfo score, ArchiveReader archive = null) { var beatmapManager = osu.Dependencies.Get(); - score.Beatmap = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); - score.Ruleset = new OsuRuleset().RulesetInfo; + score.Beatmap ??= beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); + score.Ruleset ??= new OsuRuleset().RulesetInfo; var scoreManager = osu.Dependencies.Get(); - await scoreManager.Import(score); + await scoreManager.Import(score, archive); - return scoreManager.GetAllUsableScores().First(); + return scoreManager.GetAllUsableScores().FirstOrDefault(); } - private async Task loadOsu(GameHost host) + private class TestArchiveReader : ArchiveReader { - var osu = new OsuGameBase(); - -#pragma warning disable 4014 - Task.Run(() => host.Run(osu)); -#pragma warning restore 4014 - - waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); - - var beatmapFile = TestResources.GetTestBeatmapForImport(); - var beatmapManager = osu.Dependencies.Get(); - await beatmapManager.Import(beatmapFile); - - return osu; - } - - private void waitForOrAssert(Func result, string failureMessage, int timeout = 60000) - { - Task task = Task.Run(() => + public TestArchiveReader() + : base("test_archive") { - while (!result()) Thread.Sleep(200); - }); + } - Assert.IsTrue(task.Wait(timeout), failureMessage); + public override Stream GetStream(string name) => new MemoryStream(); + + public override void Dispose() + { + } + + public override IEnumerable Filenames => new[] { "test_file.osr" }; } } } diff --git a/osu.Game.Tests/Scores/IO/TestScoreEquality.cs b/osu.Game.Tests/Scores/IO/TestScoreEquality.cs new file mode 100644 index 0000000000..d1374eb6e5 --- /dev/null +++ b/osu.Game.Tests/Scores/IO/TestScoreEquality.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 NUnit.Framework; +using osu.Game.Scoring; + +namespace osu.Game.Tests.Scores.IO +{ + [TestFixture] + public class TestScoreEquality + { + [Test] + public void TestNonMatchingByReference() + { + ScoreInfo score1 = new ScoreInfo(); + ScoreInfo score2 = new ScoreInfo(); + + Assert.That(score1, Is.Not.EqualTo(score2)); + } + + [Test] + public void TestMatchingByReference() + { + ScoreInfo score = new ScoreInfo(); + + Assert.That(score, Is.EqualTo(score)); + } + + [Test] + public void TestNonMatchingByPrimaryKey() + { + ScoreInfo score1 = new ScoreInfo { ID = 1 }; + ScoreInfo score2 = new ScoreInfo { ID = 2 }; + + Assert.That(score1, Is.Not.EqualTo(score2)); + } + + [Test] + public void TestMatchingByPrimaryKey() + { + ScoreInfo score1 = new ScoreInfo { ID = 1 }; + ScoreInfo score2 = new ScoreInfo { ID = 1 }; + + Assert.That(score1, Is.EqualTo(score2)); + } + + [Test] + public void TestNonMatchingByHash() + { + ScoreInfo score1 = new ScoreInfo { Hash = "a" }; + ScoreInfo score2 = new ScoreInfo { Hash = "b" }; + + Assert.That(score1, Is.Not.EqualTo(score2)); + } + + [Test] + public void TestMatchingByHash() + { + ScoreInfo score1 = new ScoreInfo { Hash = "a" }; + ScoreInfo score2 = new ScoreInfo { Hash = "a" }; + + Assert.That(score1, Is.EqualTo(score2)); + } + + [Test] + public void TestNonMatchingByNull() + { + ScoreInfo score = new ScoreInfo(); + + Assert.That(score, Is.Not.EqualTo(null)); + } + } +} diff --git a/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs index d7f709dc03..a6e8622b6f 100644 --- a/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/ConstantScrollTest.cs @@ -18,12 +18,21 @@ namespace osu.Game.Tests.ScrollAlgorithms } [Test] - public void TestDisplayStartTime() + public void TestPointDisplayStartTime() { - Assert.AreEqual(-8000, algorithm.GetDisplayStartTime(2000, 10000)); - Assert.AreEqual(-3000, algorithm.GetDisplayStartTime(2000, 5000)); - Assert.AreEqual(2000, algorithm.GetDisplayStartTime(7000, 5000)); - Assert.AreEqual(7000, algorithm.GetDisplayStartTime(17000, 10000)); + Assert.AreEqual(-8000, algorithm.GetDisplayStartTime(2000, 0, 10000, 1)); + Assert.AreEqual(-3000, algorithm.GetDisplayStartTime(2000, 0, 5000, 1)); + Assert.AreEqual(2000, algorithm.GetDisplayStartTime(7000, 0, 5000, 1)); + Assert.AreEqual(7000, algorithm.GetDisplayStartTime(17000, 0, 10000, 1)); + } + + [Test] + public void TestObjectDisplayStartTime() + { + Assert.AreEqual(900, algorithm.GetDisplayStartTime(2000, 50, 1000, 500)); // 2000 - (1 + 50 / 500) * 1000 + Assert.AreEqual(8900, algorithm.GetDisplayStartTime(10000, 50, 1000, 500)); // 10000 - (1 + 50 / 500) * 1000 + Assert.AreEqual(13500, algorithm.GetDisplayStartTime(15000, 250, 1000, 500)); // 15000 - (1 + 250 / 500) * 1000 + Assert.AreEqual(19000, algorithm.GetDisplayStartTime(25000, 100, 5000, 500)); // 25000 - (1 + 100 / 500) * 5000 } [Test] diff --git a/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs index 106aa88be3..1429d22c1a 100644 --- a/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/OverlappingScrollTest.cs @@ -27,11 +27,22 @@ namespace osu.Game.Tests.ScrollAlgorithms } [Test] - public void TestDisplayStartTime() + public void TestPointDisplayStartTime() { - Assert.AreEqual(1000, algorithm.GetDisplayStartTime(2000, 1000)); // Like constant - Assert.AreEqual(10000, algorithm.GetDisplayStartTime(10500, 1000)); // 10500 - (1000 * 0.5) - Assert.AreEqual(20000, algorithm.GetDisplayStartTime(22000, 1000)); // 23000 - (1000 / 0.5) + Assert.AreEqual(1000, algorithm.GetDisplayStartTime(2000, 0, 1000, 1)); // Like constant + Assert.AreEqual(10000, algorithm.GetDisplayStartTime(10500, 0, 1000, 1)); // 10500 - (1000 * 0.5) + Assert.AreEqual(20000, algorithm.GetDisplayStartTime(22000, 0, 1000, 1)); // 23000 - (1000 / 0.5) + } + + [Test] + public void TestObjectDisplayStartTime() + { + Assert.AreEqual(900, algorithm.GetDisplayStartTime(2000, 50, 1000, 500)); // 2000 - (1 + 50 / 500) * 1000 / 1 + Assert.AreEqual(9450, algorithm.GetDisplayStartTime(10000, 50, 1000, 500)); // 10000 - (1 + 50 / 500) * 1000 / 2 + Assert.AreEqual(14250, algorithm.GetDisplayStartTime(15000, 250, 1000, 500)); // 15000 - (1 + 250 / 500) * 1000 / 2 + Assert.AreEqual(16500, algorithm.GetDisplayStartTime(18000, 250, 2000, 500)); // 18000 - (1 + 250 / 500) * 2000 / 2 + Assert.AreEqual(17800, algorithm.GetDisplayStartTime(20000, 50, 1000, 500)); // 20000 - (1 + 50 / 500) * 1000 / 0.5 + Assert.AreEqual(19800, algorithm.GetDisplayStartTime(22000, 50, 1000, 500)); // 22000 - (1 + 50 / 500) * 1000 / 0.5 } [Test] diff --git a/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs b/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs index 1f0c069f8d..bd578dcbc4 100644 --- a/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs +++ b/osu.Game.Tests/ScrollAlgorithms/SequentialScrollTest.cs @@ -29,8 +29,22 @@ namespace osu.Game.Tests.ScrollAlgorithms [Test] public void TestDisplayStartTime() { - // Sequential scroll algorithm approximates the start time - // This should be fixed in the future + // easy cases - time range adjusted for velocity fits within control point duration + Assert.AreEqual(2500, algorithm.GetDisplayStartTime(5000, 0, 2500, 1)); // 5000 - (2500 / 1) + Assert.AreEqual(13750, algorithm.GetDisplayStartTime(15000, 0, 2500, 1)); // 15000 - (2500 / 2) + Assert.AreEqual(20000, algorithm.GetDisplayStartTime(25000, 0, 2500, 1)); // 25000 - (2500 / 0.5) + + // hard case - time range adjusted for velocity exceeds control point duration + + // 1st multiplier point takes 10000 / 2500 = 4 scroll lengths + // 2nd multiplier point takes 10000 / (2500 / 2) = 8 scroll lengths + // 3rd multiplier point takes 2500 / (2500 * 2) = 0.5 scroll lengths up to hitobject start + + // absolute position of the hitobject = 1000 * (4 + 8 + 0.5) = 12500 + // minus one scroll length allowance = 12500 - 1000 = 11500 = 11.5 [scroll lengths] + // therefore the start time lies within the second multiplier point (because 11.5 < 4 + 8) + // its exact time position is = 10000 + 7.5 * (2500 / 2) = 19375 + Assert.AreEqual(19375, algorithm.GetDisplayStartTime(22500, 0, 2500, 1000)); } [Test] diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs new file mode 100644 index 0000000000..a5b4b04ef5 --- /dev/null +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -0,0 +1,147 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Platform; +using osu.Game.IO.Archives; +using osu.Game.Skinning; +using SharpCompress.Archives.Zip; + +namespace osu.Game.Tests.Skins.IO +{ + public class ImportSkinTest : ImportTest + { + [Test] + public async Task TestBasicImport() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportSkinTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var imported = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("test skin", "skinner"), "skin.osk")); + + Assert.That(imported.Name, Is.EqualTo("test skin")); + Assert.That(imported.Creator, Is.EqualTo("skinner")); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportTwiceWithSameMetadata() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportSkinTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var imported = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("test skin", "skinner"), "skin.osk")); + var imported2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("test skin", "skinner"), "skin2.osk")); + + Assert.That(imported2.ID, Is.Not.EqualTo(imported.ID)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins().Count, Is.EqualTo(1)); + + // the first should be overwritten by the second import. + Assert.That(osu.Dependencies.Get().GetAllUserSkins().First().Files.First().FileInfoID, Is.EqualTo(imported2.Files.First().FileInfoID)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportTwiceWithNoMetadata() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportSkinTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + // if a user downloads two skins that do have skin.ini files but don't have any creator metadata in the skin.ini, they should both import separately just for safety. + var imported = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk(string.Empty, string.Empty), "download.osk")); + var imported2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk(string.Empty, string.Empty), "download.osk")); + + Assert.That(imported2.ID, Is.Not.EqualTo(imported.ID)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins().Count, Is.EqualTo(2)); + + Assert.That(osu.Dependencies.Get().GetAllUserSkins().First().Files.First().FileInfoID, Is.EqualTo(imported.Files.First().FileInfoID)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins().Last().Files.First().FileInfoID, Is.EqualTo(imported2.Files.First().FileInfoID)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportTwiceWithDifferentMetadata() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportSkinTest))) + { + try + { + var osu = LoadOsuIntoHost(host); + + var imported = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("test skin v2", "skinner"), "skin.osk")); + var imported2 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOsk("test skin v2.1", "skinner"), "skin2.osk")); + + Assert.That(imported2.ID, Is.Not.EqualTo(imported.ID)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins().Count, Is.EqualTo(2)); + + Assert.That(osu.Dependencies.Get().GetAllUserSkins().First().Files.First().FileInfoID, Is.EqualTo(imported.Files.First().FileInfoID)); + Assert.That(osu.Dependencies.Get().GetAllUserSkins().Last().Files.First().FileInfoID, Is.EqualTo(imported2.Files.First().FileInfoID)); + } + finally + { + host.Exit(); + } + } + } + + private MemoryStream createOsk(string name, string author) + { + var zipStream = new MemoryStream(); + using var zip = ZipArchive.Create(); + zip.AddEntry("skin.ini", generateSkinIni(name, author)); + zip.SaveTo(zipStream); + return zipStream; + } + + private MemoryStream generateSkinIni(string name, string author) + { + var stream = new MemoryStream(); + var writer = new StreamWriter(stream); + + writer.WriteLine("[General]"); + writer.WriteLine($"Name: {name}"); + writer.WriteLine($"Author: {author}"); + writer.WriteLine(); + writer.WriteLine($"# unique {Guid.NewGuid()}"); + + writer.Flush(); + + return stream; + } + + private async Task loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null) + { + var skinManager = osu.Dependencies.Get(); + return await skinManager.Import(archive); + } + } +} diff --git a/osu.Game.Tests/Skins/LegacyManiaSkinDecoderTest.cs b/osu.Game.Tests/Skins/LegacyManiaSkinDecoderTest.cs new file mode 100644 index 0000000000..e811979aed --- /dev/null +++ b/osu.Game.Tests/Skins/LegacyManiaSkinDecoderTest.cs @@ -0,0 +1,118 @@ +// 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.IO; +using osu.Game.Skinning; +using osu.Game.Tests.Resources; +using osuTK.Graphics; + +namespace osu.Game.Tests.Skins +{ + [TestFixture] + public class LegacyManiaSkinDecoderTest + { + [Test] + public void TestParseSingleConfig() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-single.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(1)); + Assert.That(configs[0].Keys, Is.EqualTo(4)); + Assert.That(configs[0].ColumnWidth, Is.EquivalentTo(new float[] { 16, 16, 16, 16 })); + Assert.That(configs[0].HitPosition, Is.EqualTo(16)); + } + } + + [Test] + public void TestParseMultipleConfig() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-multiple.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(2)); + + Assert.That(configs[0].Keys, Is.EqualTo(4)); + Assert.That(configs[0].ColumnWidth, Is.EquivalentTo(new float[] { 16, 16, 16, 16 })); + Assert.That(configs[0].HitPosition, Is.EqualTo(16)); + + Assert.That(configs[1].Keys, Is.EqualTo(2)); + Assert.That(configs[1].ColumnWidth, Is.EquivalentTo(new float[] { 32, 32 })); + Assert.That(configs[1].HitPosition, Is.EqualTo(32)); + } + } + + [Test] + public void TestParseDuplicateConfig() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-single.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(1)); + Assert.That(configs[0].Keys, Is.EqualTo(4)); + Assert.That(configs[0].ColumnWidth, Is.EquivalentTo(new float[] { 16, 16, 16, 16 })); + Assert.That(configs[0].HitPosition, Is.EqualTo(16)); + } + } + + [Test] + public void TestParseWithUnnecessaryExtraData() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-extra-data.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(1)); + Assert.That(configs[0].Keys, Is.EqualTo(4)); + Assert.That(configs[0].ColumnWidth, Is.EquivalentTo(new float[] { 16, 16, 16, 16 })); + Assert.That(configs[0].HitPosition, Is.EqualTo(16)); + } + } + + [Test] + public void TestParseColours() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-colours.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(1)); + Assert.That(configs[0].CustomColours, Contains.Key("ColourBarline").And.ContainValue(new Color4(50, 50, 50, 50))); + } + } + + [Test] + public void TestMinimumColumnWidthFallsBackWhenZeroIsProvided() + { + var decoder = new LegacyManiaSkinDecoder(); + + using (var resStream = TestResources.OpenResource("mania-skin-zero-minwidth.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var configs = decoder.Decode(stream); + + Assert.That(configs.Count, Is.EqualTo(1)); + Assert.That(configs[0].MinimumColumnWidth, Is.EqualTo(16)); + } + } + } +} diff --git a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs index 24ef9e4535..aedf26ee75 100644 --- a/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs +++ b/osu.Game.Tests/Skins/LegacySkinDecoderTest.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.IO; using NUnit.Framework; +using osu.Game.IO; using osu.Game.Skinning; using osu.Game.Tests.Resources; using osuTK.Graphics; @@ -13,33 +13,100 @@ namespace osu.Game.Tests.Skins [TestFixture] public class LegacySkinDecoderTest { - [TestCase(true)] - [TestCase(false)] - public void TestDecodeSkinColours(bool hasColours) + [Test] + public void TestDecodeSkinColours() { var decoder = new LegacySkinDecoder(); - using (var resStream = TestResources.OpenResource(hasColours ? "skin.ini" : "skin-empty.ini")) - using (var stream = new StreamReader(resStream)) + using (var resStream = TestResources.OpenResource("skin.ini")) + using (var stream = new LineBufferedReader(resStream)) { var comboColors = decoder.Decode(stream).ComboColours; - - List expectedColors; - if (hasColours) - expectedColors = new List - { - new Color4(142, 199, 255, 255), - new Color4(255, 128, 128, 255), - new Color4(128, 255, 255, 255), - new Color4(100, 100, 100, 100), - }; - else - expectedColors = new DefaultSkin().Configuration.ComboColours; + var expectedColors = new List + { + new Color4(142, 199, 255, 255), + new Color4(255, 128, 128, 255), + new Color4(128, 255, 255, 255), + new Color4(100, 100, 100, 100), + }; Assert.AreEqual(expectedColors.Count, comboColors.Count); for (int i = 0; i < expectedColors.Count; i++) Assert.AreEqual(expectedColors[i], comboColors[i]); } } + + [Test] + public void TestDecodeEmptySkinColours() + { + var decoder = new LegacySkinDecoder(); + + using (var resStream = TestResources.OpenResource("skin-empty.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var comboColors = decoder.Decode(stream).ComboColours; + var expectedColors = SkinConfiguration.DefaultComboColours; + + Assert.AreEqual(expectedColors.Count, comboColors.Count); + for (int i = 0; i < expectedColors.Count; i++) + Assert.AreEqual(expectedColors[i], comboColors[i]); + } + } + + [Test] + public void TestDecodeEmptySkinColoursNoFallback() + { + var decoder = new LegacySkinDecoder(); + + using (var resStream = TestResources.OpenResource("skin-empty.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var skinConfiguration = decoder.Decode(stream); + skinConfiguration.AllowDefaultComboColoursFallback = false; + Assert.IsNull(skinConfiguration.ComboColours); + } + } + + [Test] + public void TestDecodeGeneral() + { + var decoder = new LegacySkinDecoder(); + + using (var resStream = TestResources.OpenResource("skin.ini")) + using (var stream = new LineBufferedReader(resStream)) + { + var config = decoder.Decode(stream); + + Assert.AreEqual("test skin", config.SkinInfo.Name); + Assert.AreEqual("TestValue", config.ConfigDictionary["TestLookup"]); + } + } + + [Test] + public void TestDecodeSpecifiedVersion() + { + var decoder = new LegacySkinDecoder(); + using (var resStream = TestResources.OpenResource("skin-20.ini")) + using (var stream = new LineBufferedReader(resStream)) + Assert.AreEqual(2.0m, decoder.Decode(stream).LegacyVersion); + } + + [Test] + public void TestDecodeLatestVersion() + { + var decoder = new LegacySkinDecoder(); + using (var resStream = TestResources.OpenResource("skin-latest.ini")) + using (var stream = new LineBufferedReader(resStream)) + Assert.AreEqual(LegacySkinConfiguration.LATEST_VERSION, decoder.Decode(stream).LegacyVersion); + } + + [Test] + public void TestDecodeNoVersion() + { + var decoder = new LegacySkinDecoder(); + using (var resStream = TestResources.OpenResource("skin-empty.ini")) + using (var stream = new LineBufferedReader(resStream)) + Assert.That(decoder.Decode(stream).LegacyVersion, Is.EqualTo(1.0m)); + } } } diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.cs new file mode 100644 index 0000000000..eff430ac25 --- /dev/null +++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinResources.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.IO.Archives; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Skins +{ + [HeadlessTest] + public class TestSceneBeatmapSkinResources : OsuTestScene + { + [Resolved] + private BeatmapManager beatmaps { get; set; } + + private WorkingBeatmap beatmap; + + [BackgroundDependencyLoader] + private void load() + { + var imported = beatmaps.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-beatmap.osz"))).Result; + beatmap = beatmaps.GetWorkingBeatmap(imported.Beatmaps[0]); + beatmap.LoadTrack(); + } + + [Test] + public void TestRetrieveOggSample() => AddAssert("sample is non-null", () => beatmap.Skin.GetSample(new SampleInfo("sample")) != null); + + [Test] + public void TestRetrieveOggTrack() => AddAssert("track is non-null", () => !(beatmap.Track is TrackVirtual)); + } +} diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs new file mode 100644 index 0000000000..ad5b3ec0f6 --- /dev/null +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs @@ -0,0 +1,227 @@ +// 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 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.Textures; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.IO; +using osu.Game.Rulesets.Osu; +using osu.Game.Skinning; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; +using osuTK.Graphics; + +namespace osu.Game.Tests.Skins +{ + [TestFixture] + [HeadlessTest] + public class TestSceneSkinConfigurationLookup : OsuTestScene + { + private UserSkinSource userSource; + private BeatmapSkinSource beatmapSource; + private SkinRequester requester; + + [SetUp] + public void SetUp() => Schedule(() => + { + Add(new SkinProvidingContainer(userSource = new UserSkinSource()) + .WithChild(new SkinProvidingContainer(beatmapSource = new BeatmapSkinSource()) + .WithChild(requester = new SkinRequester()))); + }); + + [Test] + public void TestBasicLookup() + { + AddStep("Add config values", () => + { + userSource.Configuration.ConfigDictionary["Lookup"] = "user skin"; + beatmapSource.Configuration.ConfigDictionary["Lookup"] = "beatmap skin"; + }); + + AddAssert("Check lookup finds beatmap skin", () => requester.GetConfig("Lookup")?.Value == "beatmap skin"); + } + + [Test] + public void TestFloatLookup() + { + AddStep("Add config values", () => userSource.Configuration.ConfigDictionary["FloatTest"] = "1.1"); + AddAssert("Check float parse lookup", () => requester.GetConfig("FloatTest")?.Value == 1.1f); + } + + [Test] + public void TestBoolLookup() + { + AddStep("Add config values", () => userSource.Configuration.ConfigDictionary["BoolTest"] = "1"); + AddAssert("Check bool parse lookup", () => requester.GetConfig("BoolTest")?.Value == true); + } + + [Test] + public void TestEnumLookup() + { + AddStep("Add config values", () => userSource.Configuration.ConfigDictionary["Test"] = "Test2"); + AddAssert("Check enum parse lookup", () => requester.GetConfig(LookupType.Test)?.Value == ValueType.Test2); + } + + [Test] + public void TestLookupFailure() + { + AddAssert("Check lookup failure", () => requester.GetConfig("Lookup") == null); + } + + [Test] + public void TestLookupNull() + { + AddStep("Add config values", () => userSource.Configuration.ConfigDictionary["Lookup"] = null); + + AddAssert("Check lookup null", () => + { + var bindable = requester.GetConfig("Lookup"); + return bindable != null && bindable.Value == null; + }); + } + + [Test] + public void TestColourLookup() + { + AddStep("Add config colour", () => userSource.Configuration.CustomColours["Lookup"] = Color4.Red); + AddAssert("Check colour lookup", () => requester.GetConfig(new SkinCustomColourLookup("Lookup"))?.Value == Color4.Red); + } + + [Test] + public void TestGlobalLookup() + { + AddAssert("Check combo colours", () => requester.GetConfig>(GlobalSkinColours.ComboColours)?.Value?.Count > 0); + } + + [Test] + public void TestWrongColourType() + { + AddStep("Add config colour", () => userSource.Configuration.CustomColours["Lookup"] = Color4.Red); + + AddAssert("perform incorrect lookup", () => + { + try + { + requester.GetConfig(new SkinCustomColourLookup("Lookup")); + return false; + } + catch + { + return true; + } + }); + } + + [Test] + public void TestEmptyComboColours() + { + AddAssert("Check retrieved combo colours is skin default colours", () => + requester.GetConfig>(GlobalSkinColours.ComboColours)?.Value?.SequenceEqual(SkinConfiguration.DefaultComboColours) ?? false); + } + + [Test] + public void TestEmptyComboColoursNoFallback() + { + AddStep("Add custom combo colours to user skin", () => userSource.Configuration.AddComboColours( + new Color4(100, 150, 200, 255), + new Color4(55, 110, 166, 255), + new Color4(75, 125, 175, 255) + )); + + AddStep("Disallow default colours fallback in beatmap skin", () => beatmapSource.Configuration.AllowDefaultComboColoursFallback = false); + + AddAssert("Check retrieved combo colours from user skin", () => + requester.GetConfig>(GlobalSkinColours.ComboColours)?.Value?.SequenceEqual(userSource.Configuration.ComboColours) ?? false); + } + + [Test] + public void TestNullBeatmapVersionFallsBackToUserSkin() + { + AddStep("Set user skin version 2.3", () => userSource.Configuration.LegacyVersion = 2.3m); + AddStep("Set beatmap skin version null", () => beatmapSource.Configuration.LegacyVersion = null); + AddAssert("Check legacy version lookup", () => requester.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value == 2.3m); + } + + [Test] + public void TestSetBeatmapVersionFallsBackToUserSkin() + { + // completely ignoring beatmap versions for simplicity. + AddStep("Set user skin version 2.3", () => userSource.Configuration.LegacyVersion = 2.3m); + AddStep("Set beatmap skin version null", () => beatmapSource.Configuration.LegacyVersion = 1.7m); + AddAssert("Check legacy version lookup", () => requester.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value == 2.3m); + } + + [Test] + public void TestNullBeatmapAndUserVersionFallsBackToLatest() + { + AddStep("Set user skin version 2.3", () => userSource.Configuration.LegacyVersion = null); + AddStep("Set beatmap skin version null", () => beatmapSource.Configuration.LegacyVersion = null); + AddAssert("Check legacy version lookup", + () => requester.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value == LegacySkinConfiguration.LATEST_VERSION); + } + + [Test] + public void TestIniWithNoVersionFallsBackTo1() + { + AddStep("Parse skin with no version", () => userSource.Configuration = new LegacySkinDecoder().Decode(new LineBufferedReader(new MemoryStream()))); + AddAssert("Check legacy version lookup", () => requester.GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value == 1.0m); + } + + public enum LookupType + { + Test + } + + public enum ValueType + { + Test1, + Test2, + Test3 + } + + public class UserSkinSource : LegacySkin + { + public UserSkinSource() + : base(new SkinInfo(), null, null, string.Empty) + { + } + } + + public class BeatmapSkinSource : LegacyBeatmapSkin + { + public BeatmapSkinSource() + : base(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, null, null) + { + } + } + + public class SkinRequester : Drawable, ISkin + { + private ISkinSource skin; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + this.skin = skin; + } + + public Drawable GetDrawableComponent(ISkinComponent component) => skin.GetDrawableComponent(component); + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => skin.GetTexture(componentName, wrapModeS, wrapModeT); + + public SampleChannel GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); + + public IBindable GetConfig(TLookup lookup) => skin.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Tests/Skins/TestSceneSkinResources.cs b/osu.Game.Tests/Skins/TestSceneSkinResources.cs new file mode 100644 index 0000000000..107a96292f --- /dev/null +++ b/osu.Game.Tests/Skins/TestSceneSkinResources.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.Allocation; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.IO.Archives; +using osu.Game.Skinning; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Skins +{ + [HeadlessTest] + public class TestSceneSkinResources : OsuTestScene + { + [Resolved] + private SkinManager skins { get; set; } + + private ISkin skin; + + [BackgroundDependencyLoader] + private void load() + { + var imported = skins.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-skin.osk"))).Result; + skin = skins.GetSkin(imported); + } + + [Test] + public void TestRetrieveOggSample() => AddAssert("sample is non-null", () => skin.GetSample(new SampleInfo("sample")) != null); + } +} diff --git a/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs new file mode 100644 index 0000000000..80f1b02794 --- /dev/null +++ b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs @@ -0,0 +1,95 @@ +// 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.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Configuration.Tracking; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Configuration; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Testing +{ + /// + /// A test scene ensuring the dependencies for the + /// provided ruleset below are cached at the base implementation. + /// + [HeadlessTest] + public class TestSceneRulesetDependencies : OsuTestScene + { + protected override Ruleset CreateRuleset() => new TestRuleset(); + + [Test] + public void TestRetrieveTexture() + { + AddAssert("ruleset texture retrieved", () => + Dependencies.Get().Get(@"test-image") != null); + } + + [Test] + public void TestRetrieveSample() + { + AddAssert("ruleset sample retrieved", () => + Dependencies.Get().Get(@"test-sample") != null); + } + + [Test] + public void TestResolveConfigManager() + { + AddAssert("ruleset config resolved", () => + Dependencies.Get() != null); + } + + private class TestRuleset : Ruleset + { + public override string Description => string.Empty; + public override string ShortName => string.Empty; + + public TestRuleset() + { + // temporary ID to let RulesetConfigCache pass our + // config manager to the ruleset dependencies. + RulesetInfo.ID = -1; + } + + public override IResourceStore CreateResourceStore() => new NamespacedResourceStore(TestResources.GetStore(), @"Resources"); + public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new TestRulesetConfigManager(); + + public override IEnumerable GetModsFor(ModType type) => Array.Empty(); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => null; + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => null; + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => null; + } + + private class TestRulesetConfigManager : IRulesetConfigManager + { + public void Load() + { + } + + public bool Save() => true; + + public TrackedSettings CreateTrackedSettings() => new TrackedSettings(); + + public void LoadInto(TrackedSettings settings) + { + } + + public void Dispose() + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs new file mode 100644 index 0000000000..fba0d92d4b --- /dev/null +++ b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs @@ -0,0 +1,196 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; +using osu.Game.Configuration; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Tests.Visual.Background +{ + public class TestSceneSeasonalBackgroundLoader : ScreenTestScene + { + [Resolved] + private OsuConfigManager config { get; set; } + + [Resolved] + private SessionStatics statics { get; set; } + + [Cached(typeof(LargeTextureStore))] + private LookupLoggingTextureStore textureStore = new LookupLoggingTextureStore(); + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + private SeasonalBackgroundLoader backgroundLoader; + private Container backgroundContainer; + + // in real usages these would be online URLs, but correct execution of this test + // shouldn't be coupled to existence of online assets. + private static readonly List seasonal_background_urls = new List + { + "Backgrounds/bg2", + "Backgrounds/bg4", + "Backgrounds/bg3" + }; + + [BackgroundDependencyLoader] + private void load(LargeTextureStore wrappedStore) + { + textureStore.AddStore(wrappedStore); + + Add(backgroundContainer = new Container + { + RelativeSizeAxes = Axes.Both + }); + } + + [SetUp] + public void SetUp() => Schedule(() => + { + // reset API response in statics to avoid test crosstalk. + statics.Set(Static.SeasonalBackgrounds, null); + textureStore.PerformedLookups.Clear(); + dummyAPI.SetState(APIState.Online); + + backgroundContainer.Clear(); + }); + + [TestCase(-5)] + [TestCase(5)] + public void TestAlwaysSeasonal(int daysOffset) + { + registerBackgroundsResponse(DateTimeOffset.Now.AddDays(daysOffset)); + setSeasonalBackgroundMode(SeasonalBackgroundMode.Always); + + createLoader(); + + for (int i = 0; i < 4; ++i) + loadNextBackground(); + + AddAssert("all backgrounds cycled", () => new HashSet(textureStore.PerformedLookups).SetEquals(seasonal_background_urls)); + } + + [TestCase(-5)] + [TestCase(5)] + public void TestNeverSeasonal(int daysOffset) + { + registerBackgroundsResponse(DateTimeOffset.Now.AddDays(daysOffset)); + setSeasonalBackgroundMode(SeasonalBackgroundMode.Never); + + createLoader(); + + assertNoBackgrounds(); + } + + [Test] + public void TestSometimesInSeason() + { + registerBackgroundsResponse(DateTimeOffset.Now.AddDays(5)); + setSeasonalBackgroundMode(SeasonalBackgroundMode.Sometimes); + + createLoader(); + + assertAnyBackground(); + } + + [Test] + public void TestSometimesOutOfSeason() + { + registerBackgroundsResponse(DateTimeOffset.Now.AddDays(-10)); + setSeasonalBackgroundMode(SeasonalBackgroundMode.Sometimes); + + createLoader(); + + assertNoBackgrounds(); + } + + [Test] + public void TestDelayedConnectivity() + { + registerBackgroundsResponse(DateTimeOffset.Now.AddDays(30)); + setSeasonalBackgroundMode(SeasonalBackgroundMode.Always); + AddStep("go offline", () => dummyAPI.SetState(APIState.Offline)); + + createLoader(); + assertNoBackgrounds(); + + AddStep("go online", () => dummyAPI.SetState(APIState.Online)); + + assertAnyBackground(); + } + + private void registerBackgroundsResponse(DateTimeOffset endDate) + => AddStep("setup request handler", () => + { + dummyAPI.HandleRequest = request => + { + if (dummyAPI.State.Value != APIState.Online || !(request is GetSeasonalBackgroundsRequest backgroundsRequest)) + return; + + backgroundsRequest.TriggerSuccess(new APISeasonalBackgrounds + { + Backgrounds = seasonal_background_urls.Select(url => new APISeasonalBackground { Url = url }).ToList(), + EndDate = endDate + }); + }; + }); + + private void setSeasonalBackgroundMode(SeasonalBackgroundMode mode) + => AddStep($"set seasonal mode to {mode}", () => config.Set(OsuSetting.SeasonalBackgroundMode, mode)); + + private void createLoader() + => AddStep("create loader", () => + { + if (backgroundLoader != null) + Remove(backgroundLoader); + + Add(backgroundLoader = new SeasonalBackgroundLoader()); + }); + + private void loadNextBackground() + { + SeasonalBackground background = null; + + AddStep("create next background", () => + { + background = backgroundLoader.LoadNextBackground(); + LoadComponentAsync(background, bg => backgroundContainer.Child = bg); + }); + + AddUntilStep("background loaded", () => background.IsLoaded); + } + + private void assertAnyBackground() + { + loadNextBackground(); + AddAssert("background looked up", () => textureStore.PerformedLookups.Any()); + } + + private void assertNoBackgrounds() + { + AddAssert("no background available", () => backgroundLoader.LoadNextBackground() == null); + AddAssert("no lookups performed", () => !textureStore.PerformedLookups.Any()); + } + + private class LookupLoggingTextureStore : LargeTextureStore + { + public List PerformedLookups { get; } = new List(); + + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) + { + PerformedLookups.Add(name); + return base.Get(name, wrapModeS, wrapModeT); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs new file mode 100644 index 0000000000..9ef9649f77 --- /dev/null +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -0,0 +1,407 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Input.States; +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.Graphics.Sprites; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Scoring; +using osu.Game.Screens; +using osu.Game.Screens.Backgrounds; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.PlayerSettings; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Select; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using osu.Game.Users; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Background +{ + [TestFixture] + public class TestSceneUserDimBackgrounds : OsuManualInputManagerTestScene + { + private DummySongSelect songSelect; + private TestPlayerLoader playerLoader; + private LoadBlockingTestPlayer player; + private BeatmapManager manager; + private RulesetStore rulesets; + + [BackgroundDependencyLoader] + 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(new OsuConfigManager(LocalStorage)); + + manager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + + Beatmap.SetDefault(); + } + + [SetUp] + public virtual void SetUp() => Schedule(() => + { + var stack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }; + Child = stack; + + stack.Push(songSelect = new DummySongSelect()); + }); + + /// + /// Check if properly triggers the visual settings preview when a user hovers over the visual settings panel. + /// + [Test] + public void TestPlayerLoaderSettingsHover() + { + setupUserSettings(); + AddStep("Start player loader", () => songSelect.Push(playerLoader = new TestPlayerLoader(player = new LoadBlockingTestPlayer { BlockLoad = true }))); + AddUntilStep("Wait for Player Loader to load", () => playerLoader?.IsLoaded ?? false); + AddAssert("Background retained from song select", () => songSelect.IsBackgroundCurrent()); + AddStep("Trigger background preview", () => + { + InputManager.MoveMouseTo(playerLoader.ScreenPos); + InputManager.MoveMouseTo(playerLoader.VisualSettingsPos); + }); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddStep("Stop background preview", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); + AddUntilStep("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && playerLoader.IsBlurCorrect()); + } + + /// + /// In the case of a user triggering the dim preview the instant player gets loaded, then moving the cursor off of the visual settings: + /// The OnHover of PlayerLoader will trigger, which could potentially cause visual settings to be unapplied unless checked for in PlayerLoader. + /// We need to check that in this scenario, the dim and blur is still properly applied after entering player. + /// + [Test] + public void TestPlayerLoaderTransition() + { + performFullSetup(); + AddStep("Trigger hover event", () => playerLoader.TriggerOnHover()); + AddAssert("Background retained from song select", () => songSelect.IsBackgroundCurrent()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + } + + /// + /// Make sure the background is fully invisible (Alpha == 0) when the background should be disabled by the storyboard. + /// + [Test] + public void TestStoryboardBackgroundVisibility() + { + performFullSetup(); + createFakeStoryboard(); + AddStep("Enable Storyboard", () => + { + player.ReplacesBackground.Value = true; + player.StoryboardEnabled.Value = true; + }); + AddUntilStep("Background is invisible, storyboard is visible", () => songSelect.IsBackgroundInvisible() && player.IsStoryboardVisible); + AddStep("Disable Storyboard", () => + { + player.ReplacesBackground.Value = false; + player.StoryboardEnabled.Value = false; + }); + AddUntilStep("Background is visible, storyboard is invisible", () => songSelect.IsBackgroundVisible() && !player.IsStoryboardVisible); + } + + /// + /// When exiting player, the screen that it suspends/exits to needs to have a fully visible (Alpha == 1) background. + /// + [Test] + public void TestStoryboardTransition() + { + performFullSetup(); + createFakeStoryboard(); + AddStep("Exit to song select", () => player.Exit()); + AddUntilStep("Background is visible", () => songSelect.IsBackgroundVisible()); + } + + /// + /// Ensure is properly accepting user-defined visual changes for a background. + /// + [Test] + public void TestDisableUserDimBackground() + { + performFullSetup(); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddStep("Enable user dim", () => songSelect.DimEnabled.Value = false); + AddUntilStep("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && songSelect.IsUserBlurDisabled()); + AddStep("Disable user dim", () => songSelect.DimEnabled.Value = true); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + } + + /// + /// Ensure is properly accepting user-defined visual changes for a storyboard. + /// + [Test] + public void TestDisableUserDimStoryboard() + { + performFullSetup(); + createFakeStoryboard(); + AddStep("Enable Storyboard", () => + { + player.ReplacesBackground.Value = true; + player.StoryboardEnabled.Value = true; + }); + AddStep("Enable user dim", () => player.DimmableStoryboard.EnableUserDim.Value = true); + 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); + AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible); + } + + /// + /// Check if the visual settings container retains dim and blur when pausing + /// + [Test] + public void TestPause() + { + performFullSetup(true); + AddStep("Pause", () => player.Pause()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddStep("Unpause", () => player.Resume()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + } + + /// + /// Check if the visual settings container removes user dim when suspending for + /// + [Test] + public void TestTransition() + { + performFullSetup(); + + FadeAccessibleResults results = null; + + AddStep("Transition to Results", () => player.Push(results = new FadeAccessibleResults(new ScoreInfo + { + User = new User { Username = "osu!" }, + Beatmap = new TestBeatmap(Ruleset.Value).BeatmapInfo, + Ruleset = Ruleset.Value, + }))); + + AddUntilStep("Wait for results is current", () => results.IsCurrentScreen()); + AddUntilStep("Screen is undimmed, original background retained", () => + songSelect.IsBackgroundUndimmed() && songSelect.IsBackgroundCurrent() && results.IsBlurCorrect()); + } + + /// + /// 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. + /// + [Test] + public void TestResumeFromPlayer() + { + performFullSetup(); + AddStep("Move mouse to Visual Settings", () => InputManager.MoveMouseTo(playerLoader.VisualSettingsPos)); + AddStep("Resume PlayerLoader", () => player.Restart()); + AddUntilStep("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddStep("Move mouse to center of screen", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); + AddUntilStep("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && playerLoader.IsBlurCorrect()); + } + + private void createFakeStoryboard() => AddStep("Create storyboard", () => + { + player.StoryboardEnabled.Value = false; + player.ReplacesBackground.Value = false; + player.DimmableStoryboard.Add(new OsuSpriteText + { + Size = new Vector2(500, 50), + Alpha = 1, + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "THIS IS A STORYBOARD", + Font = new FontUsage(size: 50) + }); + }); + + private void performFullSetup(bool allowPause = false) + { + setupUserSettings(); + + AddStep("Start player loader", () => songSelect.Push(playerLoader = new TestPlayerLoader(player = new LoadBlockingTestPlayer(allowPause)))); + + AddUntilStep("Wait for Player Loader to load", () => playerLoader.IsLoaded); + AddStep("Move mouse to center of screen", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); + AddUntilStep("Wait for player to load", () => player.IsLoaded); + } + + private void setupUserSettings() + { + AddUntilStep("Song select is current", () => songSelect.IsCurrentScreen()); + AddUntilStep("Song select has selection", () => songSelect.Carousel?.SelectedBeatmap != null); + AddStep("Set default user settings", () => + { + SelectedMods.Value = SelectedMods.Value.Concat(new[] { new OsuModNoFail() }).ToArray(); + songSelect.DimLevel.Value = 0.7f; + songSelect.BlurLevel.Value = 0.4f; + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + rulesets?.Dispose(); + } + + private class DummySongSelect : PlaySongSelect + { + protected override BackgroundScreen CreateBackground() + { + FadeAccessibleBackground background = new FadeAccessibleBackground(Beatmap.Value); + DimEnabled.BindTo(background.EnableUserDim); + return background; + } + + public readonly Bindable DimEnabled = new Bindable(); + public readonly Bindable DimLevel = new BindableDouble(); + public readonly Bindable BlurLevel = new BindableDouble(); + + public new BeatmapCarousel Carousel => base.Carousel; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + config.BindWith(OsuSetting.DimLevel, DimLevel); + config.BindWith(OsuSetting.BlurLevel, BlurLevel); + } + + public bool IsBackgroundDimmed() => ((FadeAccessibleBackground)Background).CurrentColour == OsuColour.Gray(1f - ((FadeAccessibleBackground)Background).CurrentDim); + + public bool IsBackgroundUndimmed() => ((FadeAccessibleBackground)Background).CurrentColour == Color4.White; + + public bool IsUserBlurApplied() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2((float)BlurLevel.Value * BackgroundScreenBeatmap.USER_BLUR_FACTOR); + + public bool IsUserBlurDisabled() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2(0); + + public bool IsBackgroundInvisible() => ((FadeAccessibleBackground)Background).CurrentAlpha == 0; + + public bool IsBackgroundVisible() => ((FadeAccessibleBackground)Background).CurrentAlpha == 1; + + public bool IsBlurCorrect() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2(BACKGROUND_BLUR); + + /// + /// Make sure every time a screen gets pushed, the background doesn't get replaced + /// + /// Whether or not the original background (The one created in DummySongSelect) is still the current background + public bool IsBackgroundCurrent() => ((FadeAccessibleBackground)Background).IsCurrentScreen(); + } + + private class FadeAccessibleResults : ResultsScreen + { + public FadeAccessibleResults(ScoreInfo score) + : base(score) + { + } + + protected override BackgroundScreen CreateBackground() => new FadeAccessibleBackground(Beatmap.Value); + + public bool IsBlurCorrect() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2(BACKGROUND_BLUR); + } + + private class LoadBlockingTestPlayer : TestPlayer + { + protected override BackgroundScreen CreateBackground() => new FadeAccessibleBackground(Beatmap.Value); + + public new DimmableStoryboard DimmableStoryboard => base.DimmableStoryboard; + + // Whether or not the player should be allowed to load. + public bool BlockLoad; + + public Bindable StoryboardEnabled; + public readonly Bindable ReplacesBackground = new Bindable(); + public readonly Bindable IsPaused = new Bindable(); + + public LoadBlockingTestPlayer(bool allowPause = true) + : base(allowPause) + { + } + + public bool IsStoryboardVisible => DimmableStoryboard.ContentDisplayed; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, CancellationToken token) + { + while (BlockLoad && !token.IsCancellationRequested) + Thread.Sleep(1); + + StoryboardEnabled = config.GetBindable(OsuSetting.ShowStoryboard); + ReplacesBackground.BindTo(Background.StoryboardReplacesBackground); + DrawableRuleset.IsPaused.BindTo(IsPaused); + } + } + + private class TestPlayerLoader : PlayerLoader + { + public VisualSettings VisualSettingsPos => VisualSettings; + public BackgroundScreen ScreenPos => Background; + + public TestPlayerLoader(Player player) + : base(() => player) + { + } + + public void TriggerOnHover() => OnHover(new HoverEvent(new InputState())); + + public bool IsBlurCorrect() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2(BACKGROUND_BLUR); + + protected override BackgroundScreen CreateBackground() => new FadeAccessibleBackground(Beatmap.Value); + } + + private class FadeAccessibleBackground : BackgroundScreenBeatmap + { + protected override DimmableBackground CreateFadeContainer() => dimmable = new TestDimmableBackground { RelativeSizeAxes = Axes.Both }; + + public Color4 CurrentColour => dimmable.CurrentColour; + + public float CurrentAlpha => dimmable.CurrentAlpha; + + public float CurrentDim => dimmable.DimLevel; + + public Vector2 CurrentBlur => Background.BlurSigma; + + private TestDimmableBackground dimmable; + + public FadeAccessibleBackground(WorkingBeatmap beatmap) + : base(beatmap) + { + } + } + + private class TestDimmableBackground : BackgroundScreenBeatmap.DimmableBackground + { + public Color4 CurrentColour => Content.Colour; + public float CurrentAlpha => Content.Alpha; + + public new float DimLevel => base.DimLevel; + } + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs index dc4ceed59e..fede99f450 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimContainer.cs @@ -1,407 +1,112 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Framework.Input.States; -using osu.Framework.Platform; -using osu.Framework.Screens; -using osu.Game.Beatmaps; +using osu.Framework.Graphics.Shapes; using osu.Game.Configuration; -using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Scoring; -using osu.Game.Screens; -using osu.Game.Screens.Backgrounds; -using osu.Game.Screens.Play; -using osu.Game.Screens.Play.PlayerSettings; -using osu.Game.Screens.Select; -using osu.Game.Tests.Resources; -using osu.Game.Users; -using osuTK; using osuTK.Graphics; namespace osu.Game.Tests.Visual.Background { - [TestFixture] - public class TestSceneUserDimContainer : ManualInputManagerTestScene + public class TestSceneUserDimContainer : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ScreenWithBeatmapBackground), - typeof(PlayerLoader), - typeof(Player), - typeof(UserDimContainer), - typeof(OsuScreen) - }; + private TestUserDimContainer userDimContainer; - private DummySongSelect songSelect; - private TestPlayerLoader playerLoader; - private TestPlayer player; - private DatabaseContextFactory factory; - private BeatmapManager manager; - private RulesetStore rulesets; + private readonly BindableBool isBreakTime = new BindableBool(); + + private Bindable lightenDuringBreaks = new Bindable(); [BackgroundDependencyLoader] - private void load(GameHost host, AudioManager audio) + private void load(OsuConfigManager config) { - factory = new DatabaseContextFactory(LocalStorage); - factory.ResetDatabase(); - - using (var usage = factory.Get()) - usage.Migrate(); - - factory.ResetDatabase(); - - using (var usage = factory.Get()) - usage.Migrate(); - - Dependencies.Cache(rulesets = new RulesetStore(factory)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, factory, rulesets, null, audio, host, Beatmap.Default)); - Dependencies.Cache(new OsuConfigManager(LocalStorage)); - - manager.Import(TestResources.GetTestBeatmapForImport()).Wait(); - - Beatmap.SetDefault(); + lightenDuringBreaks = config.GetBindable(OsuSetting.LightenDuringBreaks); } [SetUp] - public virtual void SetUp() => Schedule(() => + public void SetUp() => Schedule(() => { - Child = new OsuScreenStack(songSelect = new DummySongSelect()) + Child = userDimContainer = new TestUserDimContainer { - RelativeSizeAxes = Axes.Both + RelativeSizeAxes = Axes.Both, + Child = new Box + { + Colour = Color4.White, + RelativeSizeAxes = Axes.Both, + }, }; + + userDimContainer.IsBreakTime.BindTo(isBreakTime); + isBreakTime.Value = false; + + lightenDuringBreaks.Value = false; }); - /// - /// Check if properly triggers the visual settings preview when a user hovers over the visual settings panel. - /// + private const float test_user_dim = 0.6f; + private const float test_user_dim_lightened = test_user_dim - UserDimContainer.BREAK_LIGHTEN_AMOUNT; + + [TestCase(test_user_dim, test_user_dim_lightened)] + [TestCase(0.2f, 0.0f)] + [TestCase(0.0f, 0.0f)] + public void TestBreakLightening(float userDim, float expectedBreakDim) + { + AddStep($"set dim level {userDim}", () => userDimContainer.UserDimLevel.Value = userDim); + AddStep("set lighten during break", () => lightenDuringBreaks.Value = true); + + AddStep("set break", () => isBreakTime.Value = true); + AddUntilStep("has lightened", () => userDimContainer.DimEqual(expectedBreakDim)); + AddStep("clear break", () => isBreakTime.Value = false); + AddUntilStep("not lightened", () => userDimContainer.DimEqual(userDim)); + } + [Test] - public void PlayerLoaderSettingsHoverTest() + public void TestEnableSettingDuringBreak() { - setupUserSettings(); - AddStep("Start player loader", () => songSelect.Push(playerLoader = new TestPlayerLoader(player = new TestPlayer { BlockLoad = true }))); - AddUntilStep("Wait for Player Loader to load", () => playerLoader?.IsLoaded ?? false); - AddAssert("Background retained from song select", () => songSelect.IsBackgroundCurrent()); - AddStep("Trigger background preview", () => - { - InputManager.MoveMouseTo(playerLoader.ScreenPos); - InputManager.MoveMouseTo(playerLoader.VisualSettingsPos); - }); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); - AddStep("Stop background preview", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); - waitForDim(); - AddAssert("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && playerLoader.IsBlurCorrect()); + AddStep("set dim level 0.6", () => userDimContainer.UserDimLevel.Value = test_user_dim); + + AddStep("set break", () => isBreakTime.Value = true); + AddUntilStep("not lightened", () => userDimContainer.DimEqual(test_user_dim)); + AddStep("set lighten during break", () => lightenDuringBreaks.Value = true); + AddUntilStep("has lightened", () => userDimContainer.DimEqual(test_user_dim_lightened)); } - /// - /// In the case of a user triggering the dim preview the instant player gets loaded, then moving the cursor off of the visual settings: - /// The OnHover of PlayerLoader will trigger, which could potentially cause visual settings to be unapplied unless checked for in PlayerLoader. - /// We need to check that in this scenario, the dim and blur is still properly applied after entering player. - /// [Test] - public void PlayerLoaderTransitionTest() + public void TestDisableSettingDuringBreak() { - performFullSetup(); - AddStep("Trigger hover event", () => playerLoader.TriggerOnHover()); - AddAssert("Background retained from song select", () => songSelect.IsBackgroundCurrent()); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); + AddStep("set dim level 0.6", () => userDimContainer.UserDimLevel.Value = test_user_dim); + AddStep("set lighten during break", () => lightenDuringBreaks.Value = true); + + AddStep("set break", () => isBreakTime.Value = true); + AddUntilStep("has lightened", () => userDimContainer.DimEqual(test_user_dim_lightened)); + AddStep("clear lighten during break", () => lightenDuringBreaks.Value = false); + AddUntilStep("not lightened", () => userDimContainer.DimEqual(test_user_dim)); + AddStep("clear break", () => isBreakTime.Value = false); + AddUntilStep("not lightened", () => userDimContainer.DimEqual(test_user_dim)); } - /// - /// Make sure the background is fully invisible (Alpha == 0) when the background should be disabled by the storyboard. - /// [Test] - public void StoryboardBackgroundVisibilityTest() + public void TestIgnoreUserSettings() { - performFullSetup(); - createFakeStoryboard(); - AddStep("Storyboard Enabled", () => - { - player.ReplacesBackground.Value = true; - player.StoryboardEnabled.Value = true; - }); - waitForDim(); - AddAssert("Background is invisible, storyboard is visible", () => songSelect.IsBackgroundInvisible() && player.IsStoryboardVisible); - AddStep("Storyboard Disabled", () => - { - player.ReplacesBackground.Value = false; - player.StoryboardEnabled.Value = false; - }); - waitForDim(); - AddAssert("Background is visible, storyboard is invisible", () => songSelect.IsBackgroundVisible() && !player.IsStoryboardVisible); + AddStep("set dim level 0.6", () => userDimContainer.UserDimLevel.Value = test_user_dim); + AddUntilStep("dim reached", () => userDimContainer.DimEqual(test_user_dim)); + + AddStep("ignore settings", () => userDimContainer.IgnoreUserSettings.Value = true); + AddUntilStep("no dim", () => userDimContainer.DimEqual(0)); + AddStep("set break", () => isBreakTime.Value = true); + AddAssert("no dim", () => userDimContainer.DimEqual(0)); + AddStep("clear break", () => isBreakTime.Value = false); + AddAssert("no dim", () => userDimContainer.DimEqual(0)); } - /// - /// When exiting player, the screen that it suspends/exits to needs to have a fully visible (Alpha == 1) background. - /// - [Test] - public void StoryboardTransitionTest() + private class TestUserDimContainer : UserDimContainer { - performFullSetup(); - createFakeStoryboard(); - AddStep("Exit to song select", () => player.Exit()); - waitForDim(); - AddAssert("Background is visible", () => songSelect.IsBackgroundVisible()); - } + public bool DimEqual(float expectedDimLevel) => Content.Colour == OsuColour.Gray(1f - expectedDimLevel); - /// - /// Check if the is properly accepting user-defined visual changes at all. - /// - [Test] - public void DisableUserDimTest() - { - performFullSetup(); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); - AddStep("EnableUserDim disabled", () => songSelect.DimEnabled.Value = false); - waitForDim(); - AddAssert("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && songSelect.IsUserBlurDisabled()); - AddStep("EnableUserDim enabled", () => songSelect.DimEnabled.Value = true); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); - } - - /// - /// Check if the visual settings container retains dim and blur when pausing - /// - [Test] - public void PauseTest() - { - performFullSetup(true); - AddStep("Pause", () => player.Pause()); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); - AddStep("Unpause", () => player.Resume()); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); - } - - /// - /// Check if the visual settings container removes user dim when suspending for - /// - [Test] - public void TransitionTest() - { - performFullSetup(); - var results = new FadeAccessibleResults(new ScoreInfo { User = new User { Username = "osu!" } }); - AddStep("Transition to Results", () => player.Push(results)); - AddUntilStep("Wait for results is current", results.IsCurrentScreen); - waitForDim(); - AddAssert("Screen is undimmed, original background retained", () => - songSelect.IsBackgroundUndimmed() && songSelect.IsBackgroundCurrent() && results.IsBlurCorrect()); - } - - /// - /// Check if background gets undimmed and unblurred when leaving for - /// - [Test] - public void TransitionOutTest() - { - performFullSetup(); - AddStep("Exit to song select", () => player.Exit()); - waitForDim(); - AddAssert("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. - /// - [Test] - public void ResumeFromPlayerTest() - { - performFullSetup(); - AddStep("Move mouse to Visual Settings", () => InputManager.MoveMouseTo(playerLoader.VisualSettingsPos)); - AddStep("Resume PlayerLoader", () => player.Restart()); - waitForDim(); - AddAssert("Screen is dimmed and blur applied", () => songSelect.IsBackgroundDimmed() && songSelect.IsUserBlurApplied()); - AddStep("Move mouse to center of screen", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); - waitForDim(); - AddAssert("Screen is undimmed and user blur removed", () => songSelect.IsBackgroundUndimmed() && playerLoader.IsBlurCorrect()); - } - - private void waitForDim() => AddWaitStep("Wait for dim", 5); - - private void createFakeStoryboard() => AddStep("Create storyboard", () => - { - player.StoryboardEnabled.Value = false; - player.ReplacesBackground.Value = false; - player.DimmableStoryboard.Add(new OsuSpriteText - { - Size = new Vector2(500, 50), - Alpha = 1, - Colour = Color4.White, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "THIS IS A STORYBOARD", - Font = new FontUsage(size: 50) - }); - }); - - private void performFullSetup(bool allowPause = false) - { - setupUserSettings(); - - AddStep("Start player loader", () => songSelect.Push(playerLoader = new TestPlayerLoader(player = new TestPlayer(allowPause)))); - - AddUntilStep("Wait for Player Loader to load", () => playerLoader.IsLoaded); - AddStep("Move mouse to center of screen", () => InputManager.MoveMouseTo(playerLoader.ScreenPos)); - AddUntilStep("Wait for player to load", () => player.IsLoaded); - } - - private void setupUserSettings() - { - AddUntilStep("Song select has selection", () => songSelect.Carousel.SelectedBeatmap != null); - AddStep("Set default user settings", () => - { - Mods.Value = Mods.Value.Concat(new[] { new OsuModNoFail() }).ToArray(); - songSelect.DimLevel.Value = 0.7f; - songSelect.BlurLevel.Value = 0.4f; - }); - } - - private class DummySongSelect : PlaySongSelect - { - protected override BackgroundScreen CreateBackground() - { - FadeAccessibleBackground background = new FadeAccessibleBackground(Beatmap.Value); - DimEnabled.BindTo(background.EnableUserDim); - return background; - } - - public readonly Bindable DimEnabled = new Bindable(); - public readonly Bindable DimLevel = new Bindable(); - public readonly Bindable BlurLevel = new Bindable(); - - public new BeatmapCarousel Carousel => base.Carousel; - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - config.BindWith(OsuSetting.DimLevel, DimLevel); - config.BindWith(OsuSetting.BlurLevel, BlurLevel); - } - - public bool IsBackgroundDimmed() => ((FadeAccessibleBackground)Background).CurrentColour == OsuColour.Gray(1 - (float)DimLevel.Value); - - public bool IsBackgroundUndimmed() => ((FadeAccessibleBackground)Background).CurrentColour == Color4.White; - - public bool IsUserBlurApplied() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2((float)BlurLevel.Value * BackgroundScreenBeatmap.USER_BLUR_FACTOR); - - public bool IsUserBlurDisabled() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2(0); - - public bool IsBackgroundInvisible() => ((FadeAccessibleBackground)Background).CurrentAlpha == 0; - - public bool IsBackgroundVisible() => ((FadeAccessibleBackground)Background).CurrentAlpha == 1; - - public bool IsBlurCorrect() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2(BACKGROUND_BLUR); - - /// - /// Make sure every time a screen gets pushed, the background doesn't get replaced - /// - /// Whether or not the original background (The one created in DummySongSelect) is still the current background - public bool IsBackgroundCurrent() => ((FadeAccessibleBackground)Background).IsCurrentScreen(); - } - - private class FadeAccessibleResults : SoloResults - { - public FadeAccessibleResults(ScoreInfo score) - : base(score) - { - } - - protected override BackgroundScreen CreateBackground() => new FadeAccessibleBackground(Beatmap.Value); - - public bool IsBlurCorrect() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2(BACKGROUND_BLUR); - } - - private class TestPlayer : Visual.TestPlayer - { - protected override BackgroundScreen CreateBackground() => new FadeAccessibleBackground(Beatmap.Value); - - public new DimmableStoryboard DimmableStoryboard => base.DimmableStoryboard; - - // Whether or not the player should be allowed to load. - public bool BlockLoad; - - public Bindable StoryboardEnabled; - public readonly Bindable ReplacesBackground = new Bindable(); - public readonly Bindable IsPaused = new Bindable(); - - public TestPlayer(bool allowPause = true) - : base(allowPause) - { - } - - public bool IsStoryboardVisible => DimmableStoryboard.ContentDisplayed; - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config, CancellationToken token) - { - while (BlockLoad && !token.IsCancellationRequested) - Thread.Sleep(1); - - StoryboardEnabled = config.GetBindable(OsuSetting.ShowStoryboard); - ReplacesBackground.BindTo(Background.StoryboardReplacesBackground); - DrawableRuleset.IsPaused.BindTo(IsPaused); - } - } - - private class TestPlayerLoader : PlayerLoader - { - public VisualSettings VisualSettingsPos => VisualSettings; - public BackgroundScreen ScreenPos => Background; - - public TestPlayerLoader(Player player) - : base(() => player) - { - } - - public void TriggerOnHover() => OnHover(new HoverEvent(new InputState())); - - public bool IsBlurCorrect() => ((FadeAccessibleBackground)Background).CurrentBlur == new Vector2(BACKGROUND_BLUR); - - protected override BackgroundScreen CreateBackground() => new FadeAccessibleBackground(Beatmap.Value); - } - - private class FadeAccessibleBackground : BackgroundScreenBeatmap - { - protected override DimmableBackground CreateFadeContainer() => dimmable = new TestDimmableBackground { RelativeSizeAxes = Axes.Both }; - - public Color4 CurrentColour => dimmable.CurrentColour; - - public float CurrentAlpha => dimmable.CurrentAlpha; - - public Vector2 CurrentBlur => Background.BlurSigma; - - private TestDimmableBackground dimmable; - - public FadeAccessibleBackground(WorkingBeatmap beatmap) - : base(beatmap) - { - } - } - - private class TestDimmableBackground : BackgroundScreenBeatmap.DimmableBackground - { - public Color4 CurrentColour => Content.Colour; - public float CurrentAlpha => Content.Alpha; + public new Bindable UserDimLevel => base.UserDimLevel; } } } diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs new file mode 100644 index 0000000000..fef1605f0c --- /dev/null +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -0,0 +1,235 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets; +using osu.Game.Tests.Resources; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Collections +{ + public class TestSceneManageCollectionsDialog : OsuManualInputManagerTestScene + { + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private DialogOverlay dialogOverlay; + private CollectionManager manager; + + private RulesetStore rulesets; + private BeatmapManager beatmapManager; + + private ManageCollectionsDialog dialog; + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default)); + + beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + + base.Content.AddRange(new Drawable[] + { + manager = new CollectionManager(LocalStorage), + Content, + dialogOverlay = new DialogOverlay() + }); + + Dependencies.Cache(manager); + Dependencies.Cache(dialogOverlay); + } + + [SetUp] + public void SetUp() => Schedule(() => + { + manager.Collections.Clear(); + Child = dialog = new ManageCollectionsDialog(); + }); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("show dialog", () => dialog.Show()); + } + + [Test] + public void TestHideDialog() + { + AddWaitStep("wait for animation", 3); + AddStep("hide dialog", () => dialog.Hide()); + } + + [Test] + public void TestLastItemIsPlaceholder() + { + AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType().Last().Model)); + } + + [Test] + public void TestAddCollectionExternal() + { + AddStep("add collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "First collection" } })); + assertCollectionCount(1); + assertCollectionName(0, "First collection"); + + AddStep("add another collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "Second collection" } })); + assertCollectionCount(2); + assertCollectionName(1, "Second collection"); + } + + [Test] + public void TestFocusPlaceholderDoesNotCreateCollection() + { + AddStep("focus placeholder", () => + { + InputManager.MoveMouseTo(dialog.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); + }); + + assertCollectionCount(0); + } + + [Test] + public void TestAddCollectionViaPlaceholder() + { + DrawableCollectionListItem placeholderItem = null; + + AddStep("focus placeholder", () => + { + InputManager.MoveMouseTo(placeholderItem = dialog.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); + }); + + // Done directly via the collection since InputManager methods cannot add text to textbox... + AddStep("change collection name", () => placeholderItem.Model.Name.Value = "a"); + assertCollectionCount(1); + AddAssert("collection now exists", () => manager.Collections.Contains(placeholderItem.Model)); + + AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType().Last().Model)); + } + + [Test] + public void TestRemoveCollectionExternal() + { + AddStep("add two collections", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" } }, + new BeatmapCollection { Name = { Value = "2" } }, + })); + + AddStep("remove first collection", () => manager.Collections.RemoveAt(0)); + assertCollectionCount(1); + assertCollectionName(0, "2"); + } + + [Test] + public void TestRemoveCollectionViaButton() + { + AddStep("add two collections", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" } }, + new BeatmapCollection { Name = { Value = "2" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, + })); + + assertCollectionCount(2); + + AddStep("click first delete button", () => + { + InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("dialog not displayed", () => dialogOverlay.CurrentDialog == null); + assertCollectionCount(1); + assertCollectionName(0, "2"); + + AddStep("click first delete button", () => + { + InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is DeleteCollectionDialog); + AddStep("click confirmation", () => + { + InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + assertCollectionCount(0); + } + + [Test] + public void TestCollectionNotRemovedWhenDialogCancelled() + { + AddStep("add two collections", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, + })); + + assertCollectionCount(1); + + AddStep("click first delete button", () => + { + InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is DeleteCollectionDialog); + AddStep("click cancellation", () => + { + InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); + }); + + assertCollectionCount(1); + } + + [Test] + public void TestCollectionRenamedExternal() + { + AddStep("add two collections", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" } }, + new BeatmapCollection { Name = { Value = "2" } }, + })); + + AddStep("change first collection name", () => manager.Collections[0].Name.Value = "First"); + + assertCollectionName(0, "First"); + } + + [Test] + public void TestCollectionRenamedOnTextChange() + { + AddStep("add two collections", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" } }, + new BeatmapCollection { Name = { Value = "2" } }, + })); + + assertCollectionCount(2); + + AddStep("change first collection name", () => dialog.ChildrenOfType().First().Text = "First"); + AddAssert("collection has new name", () => manager.Collections[0].Name.Value == "First"); + } + + private void assertCollectionCount(int count) + => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count(i => i.IsCreated.Value) == count); + + private void assertCollectionName(int index, string name) + => AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType().ElementAt(index).ChildrenOfType().First().Text == name); + } +} diff --git a/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs b/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs index 55aaeed8bf..4d64c7d35d 100644 --- a/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs +++ b/osu.Game.Tests/Visual/Components/TestSceneIdleTracker.cs @@ -12,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Components { [TestFixture] - public class TestSceneIdleTracker : ManualInputManagerTestScene + public class TestSceneIdleTracker : OsuManualInputManagerTestScene { private IdleTrackingBox box1; private IdleTrackingBox box2; diff --git a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs index df6740421b..d76905dab8 100644 --- a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs +++ b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs @@ -3,16 +3,20 @@ using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Containers; using osu.Game.Audio; using osu.Game.Beatmaps; +using static osu.Game.Tests.Visual.Components.TestScenePreviewTrackManager.TestPreviewTrackManager; namespace osu.Game.Tests.Visual.Components { public class TestScenePreviewTrackManager : OsuTestScene, IPreviewTrackOwner { - private readonly PreviewTrackManager trackManager = new TestPreviewTrackManager(); + private readonly TestPreviewTrackManager trackManager = new TestPreviewTrackManager(); + + private AudioManager audio; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -23,8 +27,10 @@ namespace osu.Game.Tests.Visual.Components } [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { + this.audio = audio; + Add(trackManager); } @@ -34,6 +40,7 @@ namespace osu.Game.Tests.Visual.Components PreviewTrack track = null; AddStep("get track", () => track = getOwnedTrack()); + AddUntilStep("wait loaded", () => track.IsLoaded); AddStep("start", () => track.Start()); AddAssert("started", () => track.IsRunning); AddStep("stop", () => track.Stop()); @@ -52,10 +59,15 @@ namespace osu.Game.Tests.Visual.Components track2 = getOwnedTrack(); }); + AddUntilStep("wait loaded", () => track1.IsLoaded && track2.IsLoaded); + AddStep("start track 1", () => track1.Start()); AddStep("start track 2", () => track2.Start()); AddAssert("track 1 stopped", () => !track1.IsRunning); AddAssert("track 2 started", () => track2.IsRunning); + AddStep("start track 1", () => track1.Start()); + AddAssert("track 2 stopped", () => !track2.IsRunning); + AddAssert("track 1 started", () => track1.IsRunning); } [Test] @@ -64,6 +76,7 @@ namespace osu.Game.Tests.Visual.Components PreviewTrack track = null; AddStep("get track", () => track = getOwnedTrack()); + AddUntilStep("wait loaded", () => track.IsLoaded); AddStep("start", () => track.Start()); AddStep("stop by owner", () => trackManager.StopAnyPlaying(this)); AddAssert("stopped", () => !track.IsRunning); @@ -76,6 +89,7 @@ namespace osu.Game.Tests.Visual.Components PreviewTrack track = null; AddStep("get track", () => Add(owner = new TestTrackOwner(track = getTrack()))); + AddUntilStep("wait loaded", () => track.IsLoaded); AddStep("start", () => track.Start()); AddStep("attempt stop", () => trackManager.StopAnyPlaying(this)); AddAssert("not stopped", () => track.IsRunning); @@ -83,22 +97,100 @@ namespace osu.Game.Tests.Visual.Components AddAssert("stopped", () => !track.IsRunning); } - private PreviewTrack getTrack() => trackManager.Get(null); + [Test] + public void TestNonPresentTrack() + { + TestPreviewTrack track = null; - private PreviewTrack getOwnedTrack() + AddStep("get non-present track", () => + { + Add(new TestTrackOwner(track = getTrack())); + track.Alpha = 0; + }); + AddUntilStep("wait loaded", () => track.IsLoaded); + AddStep("start", () => track.Start()); + AddStep("seek to end", () => track.Track.Seek(track.Track.Length)); + AddAssert("track stopped", () => !track.IsRunning); + } + + /// + /// Ensures that changes correctly. + /// + [Test] + public void TestCurrentTrackChanges() + { + PreviewTrack track = null; + TestTrackOwner owner = null; + + AddStep("get track", () => Add(owner = new TestTrackOwner(track = getTrack()))); + AddUntilStep("wait loaded", () => track.IsLoaded); + AddStep("start track", () => track.Start()); + AddAssert("current is track", () => trackManager.CurrentTrack == track); + AddStep("pause manager updates", () => trackManager.AllowUpdate = false); + AddStep("stop any playing", () => trackManager.StopAnyPlaying(owner)); + AddAssert("current not changed", () => trackManager.CurrentTrack == track); + AddStep("resume manager updates", () => trackManager.AllowUpdate = true); + AddAssert("current is null", () => trackManager.CurrentTrack == null); + } + + /// + /// Ensures that mutes game-wide audio tracks correctly. + /// + [TestCase(false)] + [TestCase(true)] + public void TestEnsureMutingCorrectly(bool stopAnyPlaying) + { + PreviewTrack track = null; + TestTrackOwner owner = null; + + AddStep("ensure volume not zero", () => + { + if (audio.Volume.Value == 0) + audio.Volume.Value = 1; + + if (audio.VolumeTrack.Value == 0) + audio.VolumeTrack.Value = 1; + }); + + AddAssert("game not muted", () => audio.Tracks.AggregateVolume.Value != 0); + + AddStep("get track", () => Add(owner = new TestTrackOwner(track = getTrack()))); + AddUntilStep("wait loaded", () => track.IsLoaded); + AddStep("start track", () => track.Start()); + AddAssert("game is muted", () => audio.Tracks.AggregateVolume.Value == 0); + + if (stopAnyPlaying) + AddStep("stop any playing", () => trackManager.StopAnyPlaying(owner)); + else + AddStep("stop track", () => track.Stop()); + + AddAssert("game not muted", () => audio.Tracks.AggregateVolume.Value != 0); + } + + private TestPreviewTrack getTrack() => (TestPreviewTrack)trackManager.Get(null); + + private TestPreviewTrack getOwnedTrack() { var track = getTrack(); - Add(track); + LoadComponentAsync(track, Add); return track; } private class TestTrackOwner : CompositeDrawable, IPreviewTrackOwner { + private readonly PreviewTrack track; + public TestTrackOwner(PreviewTrack track) { - AddInternal(track); + this.track = track; + } + + [BackgroundDependencyLoader] + private void load() + { + LoadComponentAsync(track, AddInternal); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -109,14 +201,28 @@ namespace osu.Game.Tests.Visual.Components } } - private class TestPreviewTrackManager : PreviewTrackManager + public class TestPreviewTrackManager : PreviewTrackManager { + public bool AllowUpdate = true; + + public new PreviewTrack CurrentTrack => base.CurrentTrack; + protected override TrackManagerPreviewTrack CreatePreviewTrack(BeatmapSetInfo beatmapSetInfo, ITrackStore trackStore) => new TestPreviewTrack(beatmapSetInfo, trackStore); - protected class TestPreviewTrack : TrackManagerPreviewTrack + public override bool UpdateSubTree() + { + if (!AllowUpdate) + return true; + + return base.UpdateSubTree(); + } + + public class TestPreviewTrack : TrackManagerPreviewTrack { private readonly ITrackStore trackManager; + public new Track Track => base.Track; + public TestPreviewTrack(BeatmapSetInfo beatmapSetInfo, ITrackStore trackManager) : base(beatmapSetInfo, trackManager) { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs new file mode 100644 index 0000000000..6cf5e6a987 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneBeatDivisorControl : OsuManualInputManagerTestScene + { + private BeatDivisorControl beatDivisorControl; + private BindableBeatDivisor bindableBeatDivisor; + + private SliderBar tickSliderBar; + private EquilateralTriangle tickMarkerHead; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = beatDivisorControl = new BeatDivisorControl(bindableBeatDivisor = new BindableBeatDivisor(16)) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(90, 90) + }; + + tickSliderBar = beatDivisorControl.ChildrenOfType>().Single(); + tickMarkerHead = tickSliderBar.ChildrenOfType().Single(); + }); + + [Test] + public void TestBindableBeatDivisor() + { + AddRepeatStep("move previous", () => bindableBeatDivisor.Previous(), 4); + AddAssert("divisor is 4", () => bindableBeatDivisor.Value == 4); + AddRepeatStep("move next", () => bindableBeatDivisor.Next(), 3); + AddAssert("divisor is 12", () => bindableBeatDivisor.Value == 12); + } + + [Test] + public void TestMouseInput() + { + AddStep("hold marker", () => + { + InputManager.MoveMouseTo(tickMarkerHead.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + }); + AddStep("move to 8 and release", () => + { + InputManager.MoveMouseTo(tickSliderBar.ScreenSpaceDrawQuad.Centre); + InputManager.ReleaseButton(MouseButton.Left); + }); + AddAssert("divisor is 8", () => bindableBeatDivisor.Value == 8); + AddStep("hold marker", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move to 16", () => InputManager.MoveMouseTo(getPositionForDivisor(16))); + AddStep("move to ~10 and release", () => + { + InputManager.MoveMouseTo(getPositionForDivisor(10)); + InputManager.ReleaseButton(MouseButton.Left); + }); + AddAssert("divisor clamped to 8", () => bindableBeatDivisor.Value == 8); + } + + private Vector2 getPositionForDivisor(int divisor) + { + var relativePosition = (float)Math.Clamp(divisor, 0, 16) / 16; + var sliderDrawQuad = tickSliderBar.ScreenSpaceDrawQuad; + return new Vector2( + sliderDrawQuad.TopLeft.X + sliderDrawQuad.Width * relativePosition, + sliderDrawQuad.Centre.Y + ); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.cs new file mode 100644 index 0000000000..6f5655006e --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeScreen.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.Framework.Allocation; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose; + +namespace osu.Game.Tests.Visual.Editing +{ + [TestFixture] + public class TestSceneComposeScreen : EditorClockTestScene + { + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + private readonly EditorBeatmap editorBeatmap = + new EditorBeatmap(new OsuBeatmap + { + BeatmapInfo = + { + Ruleset = new OsuRuleset().RulesetInfo + } + }); + + [BackgroundDependencyLoader] + private void load() + { + Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); + + Child = new ComposeScreen(); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs new file mode 100644 index 0000000000..cf5f1b8818 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.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 osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneComposeSelectBox : OsuTestScene + { + private Container selectionArea; + + public TestSceneComposeSelectBox() + { + SelectionBox selectionBox = null; + + AddStep("create box", () => + Child = selectionArea = new Container + { + Size = new Vector2(400), + Position = -new Vector2(150), + Anchor = Anchor.Centre, + Children = new Drawable[] + { + selectionBox = new SelectionBox + { + 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); + } + + private bool handleScale(Vector2 amount, Anchor reference) + { + if ((reference & Anchor.y1) == 0) + { + int directionY = (reference & Anchor.y0) > 0 ? -1 : 1; + if (directionY < 0) + selectionArea.Y += amount.Y; + selectionArea.Height += directionY * amount.Y; + } + + if ((reference & Anchor.x1) == 0) + { + int directionX = (reference & Anchor.x0) > 0 ? -1 : 1; + if (directionX < 0) + selectionArea.X += amount.X; + selectionArea.Width += directionX * amount.X; + } + + return true; + } + + private bool handleRotation(float angle) + { + // kinda silly and wrong, but just showing that the drag handles work. + selectionArea.Rotation += angle; + return true; + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs new file mode 100644 index 0000000000..8190cf5f89 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -0,0 +1,169 @@ +// 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.Shapes; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneDistanceSnapGrid : EditorClockTestScene + { + private const double beat_length = 100; + private static readonly Vector2 grid_position = new Vector2(512, 384); + + [Cached(typeof(EditorBeatmap))] + private readonly EditorBeatmap editorBeatmap; + + [Cached(typeof(IPositionSnapProvider))] + private readonly SnapProvider snapProvider = new SnapProvider(); + + public TestSceneDistanceSnapGrid() + { + editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length }); + } + + [SetUp] + public void Setup() => Schedule(() => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray + }, + new TestDistanceSnapGrid() + }; + }); + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(6)] + [TestCase(8)] + [TestCase(12)] + [TestCase(16)] + public void TestBeatDivisor(int divisor) + { + AddStep($"set beat divisor = {divisor}", () => BeatDivisor.Value = divisor); + } + + [Test] + public void TestLimitedDistance() + { + AddStep("create limited grid", () => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray + }, + new TestDistanceSnapGrid(100) + }; + }); + } + + private class TestDistanceSnapGrid : DistanceSnapGrid + { + public new float DistanceSpacing => base.DistanceSpacing; + + public TestDistanceSnapGrid(double? endTime = null) + : base(grid_position, 0, endTime) + { + } + + protected override void CreateContent() + { + AddInternal(new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(5), + Position = StartPosition + }); + + int indexFromPlacement = 0; + + for (float s = StartPosition.X + DistanceSpacing; s <= DrawWidth && indexFromPlacement < MaxIntervals; s += DistanceSpacing, indexFromPlacement++) + { + AddInternal(new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(5, 10), + Position = new Vector2(s, StartPosition.Y), + Colour = GetColourForIndexFromPlacement(indexFromPlacement) + }); + } + + indexFromPlacement = 0; + + for (float s = StartPosition.X - DistanceSpacing; s >= 0 && indexFromPlacement < MaxIntervals; s -= DistanceSpacing, indexFromPlacement++) + { + AddInternal(new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(5, 10), + Position = new Vector2(s, StartPosition.Y), + Colour = GetColourForIndexFromPlacement(indexFromPlacement) + }); + } + + indexFromPlacement = 0; + + for (float s = StartPosition.Y + DistanceSpacing; s <= DrawHeight && indexFromPlacement < MaxIntervals; s += DistanceSpacing, indexFromPlacement++) + { + AddInternal(new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(10, 5), + Position = new Vector2(StartPosition.X, s), + Colour = GetColourForIndexFromPlacement(indexFromPlacement) + }); + } + + indexFromPlacement = 0; + + for (float s = StartPosition.Y - DistanceSpacing; s >= 0 && indexFromPlacement < MaxIntervals; s -= DistanceSpacing, indexFromPlacement++) + { + AddInternal(new Circle + { + Origin = Anchor.Centre, + Size = new Vector2(10, 5), + Position = new Vector2(StartPosition.X, s), + Colour = GetColourForIndexFromPlacement(indexFromPlacement) + }); + } + } + + public override (Vector2 position, double time) GetSnappedPosition(Vector2 screenSpacePosition) + => (Vector2.Zero, 0); + } + + private class SnapProvider : IPositionSnapProvider + { + public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); + + public float GetBeatSnapDistanceAt(double referenceTime) => 10; + + public float DurationToDistance(double referenceTime, double duration) => (float)duration; + + public double DistanceToDuration(double referenceTime, float distance) => distance; + + public double GetSnappedDurationFromDistance(double referenceTime, float distance) => 0; + + public float GetSnappedDistanceFromDistance(double referenceTime, float distance) => 0; + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs new file mode 100644 index 0000000000..7584c74c71 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.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.IO; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit.Setup; +using osu.Game.Tests.Resources; +using SharpCompress.Archives; +using SharpCompress.Archives.Zip; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneEditorBeatmapCreation : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override bool EditorComponentsReady => Editor.ChildrenOfType().SingleOrDefault()?.IsLoaded == true; + + [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. + // probably needs a more solid resolution in the future but this will do for now. + AddStep("make new beatmap unique", () => EditorBeatmap.Metadata.Title = Guid.NewGuid().ToString()); + } + + [Test] + public void TestCreateNewBeatmap() + { + AddStep("save beatmap", () => Editor.Save()); + AddAssert("new beatmap persisted", () => EditorBeatmap.BeatmapInfo.ID > 0); + AddAssert("new beatmap in database", () => beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID)?.DeletePending == false); + } + + [Test] + public void TestExitWithoutSave() + { + AddStep("exit without save", () => Editor.Exit()); + AddUntilStep("wait for exit", () => !Editor.IsCurrentScreen()); + AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID)?.DeletePending == true); + } + + [Test] + public void TestAddAudioTrack() + { + AddAssert("switch track to real track", () => + { + var setup = Editor.ChildrenOfType().First(); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + bool success = setup.ChildrenOfType().First().ChangeAudioTrack(Path.Combine(extractedFolder, "03. Renatus - Soleily 192kbps.mp3")); + + File.Delete(temp); + Directory.Delete(extractedFolder, true); + + return success; + }); + + AddAssert("track length changed", () => Beatmap.Value.Track.Length > 60000); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs new file mode 100644 index 0000000000..ab53f4fd93 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.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 NUnit.Framework; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneEditorChangeStates : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestSelectedObjects() + { + HitCircle obj = null; + AddStep("add hitobject", () => EditorBeatmap.Add(obj = new HitCircle { StartTime = 1000 })); + AddStep("select hitobject", () => EditorBeatmap.SelectedHitObjects.Add(obj)); + AddAssert("confirm 1 selected", () => EditorBeatmap.SelectedHitObjects.Count == 1); + AddStep("deselect hitobject", () => EditorBeatmap.SelectedHitObjects.Remove(obj)); + AddAssert("confirm 0 selected", () => EditorBeatmap.SelectedHitObjects.Count == 0); + } + + [Test] + public void TestUndoFromInitialState() + { + int hitObjectCount = 0; + + AddStep("get initial state", () => hitObjectCount = EditorBeatmap.HitObjects.Count); + + addUndoSteps(); + + AddAssert("no change occurred", () => hitObjectCount == EditorBeatmap.HitObjects.Count); + AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges); + } + + [Test] + public void TestRedoFromInitialState() + { + int hitObjectCount = 0; + + AddStep("get initial state", () => hitObjectCount = EditorBeatmap.HitObjects.Count); + + addRedoSteps(); + + AddAssert("no change occurred", () => hitObjectCount == EditorBeatmap.HitObjects.Count); + AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges); + } + + [Test] + public void TestAddObjectAndUndo() + { + HitObject addedObject = null; + HitObject removedObject = null; + HitObject expectedObject = null; + + AddStep("bind removal", () => + { + EditorBeatmap.HitObjectAdded += h => addedObject = h; + EditorBeatmap.HitObjectRemoved += h => removedObject = h; + }); + + AddStep("add hitobject", () => EditorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); + AddAssert("hitobject added", () => addedObject == expectedObject); + AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); + + addUndoSteps(); + AddAssert("hitobject removed", () => removedObject == expectedObject); + AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges); + } + + [Test] + public void TestAddObjectThenUndoThenRedo() + { + HitObject addedObject = null; + HitObject removedObject = null; + HitObject expectedObject = null; + + AddStep("bind removal", () => + { + EditorBeatmap.HitObjectAdded += h => addedObject = h; + EditorBeatmap.HitObjectRemoved += h => removedObject = h; + }); + + AddStep("add hitobject", () => EditorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); + addUndoSteps(); + + AddStep("reset variables", () => + { + addedObject = null; + removedObject = null; + }); + + addRedoSteps(); + AddAssert("hitobject added", () => addedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance) + AddAssert("no hitobject removed", () => removedObject == null); + AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); + } + + [Test] + public void TestAddObjectThenSaveHasNoUnsavedChanges() + { + AddStep("add hitobject", () => EditorBeatmap.Add(new HitCircle { StartTime = 1000 })); + + AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); + AddStep("save changes", () => Editor.Save()); + AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges); + } + + [Test] + public void TestRemoveObjectThenUndo() + { + HitObject addedObject = null; + HitObject removedObject = null; + HitObject expectedObject = null; + + AddStep("bind removal", () => + { + EditorBeatmap.HitObjectAdded += h => addedObject = h; + EditorBeatmap.HitObjectRemoved += h => removedObject = h; + }); + + AddStep("add hitobject", () => EditorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); + AddStep("remove object", () => EditorBeatmap.Remove(expectedObject)); + AddStep("reset variables", () => + { + addedObject = null; + removedObject = null; + }); + + addUndoSteps(); + AddAssert("hitobject added", () => addedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance) + AddAssert("no hitobject removed", () => removedObject == null); + AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); // 2 steps performed, 1 undone + } + + [Test] + public void TestRemoveObjectThenUndoThenRedo() + { + HitObject addedObject = null; + HitObject removedObject = null; + HitObject expectedObject = null; + + AddStep("bind removal", () => + { + EditorBeatmap.HitObjectAdded += h => addedObject = h; + EditorBeatmap.HitObjectRemoved += h => removedObject = h; + }); + + AddStep("add hitobject", () => EditorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); + AddStep("remove object", () => EditorBeatmap.Remove(expectedObject)); + addUndoSteps(); + + AddStep("reset variables", () => + { + addedObject = null; + removedObject = null; + }); + + addRedoSteps(); + AddAssert("hitobject removed", () => removedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance after undo) + AddAssert("no hitobject added", () => addedObject == null); + AddAssert("no changes", () => !Editor.HasUnsavedChanges); // end result is empty beatmap, matching original state + } + + private void addUndoSteps() => AddStep("undo", () => Editor.Undo()); + + private void addRedoSteps() => AddStep("redo", () => Editor.Redo()); + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs new file mode 100644 index 0000000000..29046c82a6 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs @@ -0,0 +1,154 @@ +// 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.Game.Beatmaps; +using osu.Game.Rulesets; +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.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneEditorClipboard : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + [Test] + public void TestCutRemovesObjects() + { + var addedObject = new HitCircle { StartTime = 1000 }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddStep("cut hitobject", () => Editor.Cut()); + + AddAssert("no hitobjects in beatmap", () => EditorBeatmap.HitObjects.Count == 0); + } + + [TestCase(1000)] + [TestCase(2000)] + public void TestCutPaste(double newTime) + { + var addedObject = new HitCircle { StartTime = 1000 }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddStep("cut hitobject", () => Editor.Cut()); + + AddStep("move forward in time", () => EditorClock.Seek(newTime)); + + AddStep("paste hitobject", () => Editor.Paste()); + + AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); + + AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == newTime); + } + + [Test] + public void TestCutPasteSlider() + { + var addedObject = new Slider + { + StartTime = 1000, + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(), + new PathControlPoint(new Vector2(100, 0), PathType.Bezier) + } + } + }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddStep("cut hitobject", () => Editor.Cut()); + + AddStep("paste hitobject", () => Editor.Paste()); + + AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); + + AddAssert("path matches", () => + { + var path = ((Slider)EditorBeatmap.HitObjects.Single()).Path; + return path.ControlPoints.Count == 2 && path.ControlPoints.SequenceEqual(addedObject.Path.ControlPoints); + }); + } + + [Test] + public void TestCutPasteSpinner() + { + var addedObject = new Spinner + { + StartTime = 1000, + Duration = 5000 + }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddStep("cut hitobject", () => Editor.Cut()); + + AddStep("paste hitobject", () => Editor.Paste()); + + AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); + + AddAssert("duration matches", () => ((Spinner)EditorBeatmap.HitObjects.Single()).Duration == 5000); + } + + [Test] + public void TestCopyPaste() + { + var addedObject = new HitCircle { StartTime = 1000 }; + + AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); + + AddStep("select added object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject)); + + AddStep("copy hitobject", () => Editor.Copy()); + + AddStep("move forward in time", () => EditorClock.Seek(2000)); + + AddStep("paste hitobject", () => Editor.Paste()); + + AddAssert("are two objects", () => EditorBeatmap.HitObjects.Count == 2); + + AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == 2000); + } + + [Test] + public void TestCutNothing() + { + AddStep("cut hitobject", () => Editor.Cut()); + AddAssert("are no objects", () => EditorBeatmap.HitObjects.Count == 0); + } + + [Test] + public void TestCopyNothing() + { + AddStep("copy hitobject", () => Editor.Copy()); + AddAssert("are no objects", () => EditorBeatmap.HitObjects.Count == 0); + } + + [Test] + public void TestPasteNothing() + { + AddStep("paste hitobject", () => Editor.Paste()); + AddAssert("are no objects", () => EditorBeatmap.HitObjects.Count == 0); + } + } +} diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorComposeRadioButtons.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs similarity index 83% rename from osu.Game.Tests/Visual/Editor/TestSceneEditorComposeRadioButtons.cs rename to osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs index 1709067d5d..0b52ae2b95 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorComposeRadioButtons.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs @@ -1,19 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Game.Screens.Edit.Components.RadioButtons; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneEditorComposeRadioButtons : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(DrawableRadioButton) }; - public TestSceneEditorComposeRadioButtons() { RadioButtonCollection collection; @@ -26,7 +23,7 @@ namespace osu.Game.Tests.Visual.Editor { new RadioButton("Item 1", () => { }), new RadioButton("Item 2", () => { }), - new RadioButton("Item 3", () => { }), + new RadioButton("Item 3", () => { }, () => new SpriteIcon { Icon = FontAwesome.Regular.Angry }), new RadioButton("Item 4", () => { }), new RadioButton("Item 5", () => { }) } diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorMenuBar.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorMenuBar.cs similarity index 95% rename from osu.Game.Tests/Visual/Editor/TestSceneEditorMenuBar.cs rename to osu.Game.Tests/Visual/Editing/TestSceneEditorMenuBar.cs index 53c2d62067..3cb44d9ae8 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorMenuBar.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorMenuBar.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -10,13 +8,11 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Game.Screens.Edit.Components.Menus; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneEditorMenuBar : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(EditorMenuBar), typeof(ScreenSelectionTabControl) }; - public TestSceneEditorMenuBar() { Add(new Container diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs new file mode 100644 index 0000000000..f182023c0e --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSamplePlayback.cs @@ -0,0 +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.Audio; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneEditorSamplePlayback : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + [Test] + public void TestSlidingSampleStopsOnSeek() + { + DrawableSlider slider = null; + DrawableSample[] loopingSamples = null; + DrawableSample[] onceOffSamples = null; + + AddStep("get first slider", () => + { + slider = Editor.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).First(); + onceOffSamples = slider.ChildrenOfType().Where(s => !s.Looping).ToArray(); + loopingSamples = slider.ChildrenOfType().Where(s => s.Looping).ToArray(); + }); + + AddStep("start playback", () => EditorClock.Start()); + + AddUntilStep("wait for slider sliding then seek", () => + { + if (!slider.Tracking.Value) + return false; + + if (!loopingSamples.Any(s => s.Playing)) + return false; + + EditorClock.Seek(20000); + return true; + }); + + AddAssert("non-looping samples are playing", () => onceOffSamples.Length == 4 && loopingSamples.All(s => s.Played || s.Playing)); + AddAssert("looping samples are not playing", () => loopingSamples.Length == 1 && loopingSamples.All(s => s.Played && !s.Playing)); + } + } +} diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorSeekSnapping.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs similarity index 95% rename from osu.Game.Tests/Visual/Editor/TestSceneEditorSeekSnapping.cs rename to osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs index b997d6aaeb..3a19eabe81 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorSeekSnapping.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs @@ -13,7 +13,7 @@ using osu.Game.Rulesets.Osu.Objects; using osuTK; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneEditorSeekSnapping : EditorClockTestScene @@ -28,18 +28,7 @@ namespace osu.Game.Tests.Visual.Editor { var testBeatmap = new Beatmap { - ControlPointInfo = new ControlPointInfo - { - TimingPoints = - { - new TimingControlPoint { Time = 0, BeatLength = 200 }, - new TimingControlPoint { Time = 100, BeatLength = 400 }, - new TimingControlPoint { Time = 175, BeatLength = 800 }, - new TimingControlPoint { Time = 350, BeatLength = 200 }, - new TimingControlPoint { Time = 450, BeatLength = 100 }, - new TimingControlPoint { Time = 500, BeatLength = 307.69230769230802 } - } - }, + ControlPointInfo = new ControlPointInfo(), HitObjects = { new HitCircle { StartTime = 0 }, @@ -47,6 +36,13 @@ namespace osu.Game.Tests.Visual.Editor } }; + testBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 200 }); + testBeatmap.ControlPointInfo.Add(100, new TimingControlPoint { BeatLength = 400 }); + testBeatmap.ControlPointInfo.Add(175, new TimingControlPoint { BeatLength = 800 }); + testBeatmap.ControlPointInfo.Add(350, new TimingControlPoint { BeatLength = 200 }); + testBeatmap.ControlPointInfo.Add(450, new TimingControlPoint { BeatLength = 100 }); + testBeatmap.ControlPointInfo.Add(500, new TimingControlPoint { BeatLength = 307.69230769230802 }); + Beatmap.Value = CreateWorkingBeatmap(testBeatmap); Child = new TimingPointVisualiser(testBeatmap, 5000) { Clock = Clock }; @@ -179,13 +175,13 @@ namespace osu.Game.Tests.Visual.Editor AddAssert("Time = 50", () => Clock.CurrentTime == 50); AddStep("Seek(49.999)", () => Clock.Seek(49.999)); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 50", () => Clock.CurrentTime == 50); + AddAssert("Time = 100", () => Clock.CurrentTime == 100); AddStep("Seek(99)", () => Clock.Seek(99)); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); AddAssert("Time = 100", () => Clock.CurrentTime == 100); AddStep("Seek(99.999)", () => Clock.Seek(99.999)); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); - AddAssert("Time = 100", () => Clock.CurrentTime == 100); + AddAssert("Time = 100", () => Clock.CurrentTime == 150); AddStep("Seek(174)", () => Clock.Seek(174)); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); AddAssert("Time = 175", () => Clock.CurrentTime == 175); diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorSummaryTimeline.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs similarity index 81% rename from osu.Game.Tests/Visual/Editor/TestSceneEditorSummaryTimeline.cs rename to osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs index 2e04eb50ca..3adc1bd425 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorSummaryTimeline.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -10,13 +8,11 @@ using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit.Components.Timelines.Summary; using osuTK; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneEditorSummaryTimeline : EditorClockTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(SummaryTimeline) }; - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game.Tests/Visual/Editor/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs similarity index 56% rename from osu.Game.Tests/Visual/Editor/TestSceneHitObjectComposer.cs rename to osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index 7accbe2fa8..7ca24346aa 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.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 System.Collections.Generic; -using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Timing; @@ -13,34 +11,15 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Edit; -using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; -using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Screens.Edit.Compose; -using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Screens.Edit; using osuTK; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] - [Cached(Type = typeof(IPlacementHandler))] - public class TestSceneHitObjectComposer : OsuTestScene, IPlacementHandler + public class TestSceneHitObjectComposer : EditorClockTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SelectionHandler), - typeof(DragBox), - typeof(HitObjectComposer), - typeof(OsuHitObjectComposer), - typeof(BlueprintContainer), - typeof(NotNullAttribute), - typeof(HitCirclePiece), - typeof(HitCircleSelectionBlueprint), - typeof(HitCirclePlacementBlueprint), - }; - - private HitObjectComposer composer; - [BackgroundDependencyLoader] private void load() { @@ -63,19 +42,15 @@ namespace osu.Game.Tests.Visual.Editor }, }); + var editorBeatmap = new EditorBeatmap(Beatmap.Value.GetPlayableBeatmap(new OsuRuleset().RulesetInfo)); + var clock = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; Dependencies.CacheAs(clock); Dependencies.CacheAs(clock); + Dependencies.CacheAs(editorBeatmap); + Dependencies.CacheAs(editorBeatmap); - Child = composer = new OsuHitObjectComposer(new OsuRuleset()); + Child = new OsuHitObjectComposer(new OsuRuleset()); } - - public void BeginPlacement(HitObject hitObject) - { - } - - public void EndPlacement(HitObject hitObject) => composer.Add(hitObject); - - public void Delete(HitObject hitObject) => composer.Remove(hitObject); } } diff --git a/osu.Game.Tests/Visual/Editor/TestScenePlaybackControl.cs b/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs similarity index 74% rename from osu.Game.Tests/Visual/Editor/TestScenePlaybackControl.cs rename to osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs index 0d4fe4366d..6aa884a197 100644 --- a/osu.Game.Tests/Visual/Editor/TestScenePlaybackControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs @@ -4,12 +4,12 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osuTK; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestScenePlaybackControl : OsuTestScene @@ -17,9 +17,8 @@ namespace osu.Game.Tests.Visual.Editor [BackgroundDependencyLoader] private void load() { - var clock = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; - Dependencies.CacheAs(clock); - Dependencies.CacheAs(clock); + var clock = new EditorClock { IsCoupled = false }; + Dependencies.CacheAs(clock); var playback = new PlaybackControl { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs new file mode 100644 index 0000000000..62e12158ab --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Setup; + +namespace osu.Game.Tests.Visual.Editing +{ + [TestFixture] + public class TestSceneSetupScreen : EditorClockTestScene + { + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + private readonly EditorBeatmap editorBeatmap; + + public TestSceneSetupScreen() + { + editorBeatmap = new EditorBeatmap(new OsuBeatmap()); + } + + [BackgroundDependencyLoader] + private void load() + { + Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); + Child = new SetupScreen(); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.cs new file mode 100644 index 0000000000..e931be044c --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineBlueprintContainer.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Screens.Edit.Compose.Components.Timeline; + +namespace osu.Game.Tests.Visual.Editing +{ + [TestFixture] + public class TestSceneTimelineBlueprintContainer : TimelineTestScene + { + public override Drawable CreateTestComponent() => new TimelineBlueprintContainer(); + + protected override void LoadComplete() + { + base.LoadComplete(); + Clock.Seek(10000); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs new file mode 100644 index 0000000000..20e58c3d2a --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Tests.Visual.Editing +{ + [TestFixture] + public class TestSceneTimelineTickDisplay : TimelineTestScene + { + public override Drawable CreateTestComponent() => Empty(); // tick display is implicitly inside the timeline. + + [BackgroundDependencyLoader] + private void load() + { + BeatDivisor.Value = 4; + + Add(new BeatDivisorControl(BeatDivisor) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding(30), + Size = new Vector2(90) + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.cs new file mode 100644 index 0000000000..b82e776164 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimingScreen.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Timing; + +namespace osu.Game.Tests.Visual.Editing +{ + [TestFixture] + public class TestSceneTimingScreen : EditorClockTestScene + { + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + private readonly EditorBeatmap editorBeatmap; + + protected override bool ScrollUsingMouseWheel => false; + + public TestSceneTimingScreen() + { + editorBeatmap = new EditorBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + } + + [BackgroundDependencyLoader] + private void load() + { + Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap); + Beatmap.Disabled = true; + + Child = new TimingScreen(); + } + + protected override void Dispose(bool isDisposing) + { + Beatmap.Disabled = false; + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game.Tests/Visual/Editor/TestSceneWaveform.cs b/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs similarity index 87% rename from osu.Game.Tests/Visual/Editor/TestSceneWaveform.cs rename to osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs index e2762f3d5f..c3a5a0e944 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneWaveform.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneWaveform.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.Threading; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -14,7 +15,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Osu; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { [TestFixture] public class TestSceneWaveform : OsuTestScene @@ -76,7 +77,7 @@ namespace osu.Game.Tests.Visual.Editor }; }); - AddUntilStep("wait for load", () => graph.ResampledWaveform != null); + AddUntilStep("wait for load", () => graph.Loaded.IsSet); } [Test] @@ -98,12 +99,18 @@ namespace osu.Game.Tests.Visual.Editor }; }); - AddUntilStep("wait for load", () => graph.ResampledWaveform != null); + AddUntilStep("wait for load", () => graph.Loaded.IsSet); } public class TestWaveformGraph : WaveformGraph { - public new Waveform ResampledWaveform => base.ResampledWaveform; + public readonly ManualResetEventSlim Loaded = new ManualResetEventSlim(); + + protected override void OnWaveformRegenerated(Waveform waveform) + { + base.OnWaveformRegenerated(waveform); + Loaded.Set(); + } } } } diff --git a/osu.Game.Tests/Visual/Editor/TestSceneZoomableScrollContainer.cs b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs similarity index 80% rename from osu.Game.Tests/Visual/Editor/TestSceneZoomableScrollContainer.cs rename to osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs index da8702209c..95d11d6909 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneZoomableScrollContainer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneZoomableScrollContainer.cs @@ -7,17 +7,18 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; -using osu.Framework.MathUtils; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Cursor; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osuTK; using osuTK.Graphics; +using osuTK.Input; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { - public class TestSceneZoomableScrollContainer : ManualInputManagerTestScene + public class TestSceneZoomableScrollContainer : OsuManualInputManagerTestScene { private ZoomableScrollContainer scrollContainer; private Drawable innerBox; @@ -88,6 +89,7 @@ namespace osu.Game.Tests.Visual.Editor // Scroll in at 0.25 AddStep("Move mouse to 0.25x", () => InputManager.MoveMouseTo(new Vector2(scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X, scrollQuad.Centre.Y))); + AddStep("Press alt down", () => InputManager.PressKey(Key.AltLeft)); AddStep("Scroll by 3", () => InputManager.ScrollBy(new Vector2(0, 3))); AddAssert("Box not at 0", () => !Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft)); AddAssert("Box 1/4 at 1/4", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.25f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X)); @@ -96,6 +98,25 @@ namespace osu.Game.Tests.Visual.Editor AddStep("Scroll by -3", () => InputManager.ScrollBy(new Vector2(0, -3))); AddAssert("Box at 0", () => Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft)); AddAssert("Box 1/4 at 1/4", () => Precision.AlmostEquals(boxQuad.TopLeft.X + 0.25f * boxQuad.Size.X, scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X)); + AddStep("Release alt", () => InputManager.ReleaseKey(Key.AltLeft)); + } + + [Test] + public void TestMouseZoomInThenScroll() + { + reset(); + + // Scroll in at 0.25 + AddStep("Move mouse to 0.25x", () => InputManager.MoveMouseTo(new Vector2(scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X, scrollQuad.Centre.Y))); + AddStep("Press alt down", () => InputManager.PressKey(Key.AltLeft)); + AddStep("Zoom by 3", () => InputManager.ScrollBy(new Vector2(0, 3))); + AddStep("Release alt", () => InputManager.ReleaseKey(Key.AltLeft)); + + AddStep("Scroll far left", () => InputManager.ScrollBy(new Vector2(0, 30))); + AddUntilStep("Scroll is at start", () => Precision.AlmostEquals(scrollQuad.TopLeft.X, boxQuad.TopLeft.X, 1)); + + AddStep("Scroll far right", () => InputManager.ScrollBy(new Vector2(0, -300))); + AddUntilStep("Scroll is at end", () => Precision.AlmostEquals(scrollQuad.TopRight.X, boxQuad.TopRight.X, 1)); } [Test] @@ -103,6 +124,8 @@ namespace osu.Game.Tests.Visual.Editor { reset(); + AddStep("Press alt down", () => InputManager.PressKey(Key.AltLeft)); + // Scroll in at 0.25 AddStep("Move mouse to 0.25x", () => InputManager.MoveMouseTo(new Vector2(scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X, scrollQuad.Centre.Y))); AddStep("Scroll by 1", () => InputManager.ScrollBy(new Vector2(0, 1))); @@ -124,6 +147,8 @@ namespace osu.Game.Tests.Visual.Editor AddStep("Move mouse to 0.25x", () => InputManager.MoveMouseTo(new Vector2(scrollQuad.TopLeft.X + 0.25f * scrollQuad.Size.X, scrollQuad.Centre.Y))); AddStep("Scroll by -1", () => InputManager.ScrollBy(new Vector2(0, -1))); AddAssert("Box at 0", () => Precision.AlmostEquals(boxQuad.TopLeft, scrollQuad.TopLeft)); + + AddStep("Release alt", () => InputManager.ReleaseKey(Key.AltLeft)); } private void reset() diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorComposeTimeline.cs b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs similarity index 63% rename from osu.Game.Tests/Visual/Editor/TestSceneEditorComposeTimeline.cs rename to osu.Game.Tests/Visual/Editing/TimelineTestScene.cs index a8c2362910..fdb8781563 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorComposeTimeline.cs +++ b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs @@ -1,42 +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 System; -using System.Collections.Generic; -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.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components.Timeline; using osuTK; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.Editing { - [TestFixture] - public class TestSceneEditorComposeTimeline : EditorClockTestScene + public abstract class TimelineTestScene : EditorClockTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(TimelineArea), - typeof(Timeline), - typeof(TimelineButton), - typeof(CentreMarker) - }; + protected TimelineArea TimelineArea { get; private set; } [BackgroundDependencyLoader] private void load(AudioManager audio) { Beatmap.Value = new WaveformTestBeatmap(audio); - Children = new Drawable[] + var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); + + var editorBeatmap = new EditorBeatmap(playable); + + Dependencies.Cache(editorBeatmap); + Dependencies.CacheAs(editorBeatmap); + + AddRange(new Drawable[] { + editorBeatmap, new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -48,22 +47,28 @@ namespace osu.Game.Tests.Visual.Editor new AudioVisualiser(), } }, - new TimelineArea + TimelineArea = new TimelineArea { + Child = CreateTestComponent(), Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, - Size = new Vector2(0.8f, 100) + Size = new Vector2(0.8f, 100), } - }; + }); } + public abstract Drawable CreateTestComponent(); + private class AudioVisualiser : CompositeDrawable { private readonly Drawable marker; - private readonly IBindable beatmap = new Bindable(); - private IAdjustableClock adjustableClock; + [Resolved] + private IBindable beatmap { get; set; } + + [Resolved] + private EditorClock editorClock { get; set; } public AudioVisualiser() { @@ -85,25 +90,20 @@ namespace osu.Game.Tests.Visual.Editor }; } - [BackgroundDependencyLoader] - private void load(IAdjustableClock adjustableClock, IBindable beatmap) - { - this.adjustableClock = adjustableClock; - this.beatmap.BindTo(beatmap); - } - protected override void Update() { base.Update(); if (beatmap.Value.Track.IsLoaded) - marker.X = (float)(adjustableClock.CurrentTime / beatmap.Value.Track.Length); + marker.X = (float)(editorClock.CurrentTime / beatmap.Value.Track.Length); } } - private class StartStopButton : Button + private class StartStopButton : OsuButton { - private IAdjustableClock adjustableClock; + [Resolved] + private EditorClock editorClock { get; set; } + private bool started; public StartStopButton() @@ -115,22 +115,16 @@ namespace osu.Game.Tests.Visual.Editor Action = onClick; } - [BackgroundDependencyLoader] - private void load(IAdjustableClock adjustableClock) - { - this.adjustableClock = adjustableClock; - } - private void onClick() { if (started) { - adjustableClock.Stop(); + editorClock.Stop(); Text = "Start"; } else { - adjustableClock.Start(); + editorClock.Start(); Text = "Stop"; } diff --git a/osu.Game.Tests/Visual/Editor/TestSceneBeatDivisorControl.cs b/osu.Game.Tests/Visual/Editor/TestSceneBeatDivisorControl.cs deleted file mode 100644 index 7531a7be2c..0000000000 --- a/osu.Game.Tests/Visual/Editor/TestSceneBeatDivisorControl.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 System; -using System.Collections.Generic; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Screens.Edit; -using osu.Game.Screens.Edit.Compose.Components; -using osuTK; - -namespace osu.Game.Tests.Visual.Editor -{ - public class TestSceneBeatDivisorControl : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] { typeof(BindableBeatDivisor) }; - - [BackgroundDependencyLoader] - private void load() - { - Child = new BeatDivisorControl(new BindableBeatDivisor()) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(90, 90) - }; - } - } -} diff --git a/osu.Game.Tests/Visual/Editor/TestSceneEditorCompose.cs b/osu.Game.Tests/Visual/Editor/TestSceneEditorCompose.cs deleted file mode 100644 index 608df1965e..0000000000 --- a/osu.Game.Tests/Visual/Editor/TestSceneEditorCompose.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 System; -using System.Collections.Generic; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Game.Rulesets.Osu; -using osu.Game.Screens.Edit.Compose; - -namespace osu.Game.Tests.Visual.Editor -{ - [TestFixture] - public class TestSceneEditorCompose : EditorClockTestScene - { - public override IReadOnlyList RequiredTypes => new[] { typeof(ComposeScreen) }; - - [BackgroundDependencyLoader] - private void load() - { - Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - Child = new ComposeScreen(); - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/OsuPlayerTestScene.cs b/osu.Game.Tests/Visual/Gameplay/OsuPlayerTestScene.cs new file mode 100644 index 0000000000..cbf8515567 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/OsuPlayerTestScene.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 osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; + +namespace osu.Game.Tests.Visual.Gameplay +{ + /// + /// A with an arbitrary ruleset value to test with. + /// + public abstract class OsuPlayerTestScene : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + } +} diff --git a/osu.Game/Tests/Visual/AllPlayersTestScene.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs similarity index 54% rename from osu.Game/Tests/Visual/AllPlayersTestScene.cs rename to osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs index b7d1979b0d..b7dcad3825 100644 --- a/osu.Game/Tests/Visual/AllPlayersTestScene.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs @@ -2,49 +2,65 @@ // 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.Screens; using osu.Game.Configuration; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; using osu.Game.Screens.Play; -namespace osu.Game.Tests.Visual +namespace osu.Game.Tests.Visual.Gameplay { /// /// A base class which runs test for all available rulesets. /// Steps to be run for each ruleset should be added via . /// - public abstract class AllPlayersTestScene : RateAdjustedBeatmapTestScene + public abstract class TestSceneAllRulesetPlayers : RateAdjustedBeatmapTestScene { protected Player Player; [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - foreach (var r in rulesets.AvailableRulesets) - { - Player p = null; - AddStep(r.Name, () => p = loadPlayerFor(r)); - AddUntilStep("player loaded", () => - { - if (p?.IsLoaded == true) - { - p = null; - return true; - } - - return false; - }); - - AddCheckSteps(); - } - OsuConfigManager manager; Dependencies.Cache(manager = new OsuConfigManager(LocalStorage)); manager.GetBindable(OsuSetting.DimLevel).Value = 1.0; } + [Test] + public void TestOsu() => runForRuleset(new OsuRuleset().RulesetInfo); + + [Test] + public void TestTaiko() => runForRuleset(new TaikoRuleset().RulesetInfo); + + [Test] + public void TestCatch() => runForRuleset(new CatchRuleset().RulesetInfo); + + [Test] + public void TestMania() => runForRuleset(new ManiaRuleset().RulesetInfo); + + private void runForRuleset(RulesetInfo ruleset) + { + Player p = null; + AddStep($"load {ruleset.Name} player", () => p = loadPlayerFor(ruleset)); + AddUntilStep("player loaded", () => + { + if (p?.IsLoaded == true) + { + p = null; + return true; + } + + return false; + }); + + AddCheckSteps(); + } + protected abstract void AddCheckSteps(); private Player loadPlayerFor(RulesetInfo rulesetInfo) @@ -55,10 +71,7 @@ namespace osu.Game.Tests.Visual var working = CreateWorkingBeatmap(rulesetInfo); Beatmap.Value = working; - Mods.Value = new[] { ruleset.GetAllMods().First(m => m is ModNoFail) }; - - Player?.Exit(); - Player = null; + SelectedMods.Value = new[] { ruleset.GetAllMods().First(m => m is ModNoFail) }; Player = CreatePlayer(ruleset); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index 452ac859de..e198a8504b 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -3,36 +3,69 @@ using System.ComponentModel; using System.Linq; +using osu.Framework.Testing; +using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.Break; +using osu.Game.Screens.Ranking; +using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [Description("Player instantiated with an autoplay mod.")] - public class TestSceneAutoplay : AllPlayersTestScene + public class TestSceneAutoplay : TestSceneAllRulesetPlayers { + protected new TestPlayer Player => (TestPlayer)base.Player; + protected override Player CreatePlayer(Ruleset ruleset) { - Mods.Value = Mods.Value.Concat(new[] { ruleset.GetAutoplayMod() }).ToArray(); - return new ScoreAccessiblePlayer(); + SelectedMods.Value = new[] { ruleset.GetAutoplayMod() }; + return new TestPlayer(false); } protected override void AddCheckSteps() { - AddUntilStep("score above zero", () => ((ScoreAccessiblePlayer)Player).ScoreProcessor.TotalScore.Value > 0); - AddUntilStep("key counter counted keys", () => ((ScoreAccessiblePlayer)Player).HUDOverlay.KeyCounter.Children.Any(kc => kc.CountPresses > 0)); + AddUntilStep("score above zero", () => Player.ScoreProcessor.TotalScore.Value > 0); + AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Children.Any(kc => kc.CountPresses > 2)); + seekToBreak(0); + AddAssert("keys not counting", () => !Player.HUDOverlay.KeyCounter.IsCounting); + AddAssert("overlay displays 100% accuracy", () => Player.BreakOverlay.ChildrenOfType().Single().AccuracyDisplay.Current.Value == 1); + AddStep("rewind", () => Player.GameplayClockContainer.Seek(-80000)); + AddUntilStep("key counter reset", () => Player.HUDOverlay.KeyCounter.Children.All(kc => kc.CountPresses == 0)); + + double? time = null; + + AddStep("store time", () => time = Player.GameplayClockContainer.GameplayClock.CurrentTime); + + // test seek via keyboard + AddStep("seek with right arrow key", () => InputManager.Key(Key.Right)); + AddAssert("time seeked forward", () => Player.GameplayClockContainer.GameplayClock.CurrentTime > time + 2000); + + AddStep("store time", () => time = Player.GameplayClockContainer.GameplayClock.CurrentTime); + AddStep("seek with left arrow key", () => InputManager.Key(Key.Left)); + AddAssert("time seeked backward", () => Player.GameplayClockContainer.GameplayClock.CurrentTime < time); + + seekToBreak(0); + seekToBreak(1); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + AddUntilStep("results displayed", () => getResultsScreen() != null); + + AddAssert("score has combo", () => getResultsScreen().Score.Combo > 100); + AddAssert("score has no misses", () => getResultsScreen().Score.Statistics[HitResult.Miss] == 0); + + ResultsScreen getResultsScreen() => Stack.CurrentScreen as ResultsScreen; } - private class ScoreAccessiblePlayer : TestPlayer + private void seekToBreak(int breakIndex) { - public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; - public new HUDOverlay HUDOverlay => base.HUDOverlay; + AddStep($"seek to break {breakIndex}", () => Player.GameplayClockContainer.Seek(destBreak().StartTime)); + AddUntilStep("wait for seek to complete", () => Player.HUDOverlay.Progress.ReferenceClock.CurrentTime >= destBreak().StartTime); - public ScoreAccessiblePlayer() - : base(false, false) - { - } + BreakPeriod destBreak() => Beatmap.Value.Beatmap.Breaks.ElementAt(breakIndex); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakOverlay.cs deleted file mode 100644 index 3cd1b8307a..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakOverlay.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.Collections.Generic; -using NUnit.Framework; -using osu.Game.Beatmaps.Timing; -using osu.Game.Screens.Play; - -namespace osu.Game.Tests.Visual.Gameplay -{ - [TestFixture] - public class TestSceneBreakOverlay : OsuTestScene - { - private readonly BreakOverlay breakOverlay; - - public TestSceneBreakOverlay() - { - Child = breakOverlay = new BreakOverlay(true); - - AddStep("2s break", () => startBreak(2000)); - AddStep("5s break", () => startBreak(5000)); - AddStep("10s break", () => startBreak(10000)); - AddStep("15s break", () => startBreak(15000)); - AddStep("2s, 2s", startMultipleBreaks); - AddStep("0.5s, 0.7s, 1s, 2s", startAnotherMultipleBreaks); - } - - private void startBreak(double duration) - { - breakOverlay.Breaks = new List - { - new BreakPeriod - { - StartTime = Clock.CurrentTime, - EndTime = Clock.CurrentTime + duration, - } - }; - } - - private void startMultipleBreaks() - { - double currentTime = Clock.CurrentTime; - - breakOverlay.Breaks = new List - { - new BreakPeriod - { - StartTime = currentTime, - EndTime = currentTime + 2000, - }, - new BreakPeriod - { - StartTime = currentTime + 4000, - EndTime = currentTime + 6000, - } - }; - } - - private void startAnotherMultipleBreaks() - { - double currentTime = Clock.CurrentTime; - - breakOverlay.Breaks = new List - { - new BreakPeriod // Duration is less than 650 - too short to appear - { - StartTime = currentTime, - EndTime = currentTime + 500, - }, - new BreakPeriod - { - StartTime = currentTime + 1500, - EndTime = currentTime + 2200, - }, - new BreakPeriod - { - StartTime = currentTime + 3200, - EndTime = currentTime + 4200, - }, - new BreakPeriod - { - StartTime = currentTime + 5200, - EndTime = currentTime + 7200, - } - }; - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs new file mode 100644 index 0000000000..be17721b88 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBreakTracker.cs @@ -0,0 +1,196 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Timing; +using osu.Game.Beatmaps.Timing; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public class TestSceneBreakTracker : OsuTestScene + { + private readonly BreakOverlay breakOverlay; + + private readonly TestBreakTracker breakTracker; + + private readonly IReadOnlyList testBreaks = new List + { + new BreakPeriod(1000, 5000), + new BreakPeriod(6000, 13500), + }; + + public TestSceneBreakTracker() + { + AddRange(new Drawable[] + { + breakTracker = new TestBreakTracker(), + breakOverlay = new BreakOverlay(true, null) + { + ProcessCustomClock = false, + } + }); + } + + protected override void Update() + { + base.Update(); + + breakOverlay.Clock = breakTracker.Clock; + } + + [Test] + public void TestShowBreaks() + { + setClock(false); + + addShowBreakStep(2); + addShowBreakStep(5); + addShowBreakStep(15); + } + + [Test] + public void TestNoEffectsBreak() + { + var shortBreak = new BreakPeriod(0, 500); + + setClock(true); + loadBreaksStep("short break", new[] { shortBreak }); + + addBreakSeeks(shortBreak, false); + } + + [Test] + public void TestMultipleBreaks() + { + setClock(true); + loadBreaksStep("multiple breaks", testBreaks); + + foreach (var b in testBreaks) + addBreakSeeks(b, false); + } + + [Test] + public void TestRewindBreaks() + { + setClock(true); + loadBreaksStep("multiple breaks", testBreaks); + + foreach (var b in testBreaks.Reverse()) + addBreakSeeks(b, true); + } + + [Test] + public void TestSkipBreaks() + { + setClock(true); + loadBreaksStep("multiple breaks", testBreaks); + + seekAndAssertBreak("seek to break start", testBreaks[1].StartTime, true); + seekAndAssertBreak("seek to break middle", testBreaks[1].StartTime + testBreaks[1].Duration / 2, true); + seekAndAssertBreak("seek to break end", testBreaks[1].EndTime, false); + seekAndAssertBreak("seek to break after end", testBreaks[1].EndTime + 500, false); + } + + [TestCase(true)] + [TestCase(false)] + public void TestBeforeGameplayStart(bool withBreaks) + { + setClock(true); + + if (withBreaks) + loadBreaksStep("multiple breaks", testBreaks); + + seekAndAssertBreak("seek to break intro time", -100, true); + seekAndAssertBreak("seek to break intro time", 0, false); + } + + private void addShowBreakStep(double seconds) + { + AddStep($"show '{seconds}s' break", () => + { + breakOverlay.Breaks = breakTracker.Breaks = new List + { + new BreakPeriod(Clock.CurrentTime, Clock.CurrentTime + seconds * 1000) + }; + }); + } + + private void setClock(bool useManual) + { + AddStep($"set {(useManual ? "manual" : "realtime")} clock", () => breakTracker.SwitchClock(useManual)); + } + + private void loadBreaksStep(string breakDescription, IReadOnlyList breaks) + { + AddStep($"load {breakDescription}", () => breakOverlay.Breaks = breakTracker.Breaks = breaks); + seekAndAssertBreak("seek back to 0", 0, false); + } + + private void addBreakSeeks(BreakPeriod b, bool isReversed) + { + if (isReversed) + { + seekAndAssertBreak("seek to break after end", b.EndTime + 500, false); + seekAndAssertBreak("seek to break end", b.EndTime, false); + seekAndAssertBreak("seek to break middle", b.StartTime + b.Duration / 2, b.HasEffect); + seekAndAssertBreak("seek to break start", b.StartTime, b.HasEffect); + } + else + { + seekAndAssertBreak("seek to break start", b.StartTime, b.HasEffect); + seekAndAssertBreak("seek to break middle", b.StartTime + b.Duration / 2, b.HasEffect); + seekAndAssertBreak("seek to break end", b.EndTime, false); + seekAndAssertBreak("seek to break after end", b.EndTime + 500, false); + } + } + + private void seekAndAssertBreak(string seekStepDescription, double time, bool shouldBeBreak) + { + AddStep(seekStepDescription, () => breakTracker.ManualClockTime = time); + AddAssert($"is{(!shouldBeBreak ? " not" : string.Empty)} break time", () => + { + breakTracker.ProgressTime(); + return breakTracker.IsBreakTime.Value == shouldBeBreak; + }); + } + + private class TestBreakTracker : BreakTracker + { + public readonly FramedClock FramedManualClock; + + private readonly ManualClock manualClock; + private IFrameBasedClock originalClock; + + public double ManualClockTime + { + get => manualClock.CurrentTime; + set => manualClock.CurrentTime = value; + } + + public TestBreakTracker() + { + FramedManualClock = new FramedClock(manualClock = new ManualClock()); + ProcessCustomClock = false; + } + + public void ProgressTime() + { + FramedManualClock.ProcessFrame(); + Update(); + } + + public void SwitchClock(bool setManual) => Clock = setManual ? FramedManualClock : originalClock; + + protected override void LoadComplete() + { + base.LoadComplete(); + originalClock = Clock; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs new file mode 100644 index 0000000000..d0c2fb5064 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.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 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/TestSceneCompletionCancellation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs new file mode 100644 index 0000000000..6fd5511e5a --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs @@ -0,0 +1,126 @@ +// 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.Audio; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Storyboards; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneCompletionCancellation : OsuPlayerTestScene + { + [Resolved] + private AudioManager audio { get; set; } + + private int resultsDisplayWaitCount => + (int)((Screens.Play.Player.RESULTS_DISPLAY_DELAY / TimePerAction) * 2); + + protected override bool AllowFail => false; + + [SetUpSteps] + public override void SetUpSteps() + { + base.SetUpSteps(); + + // Ensure track has actually running before attempting to seek + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + } + + [Test] + public void TestCancelCompletionOnRewind() + { + complete(); + cancel(); + + checkNoRanking(); + } + + [Test] + public void TestReCompleteAfterCancellation() + { + complete(); + cancel(); + complete(); + + AddUntilStep("attempted to push ranking", () => ((FakeRankingPushPlayer)Player).GotoRankingInvoked); + } + + /// + /// Tests whether can still pause after cancelling completion by reverting back to true. + /// + [Test] + public void TestCanPauseAfterCancellation() + { + complete(); + cancel(); + + AddStep("pause", () => Player.Pause()); + AddAssert("paused successfully", () => Player.GameplayClockContainer.IsPaused.Value); + + checkNoRanking(); + } + + private void complete() + { + AddStep("seek to completion", () => Beatmap.Value.Track.Seek(5000)); + AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); + } + + private void cancel() + { + AddStep("rewind to cancel", () => Beatmap.Value.Track.Seek(4000)); + AddUntilStep("completion cleared by processor", () => !Player.ScoreProcessor.HasCompleted.Value); + } + + private void checkNoRanking() + { + // wait to ensure there was no attempt of pushing the results screen. + AddWaitStep("wait", resultsDisplayWaitCount); + AddAssert("no attempt to push ranking", () => !((FakeRankingPushPlayer)Player).GotoRankingInvoked); + } + + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audio); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var beatmap = new Beatmap(); + + for (int i = 1; i <= 19; i++) + { + beatmap.HitObjects.Add(new HitCircle + { + Position = new Vector2(256, 192), + StartTime = i * 250, + }); + } + + return beatmap; + } + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new FakeRankingPushPlayer(); + + public class FakeRankingPushPlayer : TestPlayer + { + public bool GotoRankingInvoked; + + public FakeRankingPushPlayer() + : base(true, true) + { + } + + protected override void GotoRanking() + { + GotoRankingInvoked = true; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs new file mode 100644 index 0000000000..1a1babb4a8 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs @@ -0,0 +1,331 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input; +using osu.Framework.Utils; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneDrawableScrollingRuleset : OsuTestScene + { + /// + /// The amount of time visible by the "view window" of the playfield. + /// All hitobjects added through are spaced apart by this value, such that for a beat length of 1000, + /// there will be at most 2 hitobjects visible in the "view window". + /// + private const double time_range = 1000; + + private readonly ManualClock testClock = new ManualClock(); + private TestDrawableScrollingRuleset drawableRuleset; + + [SetUp] + public void Setup() => Schedule(() => testClock.CurrentTime = 0); + + [Test] + public void TestRelativeBeatLengthScaleSingleTimingPoint() + { + var beatmap = createBeatmap(); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range / 2 }); + + createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true); + + assertPosition(0, 0f); + + // The single timing point is 1x speed relative to itself, such that the hitobject occurring time_range milliseconds later should appear + // at the bottom of the view window regardless of the timing point's beat length + assertPosition(1, 1f); + } + + [Test] + public void TestRelativeBeatLengthScaleTimingPointBeyondEndDoesNotBecomeDominant() + { + var beatmap = createBeatmap(); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range / 2 }); + beatmap.ControlPointInfo.Add(12000, new TimingControlPoint { BeatLength = time_range }); + beatmap.ControlPointInfo.Add(100000, new TimingControlPoint { BeatLength = time_range }); + + createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true); + + assertPosition(0, 0f); + assertPosition(1, 1f); + } + + [Test] + public void TestRelativeBeatLengthScaleFromSecondTimingPoint() + { + var beatmap = createBeatmap(); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); + beatmap.ControlPointInfo.Add(3 * time_range, new TimingControlPoint { BeatLength = time_range / 2 }); + + createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true); + + // The first timing point should have a relative velocity of 2 + assertPosition(0, 0f); + assertPosition(1, 0.5f); + assertPosition(2, 1f); + + // Move to the second timing point + setTime(3 * time_range); + assertPosition(3, 0f); + + // As above, this is the timing point that is 1x speed relative to itself, so the hitobject occurring time_range milliseconds later should be at the bottom of the view window + assertPosition(4, 1f); + } + + [Test] + public void TestNonRelativeScale() + { + var beatmap = createBeatmap(); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); + beatmap.ControlPointInfo.Add(3 * time_range, new TimingControlPoint { BeatLength = time_range / 2 }); + + createTest(beatmap); + + assertPosition(0, 0f); + assertPosition(1, 1); + + // Move to the second timing point + setTime(3 * time_range); + assertPosition(3, 0f); + + // For a beat length of 500, the view window of this timing point is elongated 2x (1000 / 500), such that the second hitobject is two TimeRanges away (offscreen) + // To bring it on-screen, half TimeRange is added to the current time, bringing the second half of the view window into view, and the hitobject should appear at the bottom + setTime(3 * time_range + time_range / 2); + assertPosition(4, 1f); + } + + [Test] + public void TestSliderMultiplierDoesNotAffectRelativeBeatLength() + { + var beatmap = createBeatmap(); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); + beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2; + + createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true); + AddStep("adjust time range", () => drawableRuleset.TimeRange.Value = 5000); + + for (int i = 0; i < 5; i++) + assertPosition(i, i / 5f); + } + + [Test] + public void TestSliderMultiplierAffectsNonRelativeBeatLength() + { + var beatmap = createBeatmap(); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); + beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = 2; + + createTest(beatmap); + AddStep("adjust time range", () => drawableRuleset.TimeRange.Value = 2000); + + assertPosition(0, 0); + assertPosition(1, 1); + } + + private void assertPosition(int index, float relativeY) => AddAssert($"hitobject {index} at {relativeY}", + () => Precision.AlmostEquals(drawableRuleset.Playfield.AllHitObjects.ElementAt(index).DrawPosition.Y, drawableRuleset.Playfield.HitObjectContainer.DrawHeight * relativeY)); + + private void setTime(double time) + { + AddStep($"set time = {time}", () => testClock.CurrentTime = time); + } + + /// + /// Creates an , containing 10 hitobjects and user-provided timing points. + /// The hitobjects are spaced milliseconds apart. + /// + /// The . + private IBeatmap createBeatmap() + { + var beatmap = new Beatmap { BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } }; + + for (int i = 0; i < 10; i++) + beatmap.HitObjects.Add(new HitObject { StartTime = i * time_range }); + + return beatmap; + } + + private void createTest(IBeatmap beatmap, Action overrideAction = null) => AddStep("create test", () => + { + var ruleset = new TestScrollingRuleset(); + + drawableRuleset = (TestDrawableScrollingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); + drawableRuleset.FrameStablePlayback = false; + + overrideAction?.Invoke(drawableRuleset); + + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Height = 0.75f, + Width = 400, + Masking = true, + Clock = new FramedClock(testClock), + Child = drawableRuleset + }; + }); + + #region Ruleset + + private class TestScrollingRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => throw new NotImplementedException(); + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new TestDrawableScrollingRuleset(this, beatmap, mods); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new TestBeatmapConverter(beatmap, null); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => throw new NotImplementedException(); + + public override string Description { get; } = string.Empty; + + public override string ShortName { get; } = string.Empty; + } + + private class TestDrawableScrollingRuleset : DrawableScrollingRuleset + { + public bool RelativeScaleBeatLengthsOverride { get; set; } + + protected override bool RelativeScaleBeatLengths => RelativeScaleBeatLengthsOverride; + + protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Overlapping; + + public new Bindable TimeRange => base.TimeRange; + + public TestDrawableScrollingRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + : base(ruleset, beatmap, mods) + { + TimeRange.Value = time_range; + } + + public override DrawableHitObject CreateDrawableRepresentation(TestHitObject h) => new DrawableTestHitObject(h); + + protected override PassThroughInputManager CreateInputManager() => new PassThroughInputManager(); + + protected override Playfield CreatePlayfield() => new TestPlayfield(); + } + + private class TestPlayfield : ScrollingPlayfield + { + public TestPlayfield() + { + AddInternal(new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.2f, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 150 }, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = 2, + Colour = Color4.Green + }, + HitObjectContainer + } + } + } + }); + } + } + + private class TestBeatmapConverter : BeatmapConverter + { + public TestBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) + : base(beatmap, ruleset) + { + } + + public override bool CanConvert() => true; + + protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) + { + yield return new TestHitObject + { + StartTime = original.StartTime, + Duration = (original as IHasDuration)?.Duration ?? 100 + }; + } + } + + #endregion + + #region HitObject + + private class TestHitObject : ConvertHitObject, IHasDuration + { + public double EndTime => StartTime + Duration; + + public double Duration { get; set; } + } + + private class DrawableTestHitObject : DrawableHitObject + { + public DrawableTestHitObject(TestHitObject hitObject) + : base(hitObject) + { + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + + Size = new Vector2(100, 25); + + AddRangeInternal(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.LightPink + }, + new Box + { + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + Height = 2, + Colour = Color4.Red + } + }); + } + } + + #endregion + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs new file mode 100644 index 0000000000..9501026edc --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.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 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.Skinning; +using osu.Game.Storyboards; +using osu.Game.Storyboards.Drawables; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneDrawableStoryboardSprite : SkinnableTestScene + { + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + + [Cached] + private Storyboard storyboard { get; set; } = new Storyboard(); + + [Test] + public void TestSkinSpriteDisallowedByDefault() + { + const string lookup_name = "hitcircleoverlay"; + + AddStep("allow skin lookup", () => storyboard.UseSkinSprites = false); + + AddStep("create sprites", () => SetContents( + () => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); + + assertSpritesFromSkin(false); + } + + [Test] + public void TestAllowLookupFromSkin() + { + const string lookup_name = "hitcircleoverlay"; + + AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); + + AddStep("create sprites", () => SetContents( + () => createSprite(lookup_name, Anchor.Centre, Vector2.Zero))); + + assertSpritesFromSkin(true); + } + + private DrawableStoryboardSprite createSprite(string lookupName, Anchor origin, Vector2 initialPosition) + => new DrawableStoryboardSprite( + new StoryboardSprite(lookupName, origin, initialPosition) + ).With(s => + { + s.LifetimeStart = double.MinValue; + s.LifetimeEnd = double.MaxValue; + }); + + private void assertSpritesFromSkin(bool fromSkin) => + AddAssert($"sprites are {(fromSkin ? "from skin" : "from storyboard")}", + () => this.ChildrenOfType() + .All(sprite => sprite.ChildrenOfType().Any() == fromSkin)); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs index f4e8a68819..85aaf20a19 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -10,21 +9,14 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneFailAnimation : AllPlayersTestScene + public class TestSceneFailAnimation : TestSceneAllRulesetPlayers { protected override Player CreatePlayer(Ruleset ruleset) { - Mods.Value = Array.Empty(); + SelectedMods.Value = Array.Empty(); return new FailPlayer(); } - public override IReadOnlyList RequiredTypes => new[] - { - typeof(AllPlayersTestScene), - typeof(TestPlayer), - typeof(Player), - }; - protected override void AddCheckSteps() { AddUntilStep("wait for fail", () => Player.HasFailed); @@ -43,7 +35,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override void LoadComplete() { base.LoadComplete(); - ScoreProcessor.FailConditions += (_, __) => true; + HealthProcessor.FailConditions += (_, __) => true; } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs new file mode 100644 index 0000000000..d80efb2c6e --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.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; +using System.Linq; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneFailJudgement : TestSceneAllRulesetPlayers + { + protected override Player CreatePlayer(Ruleset ruleset) + { + SelectedMods.Value = Array.Empty(); + return new FailPlayer(); + } + + protected override void AddCheckSteps() + { + AddUntilStep("wait for fail", () => Player.HasFailed); + AddUntilStep("wait for multiple judged objects", () => ((FailPlayer)Player).DrawableRuleset.Playfield.AllHitObjects.Count(h => h.AllJudged) > 1); + AddAssert("total judgements == 1", () => ((FailPlayer)Player).HealthProcessor.JudgedHits >= 1); + } + + private class FailPlayer : TestPlayer + { + public new HealthProcessor HealthProcessor => base.HealthProcessor; + + public FailPlayer() + : base(false, false) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + HealthProcessor.FailConditions += (_, __) => true; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs new file mode 100644 index 0000000000..1c55595c97 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailingLayer.cs @@ -0,0 +1,100 @@ +// 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.Testing; +using osu.Game.Configuration; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneFailingLayer : OsuTestScene + { + private FailingLayer layer; + + private readonly Bindable showHealth = new Bindable(); + + [Resolved] + private OsuConfigManager config { get; set; } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create layer", () => + { + Child = layer = new FailingLayer(); + layer.BindHealthProcessor(new DrainingHealthProcessor(1)); + layer.ShowHealth.BindTo(showHealth); + }); + + AddStep("show health", () => showHealth.Value = true); + AddStep("enable layer", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddUntilStep("layer is visible", () => layer.IsPresent); + } + + [Test] + public void TestLayerFading() + { + AddSliderStep("current health", 0.0, 1.0, 1.0, val => + { + if (layer != null) + layer.Current.Value = val; + }); + + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddUntilStep("layer fade is visible", () => layer.Child.Alpha > 0.1f); + AddStep("set health to 1", () => layer.Current.Value = 1f); + AddUntilStep("layer fade is invisible", () => !layer.Child.IsPresent); + } + + [Test] + public void TestLayerDisabledViaConfig() + { + AddStep("disable layer", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddUntilStep("layer is not visible", () => !layer.IsPresent); + } + + [Test] + public void TestLayerVisibilityWithAccumulatingProcessor() + { + AddStep("bind accumulating processor", () => layer.BindHealthProcessor(new AccumulatingHealthProcessor(1))); + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddUntilStep("layer is not visible", () => !layer.IsPresent); + } + + [Test] + public void TestLayerVisibilityWithDrainingProcessor() + { + AddStep("bind accumulating processor", () => layer.BindHealthProcessor(new DrainingHealthProcessor(1))); + AddStep("set health to 0.10", () => layer.Current.Value = 0.1); + AddWaitStep("wait for potential fade", 10); + AddAssert("layer is still visible", () => layer.IsPresent); + } + + [Test] + public void TestLayerVisibilityWithDifferentOptions() + { + 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)); + AddUntilStep("layer fade is invisible", () => !layer.IsPresent); + + AddStep("don't show health", () => showHealth.Value = false); + AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddUntilStep("layer fade is invisible", () => !layer.IsPresent); + + AddStep("show health", () => showHealth.Value = true); + AddStep("disable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, false)); + AddUntilStep("layer fade is invisible", () => !layer.IsPresent); + + AddStep("show health", () => showHealth.Value = true); + AddStep("enable FadePlayfieldWhenHealthLow", () => config.Set(OsuSetting.FadePlayfieldWhenHealthLow, true)); + AddUntilStep("layer fade is visible", () => layer.IsPresent); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs index 4727140d99..d69ac665cc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs @@ -2,13 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; -using System.ComponentModel; using System.Linq; +using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; +using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Screens.Play; using osuTK; @@ -17,10 +17,8 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [Description("player pause/fail screens")] - public class TestSceneGameplayMenuOverlay : ManualInputManagerTestScene + public class TestSceneGameplayMenuOverlay : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(FailOverlay), typeof(PauseOverlay) }; - private FailOverlay failOverlay; private PauseOverlay pauseOverlay; @@ -29,57 +27,118 @@ namespace osu.Game.Tests.Visual.Gameplay [BackgroundDependencyLoader] private void load(OsuGameBase game) { - Child = globalActionContainer = new GlobalActionContainer(game) - { - Children = new Drawable[] - { - pauseOverlay = new PauseOverlay - { - OnResume = () => Logger.Log(@"Resume"), - OnRetry = () => Logger.Log(@"Retry"), - OnQuit = () => Logger.Log(@"Quit"), - }, - failOverlay = new FailOverlay + Child = globalActionContainer = new GlobalActionContainer(game); + } - { - OnRetry = () => Logger.Log(@"Retry"), - OnQuit = () => Logger.Log(@"Quit"), - } + [SetUp] + public void SetUp() => Schedule(() => + { + globalActionContainer.Children = new Drawable[] + { + pauseOverlay = new PauseOverlay + { + OnResume = () => Logger.Log(@"Resume"), + OnRetry = () => Logger.Log(@"Retry"), + OnQuit = () => Logger.Log(@"Quit"), + }, + failOverlay = new FailOverlay + + { + OnRetry = () => Logger.Log(@"Retry"), + OnQuit = () => Logger.Log(@"Quit"), } }; + InputManager.MoveMouseTo(Vector2.Zero); + }); + + [Test] + public void TestAdjustRetryCount() + { + showOverlay(); + var retryCount = 0; - AddStep("Add retry", () => + AddRepeatStep("Add retry", () => { retryCount++; pauseOverlay.Retries = failOverlay.Retries = retryCount; - }); + }, 10); + } - AddToggleStep("Toggle pause overlay", t => pauseOverlay.ToggleVisibility()); - AddToggleStep("Toggle fail overlay", t => failOverlay.ToggleVisibility()); + /// + /// Tests that pressing enter after an overlay shows doesn't trigger an event because a selection hasn't occurred. + /// + [Test] + public void TestEnterWithoutSelection() + { + showOverlay(); - testHideResets(); + AddStep("Press select", () => press(GlobalAction.Select)); + AddAssert("Overlay still open", () => pauseOverlay.State.Value == Visibility.Visible); + } - testEnterWithoutSelection(); - testKeyUpFromInitial(); - testKeyDownFromInitial(); - testKeyUpWrapping(); - testKeyDownWrapping(); + /// + /// Tests that pressing the up arrow from the initial state selects the last button. + /// + [Test] + public void TestKeyUpFromInitial() + { + showOverlay(); - testMouseSelectionAfterKeySelection(); - testKeySelectionAfterMouseSelection(); + AddStep("Up arrow", () => InputManager.Key(Key.Up)); + AddAssert("Last button selected", () => pauseOverlay.Buttons.Last().Selected.Value); + } - testMouseDeselectionResets(); + /// + /// Tests that pressing the down arrow from the initial state selects the first button. + /// + [Test] + public void TestKeyDownFromInitial() + { + showOverlay(); - testClickSelection(); - testEnterKeySelection(); + AddStep("Down arrow", () => InputManager.Key(Key.Down)); + AddAssert("First button selected", () => getButton(0).Selected.Value); + } + + /// + /// Tests that pressing the up arrow repeatedly causes the selected button to wrap correctly. + /// + [Test] + public void TestKeyUpWrapping() + { + AddStep("Show overlay", () => failOverlay.Show()); + + AddStep("Up arrow", () => InputManager.Key(Key.Up)); + AddAssert("Last button selected", () => failOverlay.Buttons.Last().Selected.Value); + AddStep("Up arrow", () => InputManager.Key(Key.Up)); + AddAssert("First button selected", () => failOverlay.Buttons.First().Selected.Value); + AddStep("Up arrow", () => InputManager.Key(Key.Up)); + AddAssert("Last button selected", () => failOverlay.Buttons.Last().Selected.Value); + } + + /// + /// Tests that pressing the down arrow repeatedly causes the selected button to wrap correctly. + /// + [Test] + public void TestKeyDownWrapping() + { + AddStep("Show overlay", () => failOverlay.Show()); + + AddStep("Down arrow", () => InputManager.Key(Key.Down)); + AddAssert("First button selected", () => failOverlay.Buttons.First().Selected.Value); + AddStep("Down arrow", () => InputManager.Key(Key.Down)); + AddAssert("Last button selected", () => failOverlay.Buttons.Last().Selected.Value); + AddStep("Down arrow", () => InputManager.Key(Key.Down)); + AddAssert("First button selected", () => failOverlay.Buttons.First().Selected.Value); } /// /// Test that hiding the overlay after hovering a button will reset the overlay to the initial state with no buttons selected. /// - private void testHideResets() + [Test] + public void TestHideResets() { AddStep("Show overlay", () => failOverlay.Show()); @@ -90,141 +149,78 @@ namespace osu.Game.Tests.Visual.Gameplay } /// - /// Tests that pressing enter after an overlay shows doesn't trigger an event because a selection hasn't occurred. + /// Tests that entering menu with cursor initially on button doesn't selects it immediately. + /// This is to allow for stable keyboard navigation. /// - private void testEnterWithoutSelection() + [Test] + public void TestInitialButtonHover() { - AddStep("Show overlay", () => pauseOverlay.Show()); + showOverlay(); - AddStep("Press select", () => press(GlobalAction.Select)); - AddAssert("Overlay still open", () => pauseOverlay.State.Value == Visibility.Visible); + AddStep("Hover first button", () => InputManager.MoveMouseTo(getButton(0))); AddStep("Hide overlay", () => pauseOverlay.Hide()); - } + showOverlay(); - /// - /// Tests that pressing the up arrow from the initial state selects the last button. - /// - private void testKeyUpFromInitial() - { - AddStep("Show overlay", () => pauseOverlay.Show()); + AddAssert("First button not selected", () => !getButton(0).Selected.Value); - AddStep("Up arrow", () => press(Key.Up)); - AddAssert("Last button selected", () => pauseOverlay.Buttons.Last().Selected.Value); + AddStep("Move slightly", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(1))); - AddStep("Hide overlay", () => pauseOverlay.Hide()); - } - - /// - /// Tests that pressing the down arrow from the initial state selects the first button. - /// - private void testKeyDownFromInitial() - { - AddStep("Show overlay", () => pauseOverlay.Show()); - - AddStep("Down arrow", () => press(Key.Down)); - AddAssert("First button selected", () => pauseOverlay.Buttons.First().Selected.Value); - - AddStep("Hide overlay", () => pauseOverlay.Hide()); - } - - /// - /// Tests that pressing the up arrow repeatedly causes the selected button to wrap correctly. - /// - private void testKeyUpWrapping() - { - AddStep("Show overlay", () => failOverlay.Show()); - - AddStep("Up arrow", () => press(Key.Up)); - AddAssert("Last button selected", () => failOverlay.Buttons.Last().Selected.Value); - AddStep("Up arrow", () => press(Key.Up)); - AddAssert("First button selected", () => failOverlay.Buttons.First().Selected.Value); - AddStep("Up arrow", () => press(Key.Up)); - AddAssert("Last button selected", () => failOverlay.Buttons.Last().Selected.Value); - - AddStep("Hide overlay", () => failOverlay.Hide()); - } - - /// - /// Tests that pressing the down arrow repeatedly causes the selected button to wrap correctly. - /// - private void testKeyDownWrapping() - { - AddStep("Show overlay", () => failOverlay.Show()); - - AddStep("Down arrow", () => press(Key.Down)); - AddAssert("First button selected", () => failOverlay.Buttons.First().Selected.Value); - AddStep("Down arrow", () => press(Key.Down)); - AddAssert("Last button selected", () => failOverlay.Buttons.Last().Selected.Value); - AddStep("Down arrow", () => press(Key.Down)); - AddAssert("First button selected", () => failOverlay.Buttons.First().Selected.Value); - - AddStep("Hide overlay", () => failOverlay.Hide()); + AddAssert("First button selected", () => getButton(0).Selected.Value); } /// /// Tests that hovering a button that was previously selected with the keyboard correctly selects the new button and deselects the previous button. /// - private void testMouseSelectionAfterKeySelection() + [Test] + public void TestMouseSelectionAfterKeySelection() { - AddStep("Show overlay", () => pauseOverlay.Show()); + showOverlay(); - var secondButton = pauseOverlay.Buttons.Skip(1).First(); - - AddStep("Down arrow", () => press(Key.Down)); - AddStep("Hover second button", () => InputManager.MoveMouseTo(secondButton)); - AddAssert("First button not selected", () => !pauseOverlay.Buttons.First().Selected.Value); - AddAssert("Second button selected", () => secondButton.Selected.Value); - - AddStep("Hide overlay", () => pauseOverlay.Hide()); + AddStep("Down arrow", () => InputManager.Key(Key.Down)); + AddStep("Hover second button", () => InputManager.MoveMouseTo(getButton(1))); + AddAssert("First button not selected", () => !getButton(0).Selected.Value); + AddAssert("Second button selected", () => getButton(1).Selected.Value); } /// /// Tests that pressing a key after selecting a button with a hover event correctly selects a new button and deselects the previous button. /// - private void testKeySelectionAfterMouseSelection() + [Test] + public void TestKeySelectionAfterMouseSelection() { AddStep("Show overlay", () => { pauseOverlay.Show(); - InputManager.MoveMouseTo(Vector2.Zero); }); - var secondButton = pauseOverlay.Buttons.Skip(1).First(); - - AddStep("Hover second button", () => InputManager.MoveMouseTo(secondButton)); - AddStep("Up arrow", () => press(Key.Up)); - AddAssert("Second button not selected", () => !secondButton.Selected.Value); - AddAssert("First button selected", () => pauseOverlay.Buttons.First().Selected.Value); - - AddStep("Hide overlay", () => pauseOverlay.Hide()); + AddStep("Hover second button", () => InputManager.MoveMouseTo(getButton(1))); + AddStep("Up arrow", () => InputManager.Key(Key.Up)); + AddAssert("Second button not selected", () => !getButton(1).Selected.Value); + AddAssert("First button selected", () => getButton(0).Selected.Value); } /// /// Tests that deselecting with the mouse by losing hover will reset the overlay to the initial state. /// - private void testMouseDeselectionResets() + [Test] + public void TestMouseDeselectionResets() { - AddStep("Show overlay", () => pauseOverlay.Show()); + showOverlay(); - var secondButton = pauseOverlay.Buttons.Skip(1).First(); - - AddStep("Hover second button", () => InputManager.MoveMouseTo(secondButton)); + AddStep("Hover second button", () => InputManager.MoveMouseTo(getButton(1))); AddStep("Unhover second button", () => InputManager.MoveMouseTo(Vector2.Zero)); - AddStep("Down arrow", () => press(Key.Down)); - AddAssert("First button selected", () => pauseOverlay.Buttons.First().Selected.Value); // Initial state condition - - AddStep("Hide overlay", () => pauseOverlay.Hide()); + AddStep("Down arrow", () => InputManager.Key(Key.Down)); + AddAssert("First button selected", () => getButton(0).Selected.Value); // Initial state condition } /// /// Tests that clicking on a button correctly causes a click event for that button. /// - private void testClickSelection() + [Test] + public void TestClickSelection() { - AddStep("Show overlay", () => pauseOverlay.Show()); - - var retryButton = pauseOverlay.Buttons.Skip(1).First(); + showOverlay(); bool triggered = false; AddStep("Click retry button", () => @@ -232,7 +228,7 @@ namespace osu.Game.Tests.Visual.Gameplay var lastAction = pauseOverlay.OnRetry; pauseOverlay.OnRetry = () => triggered = true; - retryButton.Click(); + getButton(1).Click(); pauseOverlay.OnRetry = lastAction; }); @@ -243,14 +239,15 @@ namespace osu.Game.Tests.Visual.Gameplay /// /// Tests that pressing the enter key with a button selected correctly causes a click event for that button. /// - private void testEnterKeySelection() + [Test] + public void TestEnterKeySelection() { - AddStep("Show overlay", () => pauseOverlay.Show()); + showOverlay(); AddStep("Select second button", () => { - press(Key.Down); - press(Key.Down); + InputManager.Key(Key.Down); + InputManager.Key(Key.Down); }); bool triggered = false; @@ -259,7 +256,7 @@ namespace osu.Game.Tests.Visual.Gameplay { lastAction = pauseOverlay.OnRetry; pauseOverlay.OnRetry = () => triggered = true; - press(Key.Enter); + InputManager.Key(Key.Enter); }); AddAssert("Action was triggered", () => @@ -275,12 +272,24 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("Overlay is closed", () => pauseOverlay.State.Value == Visibility.Hidden); } - private void press(Key key) + [Test] + public void TestSelectionResetOnVisibilityChange() { - InputManager.PressKey(key); - InputManager.ReleaseKey(key); + showOverlay(); + AddStep("Select last button", () => InputManager.Key(Key.Up)); + + hideOverlay(); + showOverlay(); + + AddAssert("No button selected", + () => pauseOverlay.Buttons.All(button => !button.Selected.Value)); } + private void showOverlay() => AddStep("Show overlay", () => pauseOverlay.Show()); + private void hideOverlay() => AddStep("Hide overlay", () => pauseOverlay.Hide()); + + private DialogButton getButton(int index) => pauseOverlay.Buttons.Skip(index).First(); + private void press(GlobalAction action) { globalActionContainer.TriggerPressed(action); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs index 237fee1594..73c6970482 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayRewinding.cs @@ -1,70 +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 System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Audio.Track; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Rulesets; -using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.UI; -using osu.Game.Screens.Play; +using osu.Game.Storyboards; using osuTK; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneGameplayRewinding : PlayerTestScene + public class TestSceneGameplayRewinding : OsuPlayerTestScene { - private RulesetExposingPlayer player => (RulesetExposingPlayer)Player; - [Resolved] private AudioManager audioManager { get; set; } - public TestSceneGameplayRewinding() - : base(new OsuRuleset()) - { - } - - private Track track; - - protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap) - { - var working = new ClockBackedTestWorkingBeatmap(beatmap, new FramedClock(new ManualClock { Rate = 1 }), audioManager); - track = working.Track; - return working; - } + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => + new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); [Test] public void TestNoJudgementsOnRewind() { - AddUntilStep("wait for track to start running", () => track.IsRunning); + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); addSeekStep(3000); - AddAssert("all judged", () => player.DrawableRuleset.Playfield.AllHitObjects.All(h => h.Judged)); - AddStep("clear results", () => player.AppliedResults.Clear()); + AddAssert("all judged", () => Player.DrawableRuleset.Playfield.AllHitObjects.All(h => h.Judged)); + AddUntilStep("key counter counted keys", () => Player.HUDOverlay.KeyCounter.Children.All(kc => kc.CountPresses >= 7)); + AddStep("clear results", () => Player.Results.Clear()); addSeekStep(0); - AddAssert("none judged", () => player.DrawableRuleset.Playfield.AllHitObjects.All(h => !h.Judged)); - AddAssert("no results triggered", () => player.AppliedResults.Count == 0); + AddAssert("none judged", () => Player.DrawableRuleset.Playfield.AllHitObjects.All(h => !h.Judged)); + AddUntilStep("key counters reset", () => Player.HUDOverlay.KeyCounter.Children.All(kc => kc.CountPresses == 0)); + AddAssert("no results triggered", () => Player.Results.Count == 0); } private void addSeekStep(double time) { - AddStep($"seek to {time}", () => track.Seek(time)); + AddStep($"seek to {time}", () => Beatmap.Value.Track.Seek(time)); // Allow a few frames of lenience - AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); + AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); } - protected override Player CreatePlayer(Ruleset ruleset) + protected override TestPlayer CreatePlayer(Ruleset ruleset) { - Mods.Value = Mods.Value.Concat(new[] { ruleset.GetAutoplayMod() }).ToArray(); - return new RulesetExposingPlayer(); + SelectedMods.Value = SelectedMods.Value.Concat(new[] { ruleset.GetAutoplayMod() }).ToArray(); + return base.CreatePlayer(ruleset); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) @@ -85,25 +69,5 @@ namespace osu.Game.Tests.Visual.Gameplay return beatmap; } - - private class RulesetExposingPlayer : Player - { - public readonly List AppliedResults = new List(); - - public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; - - public new DrawableRuleset DrawableRuleset => base.DrawableRuleset; - - public RulesetExposingPlayer() - : base(false, false) - { - } - - [BackgroundDependencyLoader] - private void load() - { - ScoreProcessor.NewJudgement += r => AppliedResults.Add(r); - } - } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs new file mode 100644 index 0000000000..b86cb69eb4 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.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 System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.Audio; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Screens.Play; +using osu.Game.Skinning; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneGameplaySamplePlayback : PlayerTestScene + { + [Test] + public void TestAllSamplesStopDuringSeek() + { + DrawableSlider slider = null; + DrawableSample[] samples = null; + ISamplePlaybackDisabler sampleDisabler = null; + + AddStep("get variables", () => + { + sampleDisabler = Player; + slider = Player.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).First(); + samples = slider.ChildrenOfType().ToArray(); + }); + + AddUntilStep("wait for slider sliding then seek", () => + { + if (!slider.Tracking.Value) + return false; + + if (!samples.Any(s => s.Playing)) + return false; + + Player.ChildrenOfType().First().Seek(40000); + return true; + }); + + AddAssert("sample playback disabled", () => sampleDisabler.SamplePlaybackDisabled.Value); + + // because we are in frame stable context, it's quite likely that not all samples are "played" at this point. + // the important thing is that at least one started, and that sample has since stopped. + AddAssert("all looping samples stopped immediately", () => allStopped(allLoopingSounds)); + AddUntilStep("all samples stopped eventually", () => allStopped(allSounds)); + + AddAssert("sample playback still disabled", () => sampleDisabler.SamplePlaybackDisabled.Value); + + AddUntilStep("seek finished, sample playback enabled", () => !sampleDisabler.SamplePlaybackDisabled.Value); + AddUntilStep("any sample is playing", () => Player.ChildrenOfType().Any(s => s.IsPlaying)); + } + + private IEnumerable allSounds => Player.ChildrenOfType(); + private IEnumerable allLoopingSounds => allSounds.Where(sound => sound.Looping); + + private bool allStopped(IEnumerable sounds) => sounds.All(sound => !sound.IsPlaying); + + protected override bool Autoplay => true; + + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs new file mode 100644 index 0000000000..f9914e0193 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -0,0 +1,155 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneHUDOverlay : OsuManualInputManagerTestScene + { + private HUDOverlay hudOverlay; + + // best way to check without exposing. + private Drawable hideTarget => hudOverlay.KeyCounter; + private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); + + [Resolved] + private OsuConfigManager config { get; set; } + + [Test] + public void TestComboCounterIncrementing() + { + createNew(); + + AddRepeatStep("increase combo", () => { hudOverlay.ComboCounter.Current.Value++; }, 10); + + AddStep("reset combo", () => { hudOverlay.ComboCounter.Current.Value = 0; }); + } + + [Test] + public void TestShownByDefault() + { + createNew(); + + AddAssert("showhud is set", () => hudOverlay.ShowHud.Value); + + AddAssert("hidetarget is visible", () => hideTarget.IsPresent); + AddAssert("key counter flow is visible", () => keyCounterFlow.IsPresent); + AddAssert("pause button is visible", () => hudOverlay.HoldToQuit.IsPresent); + } + + [Test] + public void TestFadesInOnLoadComplete() + { + float? initialAlpha = null; + + createNew(h => h.OnLoadComplete += _ => initialAlpha = hideTarget.Alpha); + AddUntilStep("wait for load", () => hudOverlay.IsAlive); + AddAssert("initial alpha was less than 1", () => initialAlpha < 1); + } + + [Test] + public void TestHideExternally() + { + createNew(); + + AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); + + AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); + AddAssert("pause button is still visible", () => hudOverlay.HoldToQuit.IsPresent); + + // Key counter flow container should not be affected by this, only the key counter display will be hidden as checked above. + AddAssert("key counter flow not affected", () => keyCounterFlow.IsPresent); + } + + [Test] + public void TestMomentaryShowHUD() + { + createNew(); + + HUDVisibilityMode originalConfigValue = HUDVisibilityMode.HideDuringGameplay; + + AddStep("get original config value", () => originalConfigValue = config.Get(OsuSetting.HUDVisibilityMode)); + + AddStep("set hud to never show", () => config.Set(OsuSetting.HUDVisibilityMode, HUDVisibilityMode.Never)); + + AddUntilStep("wait for fade", () => !hideTarget.IsPresent); + + AddStep("trigger momentary show", () => InputManager.PressKey(Key.ControlLeft)); + AddUntilStep("wait for visible", () => hideTarget.IsPresent); + + AddStep("stop trigering", () => InputManager.ReleaseKey(Key.ControlLeft)); + AddUntilStep("wait for fade", () => !hideTarget.IsPresent); + + AddStep("set original config value", () => config.Set(OsuSetting.HUDVisibilityMode, originalConfigValue)); + } + + [Test] + public void TestExternalHideDoesntAffectConfig() + { + HUDVisibilityMode originalConfigValue = HUDVisibilityMode.HideDuringGameplay; + + createNew(); + + AddStep("get original config value", () => originalConfigValue = config.Get(OsuSetting.HUDVisibilityMode)); + + AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); + AddAssert("config unchanged", () => originalConfigValue == config.Get(OsuSetting.HUDVisibilityMode)); + + AddStep("set showhud true", () => hudOverlay.ShowHud.Value = true); + AddAssert("config unchanged", () => originalConfigValue == config.Get(OsuSetting.HUDVisibilityMode)); + } + + [Test] + public void TestChangeHUDVisibilityOnHiddenKeyCounter() + { + bool keyCounterVisibleValue = false; + + createNew(); + AddStep("save keycounter visible value", () => keyCounterVisibleValue = config.Get(OsuSetting.KeyOverlay)); + + AddStep("set keycounter visible false", () => + { + config.Set(OsuSetting.KeyOverlay, false); + hudOverlay.KeyCounter.AlwaysVisible.Value = false; + }); + + AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); + AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); + AddAssert("key counters hidden", () => !keyCounterFlow.IsPresent); + + AddStep("set showhud true", () => hudOverlay.ShowHud.Value = true); + AddUntilStep("hidetarget is visible", () => hideTarget.IsPresent); + AddAssert("key counters still hidden", () => !keyCounterFlow.IsPresent); + + AddStep("return value", () => config.Set(OsuSetting.KeyOverlay, keyCounterVisibleValue)); + } + + private void createNew(Action action = null) + { + AddStep("create overlay", () => + { + hudOverlay = new HUDOverlay(null, null, 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); + + Child = hudOverlay; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs new file mode 100644 index 0000000000..1021ac3760 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -0,0 +1,166 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Judgements; +using osu.Framework.Utils; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Threading; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Catch.Scoring; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Scoring; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.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; + + public TestSceneHitErrorMeter() + { + recreateDisplay(new OsuHitWindows(), 5); + + 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); + AddStep("New fixed judgement (50ms)", () => newJudgement(50)); + + AddStep("Judgement barrage", () => + { + int runCount = 0; + + ScheduledDelegate del = null; + + del = Scheduler.AddDelayed(() => + { + newJudgement(runCount++ / 10f); + + if (runCount == 500) + // ReSharper disable once AccessToModifiedClosure + del?.Cancel(); + }, 10, true); + }); + } + + [Test] + public void TestOsu() + { + AddStep("OD 1", () => recreateDisplay(new OsuHitWindows(), 1)); + AddStep("OD 10", () => recreateDisplay(new OsuHitWindows(), 10)); + } + + [Test] + public void TestTaiko() + { + AddStep("OD 1", () => recreateDisplay(new TaikoHitWindows(), 1)); + AddStep("OD 10", () => recreateDisplay(new TaikoHitWindows(), 10)); + } + + [Test] + public void TestMania() + { + AddStep("OD 1", () => recreateDisplay(new ManiaHitWindows(), 1)); + AddStep("OD 10", () => recreateDisplay(new ManiaHitWindows(), 10)); + } + + [Test] + public void TestCatch() + { + AddStep("OD 1", () => recreateDisplay(new CatchHitWindows(), 1)); + AddStep("OD 10", () => recreateDisplay(new CatchHitWindows(), 10)); + } + + private void recreateDisplay(HitWindows hitWindows, float overallDifficulty) + { + this.hitWindows = hitWindows; + + hitWindows?.SetDifficulty(overallDifficulty); + + Clear(); + + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new[] + { + new OsuSpriteText { Text = $@"Great: {hitWindows?.WindowFor(HitResult.Great)}" }, + new OsuSpriteText { Text = $@"Good: {hitWindows?.WindowFor(HitResult.Ok)}" }, + new OsuSpriteText { Text = $@"Meh: {hitWindows?.WindowFor(HitResult.Meh)}" }, + } + }); + + Add(barMeter = new BarHitErrorMeter(hitWindows, true) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }); + + Add(barMeter2 = new BarHitErrorMeter(hitWindows, false) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }); + + Add(barMeter3 = new BarHitErrorMeter(hitWindows, true) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.CentreLeft, + Rotation = 270, + }); + + Add(colourMeter = new ColourHitErrorMeter(hitWindows) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Right = 50 } + }); + + Add(colourMeter2 = new ColourHitErrorMeter(hitWindows) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 50 } + }); + + Add(colourMeter3 = new ColourHitErrorMeter(hitWindows) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.CentreLeft, + Rotation = 270, + Margin = new MarginPadding { Left = 50 } + }); + } + + private void newJudgement(double offset = 0) + { + var judgement = new JudgementResult(new HitObject(), 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); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs index d42b61ea55..235842acc9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHoldForMenuButton.cs @@ -13,10 +13,12 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [Description("'Hold to Quit' UI element")] - public class TestSceneHoldForMenuButton : ManualInputManagerTestScene + public class TestSceneHoldForMenuButton : OsuManualInputManagerTestScene { private bool exitAction; + protected override double TimePerAction => 100; // required for the early exit test, since hold-to-confirm delay is 200ms + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs new file mode 100644 index 0000000000..6de85499c5 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs @@ -0,0 +1,94 @@ +// 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.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [HeadlessTest] + public class TestSceneKeyBindings : OsuManualInputManagerTestScene + { + private readonly ActionReceiver receiver; + + public TestSceneKeyBindings() + { + Add(new TestKeyBindingContainer + { + Child = receiver = new ActionReceiver() + }); + } + + [Test] + public void TestDefaultsWhenNotDatabased() + { + AddStep("fire key", () => InputManager.Key(Key.A)); + + AddAssert("received key", () => receiver.ReceivedAction); + } + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => + throw new System.NotImplementedException(); + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => + throw new System.NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => + throw new System.NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => + throw new System.NotImplementedException(); + + public override IEnumerable GetDefaultKeyBindings(int variant = 0) + { + return new[] + { + new KeyBinding(InputKey.A, TestAction.Down), + }; + } + + public override string Description => "test"; + public override string ShortName => "test"; + } + + private enum TestAction + { + Down, + } + + private class TestKeyBindingContainer : DatabasedKeyBindingContainer + { + public TestKeyBindingContainer() + : base(new TestRuleset().RulesetInfo, 0) + { + } + } + + private class ActionReceiver : CompositeDrawable, IKeyBindingHandler + { + public bool ReceivedAction; + + public bool OnPressed(TestAction action) + { + ReceivedAction = action == TestAction.Down; + return true; + } + + public void OnReleased(TestAction action) + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs index 18088a9a5b..87ab42fe60 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyCounter.cs @@ -1,38 +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; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.MathUtils; -using osu.Framework.Timing; +using osu.Framework.Utils; using osu.Game.Screens.Play; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneKeyCounter : ManualInputManagerTestScene + public class TestSceneKeyCounter : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(KeyCounterKeyboard), - typeof(KeyCounterMouse), - typeof(KeyCounterDisplay) - }; - public TestSceneKeyCounter() { - KeyCounterKeyboard rewindTestKeyCounterKeyboard; + KeyCounterKeyboard testCounter; + KeyCounterDisplay kc = new KeyCounterDisplay { Origin = Anchor.Centre, Anchor = Anchor.Centre, Children = new KeyCounter[] { - rewindTestKeyCounterKeyboard = new KeyCounterKeyboard(Key.X), + testCounter = new KeyCounterKeyboard(Key.X), new KeyCounterKeyboard(Key.X), new KeyCounterMouse(MouseButton.Left), new KeyCounterMouse(MouseButton.Right), @@ -44,59 +35,23 @@ namespace osu.Game.Tests.Visual.Gameplay Key key = (Key)((int)Key.A + RNG.Next(26)); kc.Add(new KeyCounterKeyboard(key)); }); - AddSliderStep("Fade time", 0, 200, 50, v => kc.FadeTime = v); Key testKey = ((KeyCounterKeyboard)kc.Children.First()).Key; - double time1 = 0; - AddStep($"Press {testKey} key", () => + void addPressKeyStep() { - InputManager.PressKey(testKey); - InputManager.ReleaseKey(testKey); - }); + AddStep($"Press {testKey} key", () => InputManager.Key(testKey)); + } - AddAssert($"Check {testKey} counter after keypress", () => rewindTestKeyCounterKeyboard.CountPresses == 1); - - AddStep($"Press {testKey} key", () => - { - InputManager.PressKey(testKey); - InputManager.ReleaseKey(testKey); - time1 = Clock.CurrentTime; - }); - - AddAssert($"Check {testKey} counter after keypress", () => rewindTestKeyCounterKeyboard.CountPresses == 2); - - IFrameBasedClock oldClock = null; - - AddStep($"Rewind {testKey} counter once", () => - { - oldClock = rewindTestKeyCounterKeyboard.Clock; - rewindTestKeyCounterKeyboard.Clock = new FramedOffsetClock(new FixedClock(time1 - 10)); - }); - - AddAssert($"Check {testKey} counter after rewind", () => rewindTestKeyCounterKeyboard.CountPresses == 1); - - AddStep($"Rewind {testKey} counter to zero", () => rewindTestKeyCounterKeyboard.Clock = new FramedOffsetClock(new FixedClock(0))); - - AddAssert($"Check {testKey} counter after rewind", () => rewindTestKeyCounterKeyboard.CountPresses == 0); - - AddStep("Restore clock", () => rewindTestKeyCounterKeyboard.Clock = oldClock); + addPressKeyStep(); + AddAssert($"Check {testKey} counter after keypress", () => testCounter.CountPresses == 1); + addPressKeyStep(); + AddAssert($"Check {testKey} counter after keypress", () => testCounter.CountPresses == 2); + AddStep("Disable counting", () => testCounter.IsCounting = false); + addPressKeyStep(); + AddAssert($"Check {testKey} count has not changed", () => testCounter.CountPresses == 2); Add(kc); } - - private class FixedClock : IClock - { - private readonly double time; - - public FixedClock(double time) - { - this.time = time; - } - - public double CurrentTime => time; - public double Rate => 1; - public bool IsRunning => false; - } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs new file mode 100644 index 0000000000..563d6be0da --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.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. + +using System.Diagnostics; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play; +using osu.Game.Storyboards; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneLeadIn : RateAdjustedBeatmapTestScene + { + private LeadInPlayer player; + + private const double lenience_ms = 10; + + private const double first_hit_object = 2170; + + [TestCase(1000, 0)] + [TestCase(2000, 0)] + [TestCase(3000, first_hit_object - 3000)] + [TestCase(10000, first_hit_object - 10000)] + public void TestLeadInProducesCorrectStartTime(double leadIn, double expectedStartTime) + { + loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = { AudioLeadIn = leadIn } + }); + + AddAssert($"first frame is {expectedStartTime}", () => + { + Debug.Assert(player.FirstFrameClockTime != null); + return Precision.AlmostEquals(player.FirstFrameClockTime.Value, expectedStartTime, lenience_ms); + }); + } + + [TestCase(1000, 0)] + [TestCase(0, 0)] + [TestCase(-1000, -1000)] + [TestCase(-10000, -10000)] + public void TestStoryboardProducesCorrectStartTime(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); + + 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", () => + { + Beatmap.Value = CreateWorkingBeatmap(beatmap, storyboard); + LoadScreen(player = new LeadInPlayer()); + }); + + AddUntilStep("player loaded", () => player.IsLoaded && player.Alpha == 1); + } + + private class LeadInPlayer : TestPlayer + { + public LeadInPlayer() + : base(false, false) + { + } + + public double? FirstFrameClockTime; + + public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; + + public double GameplayStartTime => DrawableRuleset.GameplayStartTime; + + public double FirstHitObjectTime => DrawableRuleset.Objects.First().StartTime; + + public double GameplayClockTime => GameplayClockContainer.GameplayClock.CurrentTime; + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (!FirstFrameClockTime.HasValue) + { + FirstFrameClockTime = GameplayClockContainer.GameplayClock.CurrentTime; + AddInternal(new OsuSpriteText + { + Text = $"GameplayStartTime: {DrawableRuleset.GameplayStartTime} " + + $"FirstHitObjectTime: {FirstHitObjectTime} " + + $"LeadInTime: {Beatmap.Value.BeatmapInfo.AudioLeadIn} " + + $"FirstFrameClockTime: {FirstFrameClockTime}" + }); + } + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs index 41722b430e..0ada3cf05f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneMedalOverlay.cs @@ -1,11 +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.Collections.Generic; using NUnit.Framework; using osu.Game.Overlays; -using osu.Game.Overlays.MedalSplash; using osu.Game.Users; namespace osu.Game.Tests.Visual.Gameplay @@ -13,12 +10,6 @@ namespace osu.Game.Tests.Visual.Gameplay [TestFixture] public class TestSceneMedalOverlay : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(MedalOverlay), - typeof(DrawableMedal), - }; - public TestSceneMedalOverlay() { AddStep(@"display", () => diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.cs new file mode 100644 index 0000000000..951ee1489d --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneNightcoreBeatContainer.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.Linq; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Tests.Visual.UserInterface; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneNightcoreBeatContainer : TestSceneBeatSyncedContainer + { + protected override void LoadComplete() + { + base.LoadComplete(); + + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + + Beatmap.Value.Track.Start(); + Beatmap.Value.Track.Seek(Beatmap.Value.Beatmap.HitObjects.First().StartTime - 1000); + + Add(new ModNightcore.NightcoreBeatContainer()); + + AddStep("change signature to quadruple", () => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ForEach(p => p.TimeSignature = TimeSignatures.SimpleQuadruple)); + AddStep("change signature to triple", () => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ForEach(p => p.TimeSignature = TimeSignatures.SimpleTriple)); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs new file mode 100644 index 0000000000..4fa4c00981 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.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.Bindables; +using osu.Game.Overlays; +using osu.Game.Rulesets; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneOverlayActivation : OsuPlayerTestScene + { + protected new OverlayTestPlayer Player => base.Player as OverlayTestPlayer; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddUntilStep("gameplay has started", + () => Player.GameplayClockContainer.GameplayClock.CurrentTime > Player.DrawableRuleset.GameplayStartTime); + } + + [Test] + public void TestGameplayOverlayActivation() + { + AddAssert("local user playing", () => Player.LocalUserPlaying.Value); + AddAssert("activation mode is disabled", () => Player.OverlayActivationMode == OverlayActivation.Disabled); + } + + [Test] + public void TestGameplayOverlayActivationPaused() + { + AddAssert("local user playing", () => Player.LocalUserPlaying.Value); + AddAssert("activation mode is disabled", () => Player.OverlayActivationMode == OverlayActivation.Disabled); + AddStep("pause gameplay", () => Player.Pause()); + AddAssert("local user not playing", () => !Player.LocalUserPlaying.Value); + AddUntilStep("activation mode is user triggered", () => Player.OverlayActivationMode == OverlayActivation.UserTriggered); + } + + [Test] + public void TestGameplayOverlayActivationReplayLoaded() + { + AddAssert("local user playing", () => Player.LocalUserPlaying.Value); + AddAssert("activation mode is disabled", () => Player.OverlayActivationMode == OverlayActivation.Disabled); + AddStep("load a replay", () => Player.DrawableRuleset.HasReplayLoaded.Value = true); + AddAssert("local user not playing", () => !Player.LocalUserPlaying.Value); + AddAssert("activation mode is user triggered", () => Player.OverlayActivationMode == OverlayActivation.UserTriggered); + } + + [Test] + public void TestGameplayOverlayActivationBreaks() + { + AddAssert("local user playing", () => Player.LocalUserPlaying.Value); + AddAssert("activation mode is disabled", () => Player.OverlayActivationMode == OverlayActivation.Disabled); + AddStep("seek to break", () => Player.GameplayClockContainer.Seek(Beatmap.Value.Beatmap.Breaks.First().StartTime)); + AddUntilStep("activation mode is user triggered", () => Player.OverlayActivationMode == OverlayActivation.UserTriggered); + AddAssert("local user not playing", () => !Player.LocalUserPlaying.Value); + AddStep("seek to break end", () => Player.GameplayClockContainer.Seek(Beatmap.Value.Beatmap.Breaks.First().EndTime)); + AddUntilStep("activation mode is disabled", () => Player.OverlayActivationMode == OverlayActivation.Disabled); + AddAssert("local user playing", () => Player.LocalUserPlaying.Value); + } + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new OverlayTestPlayer(); + + protected class OverlayTestPlayer : TestPlayer + { + public new OverlayActivation OverlayActivationMode => base.OverlayActivationMode.Value; + public new Bindable LocalUserPlaying => base.LocalUserPlaying; + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 5808a78056..46dd91710a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -10,15 +10,14 @@ using osu.Framework.Testing; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Rulesets; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; +using osu.Game.Skinning; using osuTK; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePause : PlayerTestScene + public class TestScenePause : OsuPlayerTestScene { protected new PausePlayer Player => (PausePlayer)base.Player; @@ -27,7 +26,6 @@ namespace osu.Game.Tests.Visual.Gameplay protected override Container Content => content; public TestScenePause() - : base(new OsuRuleset()) { base.Content.Add(content = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }); } @@ -36,6 +34,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override void SetUpSteps() { base.SetUpSteps(); + AddStep("resume player", () => Player.GameplayClockContainer.Start()); confirmClockRunning(true); } @@ -52,7 +51,7 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestResumeWithResumeOverlay() { AddStep("move cursor to center", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("wait for hitobjects", () => Player.ScoreProcessor.Health.Value < 1); + AddUntilStep("wait for hitobjects", () => Player.HealthProcessor.Health.Value < 1); pauseAndConfirm(); resume(); @@ -60,21 +59,35 @@ namespace osu.Game.Tests.Visual.Gameplay confirmClockRunning(false); confirmPauseOverlayShown(false); - AddStep("click to resume", () => - { - InputManager.PressButton(MouseButton.Left); - InputManager.ReleaseButton(MouseButton.Left); - }); + AddStep("click to resume", () => InputManager.Click(MouseButton.Left)); confirmClockRunning(true); } + [Test] + public void TestPauseWithResumeOverlay() + { + AddStep("move cursor to center", () => InputManager.MoveMouseTo(Player.ScreenSpaceDrawQuad.Centre)); + AddUntilStep("wait for hitobjects", () => Player.HealthProcessor.Health.Value < 1); + + pauseAndConfirm(); + + resume(); + confirmClockRunning(false); + confirmPauseOverlayShown(false); + + pauseAndConfirm(); + + AddUntilStep("resume overlay is not active", () => Player.DrawableRuleset.ResumeOverlay.State.Value == Visibility.Hidden); + confirmPaused(); + } + [Test] public void TestResumeWithResumeOverlaySkipped() { AddStep("move cursor to button", () => InputManager.MoveMouseTo(Player.HUDOverlay.HoldToQuit.Children.OfType().First().ScreenSpaceDrawQuad.Centre)); - AddUntilStep("wait for hitobjects", () => Player.ScoreProcessor.Health.Value < 1); + AddUntilStep("wait for hitobjects", () => Player.HealthProcessor.Health.Value < 1); pauseAndConfirm(); resumeAndConfirm(); @@ -97,8 +110,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestExitTooSoon() { - pauseAndConfirm(); + AddStep("seek before gameplay", () => Player.GameplayClockContainer.Seek(-5000)); + pauseAndConfirm(); resume(); AddStep("exit too soon", () => Player.Exit()); @@ -128,13 +142,46 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] - public void TestExitFromGameplay() + public void TestExitFromFailedGameplay() { + AddUntilStep("wait for fail", () => Player.HasFailed); AddStep("exit", () => Player.Exit()); - confirmPaused(); + confirmExited(); + } - exitAndConfirm(); + [Test] + public void TestQuickRetryFromFailedGameplay() + { + AddUntilStep("wait for fail", () => Player.HasFailed); + AddStep("quick retry", () => Player.GameplayClockContainer.ChildrenOfType().First().Action?.Invoke()); + + confirmExited(); + } + + [Test] + public void TestQuickExitFromFailedGameplay() + { + AddUntilStep("wait for fail", () => Player.HasFailed); + AddStep("quick exit", () => Player.GameplayClockContainer.ChildrenOfType().First().Action?.Invoke()); + + confirmExited(); + } + + [Test] + public void TestExitFromGameplay() + { + // an externally triggered exit should immediately exit, skipping all pause logic. + AddStep("exit", () => Player.Exit()); + confirmExited(); + } + + [Test] + public void TestQuickExitFromGameplay() + { + AddStep("quick exit", () => Player.GameplayClockContainer.ChildrenOfType().First().Action?.Invoke()); + + confirmExited(); } [Test] @@ -160,6 +207,42 @@ namespace osu.Game.Tests.Visual.Gameplay exitAndConfirm(); } + [Test] + public void TestRestartAfterResume() + { + AddStep("seek before gameplay", () => Player.GameplayClockContainer.Seek(-5000)); + + pauseAndConfirm(); + resumeAndConfirm(); + restart(); + confirmExited(); + } + + [Test] + public void TestPauseSoundLoop() + { + AddStep("seek before gameplay", () => Player.GameplayClockContainer.Seek(-5000)); + + SkinnableSound getLoop() => Player.ChildrenOfType().FirstOrDefault()?.ChildrenOfType().FirstOrDefault(); + + pauseAndConfirm(); + AddAssert("loop is playing", () => getLoop().IsPlaying); + + resumeAndConfirm(); + AddUntilStep("loop is stopped", () => !getLoop().IsPlaying); + + AddUntilStep("pause again", () => + { + Player.Pause(); + return !Player.GameplayClockContainer.GameplayClock.IsRunning; + }); + + AddAssert("loop is playing", () => getLoop().IsPlaying); + + resumeAndConfirm(); + AddUntilStep("loop is stopped", () => !getLoop().IsPlaying); + } + private void pauseAndConfirm() { pause(); @@ -177,6 +260,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("player not exited", () => Player.IsCurrentScreen()); AddStep("exit", () => Player.Exit()); confirmExited(); + confirmNoTrackAdjustments(); } private void confirmPaused() @@ -198,6 +282,12 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("player exited", () => !Player.IsCurrentScreen()); } + private void confirmNoTrackAdjustments() + { + AddAssert("track has no adjustments", () => Beatmap.Value.Track.AggregateFrequency.Value == 1); + } + + private void restart() => AddStep("restart", () => Player.Restart()); private void pause() => AddStep("pause", () => Player.Pause()); private void resume() => AddStep("resume", () => Player.Resume()); @@ -209,16 +299,10 @@ namespace osu.Game.Tests.Visual.Gameplay protected override bool AllowFail => true; - protected override Player CreatePlayer(Ruleset ruleset) => new PausePlayer(); + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new PausePlayer(); protected class PausePlayer : TestPlayer { - public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; - - public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; - - public new HUDOverlay HUDOverlay => base.HUDOverlay; - public bool FailOverlayVisible => FailOverlay.State.Value == Visibility.Visible; public bool PauseOverlayVisible => PauseOverlay.State.Value == Visibility.Visible; diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.cs new file mode 100644 index 0000000000..e43e5ba3ce --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePauseWhenInactive.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; + +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; } + + [Test] + public void TestDoesntPauseDuringIntro() + { + AddStep("set inactive", () => ((Bindable)host.IsActive).Value = false); + + AddStep("resume player", () => Player.GameplayClockContainer.Start()); + AddAssert("ensure not paused", () => !Player.GameplayClockContainer.IsPaused.Value); + 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); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index ab519360ac..9b31dd045a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -5,57 +5,161 @@ 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.Audio; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens; using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; +using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePlayerLoader : ManualInputManagerTestScene + public class TestScenePlayerLoader : OsuManualInputManagerTestScene { private TestPlayerLoader loader; - private OsuScreenStack stack; + private TestPlayerLoaderContainer container; + private TestPlayer player; - [SetUp] - public void Setup() => Schedule(() => + private bool epilepsyWarning; + + [Resolved] + private AudioManager audioManager { get; set; } + + [Resolved] + private SessionStatics sessionStatics { get; set; } + + /// + /// Sets the input manager child to a new test player loader container instance. + /// + /// If the test player should behave like the production one. + /// An action to run before player load but after bindable leases are returned. + public void ResetPlayer(bool interactive, Action beforeLoadAction = null) { - InputManager.Child = stack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }; + player = null; + + audioManager.Volume.SetDefault(); + + InputManager.Clear(); + + container = new TestPlayerLoaderContainer(loader = new TestPlayerLoader(() => player = new TestPlayer(interactive, interactive))); + + beforeLoadAction?.Invoke(); + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); - }); + Beatmap.Value.BeatmapInfo.EpilepsyWarning = epilepsyWarning; + + foreach (var mod in SelectedMods.Value.OfType()) + mod.ApplyToTrack(Beatmap.Value.Track); + + InputManager.Child = container; + } + + [Test] + public void TestEarlyExitBeforePlayerConstruction() + { + AddStep("load dummy beatmap", () => ResetPlayer(false, () => SelectedMods.Value = new[] { new OsuModNightcore() })); + AddUntilStep("wait for current", () => loader.IsCurrentScreen()); + AddStep("exit loader", () => loader.Exit()); + AddUntilStep("wait for not current", () => !loader.IsCurrentScreen()); + AddAssert("player did not load", () => player == null); + AddUntilStep("player disposed", () => loader.DisposalTask == null); + AddAssert("mod rate still applied", () => Beatmap.Value.Track.Rate != 1); + } + + /// + /// When exits early, it has to wait for the player load task + /// to complete before running disposal on player. This previously caused an issue where mod + /// speed adjustments were undone too late, causing cross-screen pollution. + /// + [Test] + public void TestEarlyExitAfterPlayerConstruction() + { + AddStep("load dummy beatmap", () => ResetPlayer(false, () => SelectedMods.Value = new[] { new OsuModNightcore() })); + AddUntilStep("wait for current", () => loader.IsCurrentScreen()); + AddAssert("mod rate applied", () => Beatmap.Value.Track.Rate != 1); + AddUntilStep("wait for non-null player", () => player != null); + AddStep("exit loader", () => loader.Exit()); + AddUntilStep("wait for not current", () => !loader.IsCurrentScreen()); + AddAssert("player did not load", () => !player.IsLoaded); + AddUntilStep("player disposed", () => loader.DisposalTask?.IsCompleted == true); + AddAssert("mod rate still applied", () => Beatmap.Value.Track.Rate != 1); + } [Test] public void TestBlockLoadViaMouseMovement() { - AddStep("load dummy beatmap", () => stack.Push(loader = new TestPlayerLoader(() => new TestPlayer(false, false)))); + AddStep("load dummy beatmap", () => ResetPlayer(false)); AddUntilStep("wait for current", () => loader.IsCurrentScreen()); - AddRepeatStep("move mouse", () => InputManager.MoveMouseTo(loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft + (loader.VisualSettings.ScreenSpaceDrawQuad.BottomRight - loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft) * RNG.NextSingle()), 20); + + AddUntilStep("wait for load ready", () => + { + moveMouse(); + return player?.LoadState == LoadState.Ready; + }); + AddRepeatStep("move mouse", moveMouse, 20); + AddAssert("loader still active", () => loader.IsCurrentScreen()); AddUntilStep("loads after idle", () => !loader.IsCurrentScreen()); + + void moveMouse() + { + InputManager.MoveMouseTo( + loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft + + (loader.VisualSettings.ScreenSpaceDrawQuad.BottomRight - loader.VisualSettings.ScreenSpaceDrawQuad.TopLeft) + * RNG.NextSingle()); + } + } + + [Test] + public void TestBlockLoadViaFocus() + { + OsuFocusedOverlayContainer overlay = null; + + AddStep("load dummy beatmap", () => ResetPlayer(false)); + AddUntilStep("wait for current", () => loader.IsCurrentScreen()); + + AddStep("show focused overlay", () => { container.Add(overlay = new ChangelogOverlay { State = { Value = Visibility.Visible } }); }); + AddUntilStep("overlay visible", () => overlay.IsPresent); + + AddUntilStep("wait for load ready", () => player.LoadState == LoadState.Ready); + AddRepeatStep("twiddle thumbs", () => { }, 20); + + AddAssert("loader still active", () => loader.IsCurrentScreen()); + + AddStep("hide overlay", () => overlay.Hide()); + AddUntilStep("loads after idle", () => !loader.IsCurrentScreen()); } [Test] public void TestLoadContinuation() { - Player player = null; SlowLoadPlayer slowPlayer = null; - AddStep("load dummy beatmap", () => stack.Push(loader = new TestPlayerLoader(() => player = new TestPlayer(false, false)))); + AddStep("load dummy beatmap", () => ResetPlayer(false)); AddUntilStep("wait for current", () => loader.IsCurrentScreen()); AddStep("mouse in centre", () => InputManager.MoveMouseTo(loader.ScreenSpaceDrawQuad.Centre)); AddUntilStep("wait for player to be current", () => player.IsCurrentScreen()); AddStep("load slow dummy beatmap", () => { - stack.Push(loader = new TestPlayerLoader(() => slowPlayer = new SlowLoadPlayer(false, false))); + InputManager.Child = container = new TestPlayerLoaderContainer( + loader = new TestPlayerLoader(() => slowPlayer = new SlowLoadPlayer(false, false))); + Scheduler.AddDelayed(() => slowPlayer.AllowLoad.Set(), 5000); }); @@ -65,16 +169,11 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestModReinstantiation() { - TestPlayer player = null; TestMod gameMod = null; TestMod playerMod1 = null; TestMod playerMod2 = null; - AddStep("load player", () => - { - Mods.Value = new[] { gameMod = new TestMod() }; - stack.Push(loader = new TestPlayerLoader(() => player = new TestPlayer())); - }); + AddStep("load player", () => { ResetPlayer(true, () => SelectedMods.Value = new[] { gameMod = new TestMod() }); }); AddUntilStep("wait for loader to become current", () => loader.IsCurrentScreen()); AddStep("mouse in centre", () => InputManager.MoveMouseTo(loader.ScreenSpaceDrawQuad.Centre)); @@ -97,10 +196,141 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("player mods applied", () => playerMod2.Applied); } + [Test] + public void TestModDisplayChanges() + { + var testMod = new TestMod(); + + AddStep("load player", () => ResetPlayer(true)); + + AddUntilStep("wait for loader to become current", () => loader.IsCurrentScreen()); + AddStep("set test mod in loader", () => loader.Mods.Value = new[] { testMod }); + AddAssert("test mod is displayed", () => (TestMod)loader.DisplayedMods.Single() == testMod); + } + + [Test] + public void TestMutedNotificationMasterVolume() + { + addVolumeSteps("master volume", () => audioManager.Volume.Value = 0, () => audioManager.Volume.IsDefault); + } + + [Test] + public void TestMutedNotificationTrackVolume() + { + addVolumeSteps("music volume", () => audioManager.VolumeTrack.Value = 0, () => audioManager.VolumeTrack.IsDefault); + } + + [Test] + public void TestMutedNotificationMuteButton() + { + addVolumeSteps("mute button", () => container.VolumeOverlay.IsMuted.Value = true, () => !container.VolumeOverlay.IsMuted.Value); + } + + /// + /// Created for avoiding copy pasting code for the same steps. + /// + /// What part of the volume system is checked + /// The action to be invoked to set the volume before loading + /// The function to be invoked and checked + private void addVolumeSteps(string volumeName, Action beforeLoad, Func assert) + { + AddStep("reset notification lock", () => sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce).Value = false); + + AddStep("load player", () => ResetPlayer(false, beforeLoad)); + AddUntilStep("wait for player", () => player?.LoadState == LoadState.Ready); + + AddAssert("check for notification", () => container.NotificationOverlay.UnreadCount.Value == 1); + AddStep("click notification", () => + { + var scrollContainer = (OsuScrollContainer)container.NotificationOverlay.Children.Last(); + var flowContainer = scrollContainer.Children.OfType>().First(); + var notification = flowContainer.First(); + + InputManager.MoveMouseTo(notification); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("check " + volumeName, assert); + + AddUntilStep("wait for player load", () => player.IsLoaded); + } + + [TestCase(true)] + [TestCase(false)] + public void TestEpilepsyWarning(bool warning) + { + AddStep("change epilepsy warning", () => epilepsyWarning = warning); + AddStep("load dummy beatmap", () => ResetPlayer(false)); + + AddUntilStep("wait for current", () => loader.IsCurrentScreen()); + + AddAssert($"epilepsy warning {(warning ? "present" : "absent")}", () => this.ChildrenOfType().Any() == warning); + + if (warning) + { + AddUntilStep("sound volume decreased", () => Beatmap.Value.Track.AggregateVolume.Value == 0.25); + AddUntilStep("sound volume restored", () => Beatmap.Value.Track.AggregateVolume.Value == 1); + } + } + + [Test] + public void TestEpilepsyWarningEarlyExit() + { + AddStep("set epilepsy warning", () => epilepsyWarning = true); + AddStep("load dummy beatmap", () => ResetPlayer(false)); + + AddUntilStep("wait for current", () => loader.IsCurrentScreen()); + + AddUntilStep("wait for epilepsy warning", () => loader.ChildrenOfType().Single().Alpha > 0); + AddStep("exit early", () => loader.Exit()); + + AddUntilStep("sound volume restored", () => Beatmap.Value.Track.AggregateVolume.Value == 1); + } + + private class TestPlayerLoaderContainer : Container + { + [Cached] + public readonly NotificationOverlay NotificationOverlay; + + [Cached] + public readonly VolumeOverlay VolumeOverlay; + + public TestPlayerLoaderContainer(IScreen screen) + { + RelativeSizeAxes = Axes.Both; + + OsuScreenStack stack; + + InternalChildren = new Drawable[] + { + stack = new OsuScreenStack + { + RelativeSizeAxes = Axes.Both, + }, + NotificationOverlay = new NotificationOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + VolumeOverlay = new VolumeOverlay + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + } + }; + + stack.Push(screen); + } + } + private class TestPlayerLoader : PlayerLoader { public new VisualSettings VisualSettings => base.VisualSettings; + public new Task DisposalTask => base.DisposalTask; + + public IReadOnlyList DisplayedMods => MetadataInfo.Mods.Value; + public TestPlayerLoader(Func createPlayer) : base(createPlayer) { @@ -123,17 +353,7 @@ namespace osu.Game.Tests.Visual.Gameplay public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; } - private class TestPlayer : Visual.TestPlayer - { - public new Bindable> Mods => base.Mods; - - public TestPlayer(bool allowPause = true, bool showResults = true) - : base(allowPause, showResults) - { - } - } - - protected class SlowLoadPlayer : Visual.TestPlayer + protected class SlowLoadPlayer : TestPlayer { public readonly ManualResetEventSlim AllowLoad = new ManualResetEventSlim(false); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs index 65b56319e8..8f767659c6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerReferenceLeaking.cs @@ -6,10 +6,11 @@ using osu.Framework.Lists; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Screens.Play; +using osu.Game.Storyboards; namespace osu.Game.Tests.Visual.Gameplay { - public class TestScenePlayerReferenceLeaking : AllPlayersTestScene + public class TestScenePlayerReferenceLeaking : TestSceneAllRulesetPlayers { private readonly WeakList workingWeakReferences = new WeakList(); @@ -42,9 +43,9 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap) + protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) { - var working = base.CreateWorkingBeatmap(beatmap); + var working = base.CreateWorkingBeatmap(beatmap, storyboard); workingWeakReferences.Add(working); return working; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs index 3fbce9d43c..3a71d4ca54 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplay.cs @@ -13,19 +13,20 @@ using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual.Gameplay { [Description("Player instantiated with a replay.")] - public class TestSceneReplay : AllPlayersTestScene + public class TestSceneReplay : TestSceneAllRulesetPlayers { protected override Player CreatePlayer(Ruleset ruleset) { var beatmap = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, Array.Empty()); - return new ScoreAccessibleReplayPlayer(ruleset.GetAutoplayMod().CreateReplayScore(beatmap)); + return new ScoreAccessibleReplayPlayer(ruleset.GetAutoplayMod()?.CreateReplayScore(beatmap)); } protected override void AddCheckSteps() { AddUntilStep("score above zero", () => ((ScoreAccessibleReplayPlayer)Player).ScoreProcessor.TotalScore.Value > 0); AddUntilStep("key counter counted keys", () => ((ScoreAccessibleReplayPlayer)Player).HUDOverlay.KeyCounter.Children.Any(kc => kc.CountPresses > 0)); + AddAssert("cannot fail", () => !((ScoreAccessibleReplayPlayer)Player).AllowFail); } private class ScoreAccessibleReplayPlayer : ReplayPlayer @@ -33,6 +34,8 @@ namespace osu.Game.Tests.Visual.Gameplay public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; public new HUDOverlay HUDOverlay => base.HUDOverlay; + public bool AllowFail => base.CheckModsAllowFailure(); + protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs index 0dfcda122f..1809332bce 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs @@ -5,23 +5,19 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets.Osu; using osu.Game.Scoring; -using osu.Game.Screens.Play; using osu.Game.Users; -using osuTK; -using System; -using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Game.Rulesets; +using osu.Game.Screens.Ranking; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] public class TestSceneReplayDownloadButton : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ReplayDownloadButton) - }; + [Resolved] + private RulesetStore rulesets { get; set; } private TestReplayDownloadButton downloadButton; @@ -32,6 +28,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep(@"locally available state", () => downloadButton.SetDownloadState(DownloadState.LocallyAvailable)); AddStep(@"not downloaded state", () => downloadButton.SetDownloadState(DownloadState.NotDownloaded)); createButton(false); + createButtonNoScore(); } private void createButton(bool withReplay) @@ -42,25 +39,39 @@ namespace osu.Game.Tests.Visual.Gameplay { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(80, 40), }; }); + + AddUntilStep("wait for load", () => downloadButton.IsLoaded); + } + + private void createButtonNoScore() + { + AddStep("create button with null score", () => + { + Child = downloadButton = new TestReplayDownloadButton(null) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + }); + + AddUntilStep("wait for load", () => downloadButton.IsLoaded); } private ScoreInfo getScoreInfo(bool replayAvailable) { return new APILegacyScoreInfo { - ID = 1, OnlineScoreID = 2553163309, - Ruleset = new OsuRuleset().RulesetInfo, + OnlineRulesetID = 0, Replay = replayAvailable, User = new User { Id = 39828, Username = @"WubWoofWolf", } - }; + }.CreateScoreInfo(rulesets); } private class TestReplayDownloadButton : ReplayDownloadButton diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs new file mode 100644 index 0000000000..b72960931f --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -0,0 +1,283 @@ +// 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.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; +using osu.Framework.Testing; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Replays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneReplayRecorder : OsuManualInputManagerTestScene + { + private TestRulesetInputManager playbackManager; + private TestRulesetInputManager recordingManager; + + private Replay replay; + + private TestReplayRecorder recorder; + + [Cached] + private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap()); + + [SetUp] + public void SetUp() => Schedule(() => + { + replay = new Replay(); + + Add(new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + recordingManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + Recorder = recorder = new TestReplayRecorder(replay) + { + ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Brown, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Recording", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + }, + new Drawable[] + { + playbackManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + ReplayInputHandler = new TestFramedReplayInputHandler(replay) + { + GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.DarkBlue, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Playback", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + } + } + }); + }); + + [Test] + 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); + } + + [Test] + public void TestHighFrameRate() + { + ScheduledDelegate moveFunction = null; + + 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("at least 60 frames recorded", () => replay.Frames.Count > 60); + } + + [Test] + public void TestLimitedFrameRate() + { + ScheduledDelegate moveFunction = null; + + AddStep("lower rate", () => recorder.RecordFrameRate = 2); + 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); + } + + [Test] + public void TestLimitedFrameRateWithImportantFrames() + { + ScheduledDelegate moveFunction = null; + + AddStep("lower rate", () => recorder.RecordFrameRate = 2); + AddStep("move to center", () => InputManager.MoveMouseTo(recordingManager.ScreenSpaceDrawQuad.Centre)); + AddStep("much move with press", () => moveFunction = Scheduler.AddDelayed(() => + { + InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(-1, 0)); + InputManager.Click(MouseButton.Left); + }, 10, true)); + AddWaitStep("move", 10); + AddStep("stop move", () => moveFunction.Cancel()); + AddAssert("at least 60 frames recorded", () => replay.Frames.Count > 60); + } + + protected override void Update() + { + base.Update(); + playbackManager?.ReplayInputHandler.SetFrameFromTime(Time.Current - 100); + } + + [TearDownSteps] + public void TearDown() + { + AddStep("stop recorder", () => recorder.Expire()); + } + + public class TestFramedReplayInputHandler : FramedReplayInputHandler + { + public TestFramedReplayInputHandler(Replay replay) + : base(replay) + { + } + + public override void CollectPendingInputs(List inputs) + { + inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) }); + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); + } + } + + public class TestInputConsumer : CompositeDrawable, IKeyBindingHandler + { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos); + + private readonly Box box; + + public TestInputConsumer() + { + Size = new Vector2(30); + + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + Position = e.MousePosition; + return base.OnMouseMove(e); + } + + public bool OnPressed(TestAction action) + { + box.Colour = Color4.White; + return true; + } + + public void OnReleased(TestAction action) + { + box.Colour = Color4.Black; + } + } + + public class TestRulesetInputManager : RulesetInputManager + { + public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + : base(ruleset, variant, unique) + { + } + + protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + => new TestKeyBindingContainer(); + + internal class TestKeyBindingContainer : KeyBindingContainer + { + public override IEnumerable DefaultKeyBindings => new[] + { + new KeyBinding(InputKey.MouseLeft, TestAction.Down), + }; + } + } + + public class TestReplayFrame : ReplayFrame + { + public Vector2 Position; + + public List Actions = new List(); + + public TestReplayFrame(double time, Vector2 position, params TestAction[] actions) + : base(time) + { + Position = position; + Actions.AddRange(actions); + } + } + + public enum TestAction + { + Down, + } + + internal class TestReplayRecorder : ReplayRecorder + { + public TestReplayRecorder(Replay target) + : base(target) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) + => new TestReplayFrame(Time.Current, mousePosition, actions.ToArray()); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs new file mode 100644 index 0000000000..6872b6a669 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs @@ -0,0 +1,217 @@ +// 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.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Replays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneReplayRecording : OsuTestScene + { + private readonly TestRulesetInputManager playbackManager; + + private readonly TestRulesetInputManager recordingManager; + + [Cached] + private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap()); + + public TestSceneReplayRecording() + { + Replay replay = new Replay(); + + Add(new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + recordingManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + Recorder = new TestReplayRecorder(replay) + { + ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos) + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Brown, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Recording", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestConsumer() + } + }, + } + }, + new Drawable[] + { + playbackManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + ReplayInputHandler = new TestFramedReplayInputHandler(replay) + { + GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.DarkBlue, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Playback", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestConsumer() + } + }, + } + } + } + }); + } + + protected override void Update() + { + base.Update(); + + playbackManager.ReplayInputHandler.SetFrameFromTime(Time.Current - 500); + } + } + + public class TestFramedReplayInputHandler : FramedReplayInputHandler + { + public TestFramedReplayInputHandler(Replay replay) + : base(replay) + { + } + + public override void CollectPendingInputs(List inputs) + { + inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) }); + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); + } + } + + public class TestConsumer : CompositeDrawable, IKeyBindingHandler + { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos); + + private readonly Box box; + + public TestConsumer() + { + Size = new Vector2(30); + + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + Position = e.MousePosition; + return base.OnMouseMove(e); + } + + public bool OnPressed(TestAction action) + { + box.Colour = Color4.White; + return true; + } + + public void OnReleased(TestAction action) + { + box.Colour = Color4.Black; + } + } + + public class TestRulesetInputManager : RulesetInputManager + { + public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + : base(ruleset, variant, unique) + { + } + + protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + => new TestKeyBindingContainer(); + + internal class TestKeyBindingContainer : KeyBindingContainer + { + public override IEnumerable DefaultKeyBindings => new[] + { + new KeyBinding(InputKey.MouseLeft, TestAction.Down), + }; + } + } + + public class TestReplayFrame : ReplayFrame + { + public Vector2 Position; + + public List Actions = new List(); + + public TestReplayFrame(double time, Vector2 position, params TestAction[] actions) + : base(time) + { + Position = position; + Actions.AddRange(actions); + } + } + + public enum TestAction + { + Down, + } + + internal class TestReplayRecorder : ReplayRecorder + { + public TestReplayRecorder(Replay target) + : base(target) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) => + new TestReplayFrame(Time.Current, mousePosition, actions.ToArray()); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs index 944480243d..f8fab784cc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.PlayerSettings; @@ -20,6 +21,7 @@ namespace osu.Game.Tests.Visual.Gameplay { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, + State = { Value = Visibility.Visible } }); Add(container = new ExampleContainer()); @@ -46,7 +48,10 @@ namespace osu.Game.Tests.Visual.Gameplay private class ExampleContainer : PlayerSettingsGroup { - protected override string Title => @"example"; + public ExampleContainer() + : base("example") + { + } } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneResults.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneResults.cs deleted file mode 100644 index f3c8f89db7..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneResults.cs +++ /dev/null @@ -1,67 +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.Framework.Allocation; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Screens.Play; -using osu.Game.Screens.Ranking; -using osu.Game.Screens.Ranking.Pages; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Gameplay -{ - [TestFixture] - public class TestSceneResults : ScreenTestScene - { - private BeatmapManager beatmaps; - - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ScoreInfo), - typeof(Results), - typeof(ResultsPage), - typeof(ScoreResultsPage), - typeof(LocalLeaderboardPage) - }; - - [BackgroundDependencyLoader] - private void load(BeatmapManager beatmaps) - { - this.beatmaps = beatmaps; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - var beatmapInfo = beatmaps.QueryBeatmap(b => b.RulesetID == 0); - if (beatmapInfo != null) - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo); - - LoadScreen(new SoloResults(new ScoreInfo - { - TotalScore = 2845370, - Accuracy = 0.98, - MaxCombo = 123, - Rank = ScoreRank.A, - Date = DateTimeOffset.Now, - Statistics = new Dictionary - { - { HitResult.Great, 50 }, - { HitResult.Good, 20 }, - { HitResult.Meh, 50 }, - { HitResult.Miss, 1 } - }, - User = new User - { - Username = "peppy", - } - })); - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScoreCounter.cs deleted file mode 100644 index 080a287b48..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScoreCounter.cs +++ /dev/null @@ -1,107 +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.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.MathUtils; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Screens.Play.HUD; -using osuTK; - -namespace osu.Game.Tests.Visual.Gameplay -{ - [TestFixture] - public class TestSceneScoreCounter : OsuTestScene - { - public TestSceneScoreCounter() - { - int numerator = 0, denominator = 0; - - ScoreCounter score = new ScoreCounter(7) - { - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - TextSize = 40, - Margin = new MarginPadding(20), - }; - Add(score); - - ComboCounter comboCounter = new StandardComboCounter - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Margin = new MarginPadding(10), - TextSize = 40, - }; - Add(comboCounter); - - PercentageCounter accuracyCounter = new PercentageCounter - { - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - Position = new Vector2(-20, 60), - }; - Add(accuracyCounter); - - StarCounter stars = new StarCounter - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Position = new Vector2(20, -160), - CountStars = 5, - }; - Add(stars); - - SpriteText starsLabel = new OsuSpriteText - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Position = new Vector2(20, -190), - Text = stars.CountStars.ToString("0.00"), - }; - Add(starsLabel); - - AddStep(@"Reset all", delegate - { - score.Current.Value = 0; - comboCounter.Current.Value = 0; - numerator = denominator = 0; - accuracyCounter.SetFraction(0, 0); - stars.CountStars = 0; - starsLabel.Text = stars.CountStars.ToString("0.00"); - }); - - AddStep(@"Hit! :D", delegate - { - score.Current.Value += 300 + (ulong)(300.0 * (comboCounter.Current.Value > 0 ? comboCounter.Current.Value - 1 : 0) / 25.0); - comboCounter.Increment(); - numerator++; - denominator++; - accuracyCounter.SetFraction(numerator, denominator); - }); - - AddStep(@"miss...", delegate - { - comboCounter.Current.Value = 0; - denominator++; - accuracyCounter.SetFraction(numerator, denominator); - }); - - AddStep(@"Alter stars", delegate - { - stars.CountStars = RNG.NextSingle() * (stars.StarCount + 1); - starsLabel.Text = stars.CountStars.ToString("0.00"); - }); - - AddStep(@"Stop counters", delegate - { - score.StopRolling(); - comboCounter.StopRolling(); - accuracyCounter.StopRolling(); - stars.StopAnimation(); - }); - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs index 0a9cdc6a8e..2f15e549f7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs @@ -3,37 +3,44 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Timing; -using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osuTK; +using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] public class TestSceneScrollingHitObjects : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(Playfield) }; - [Cached(typeof(IReadOnlyList))] private IReadOnlyList mods { get; set; } = Array.Empty(); + private const int time_range = 5000; + private const int spawn_rate = time_range / 10; + private readonly ScrollingTestContainer[] scrollContainers = new ScrollingTestContainer[4]; private readonly TestPlayfield[] playfields = new TestPlayfield[4]; + private ScheduledDelegate hitObjectSpawnDelegate; - public TestSceneScrollingHitObjects() + [SetUp] + public void Setup() => Schedule(() => { - Add(new GridContainer + Child = new GridContainer { RelativeSizeAxes = Axes.Both, Content = new[] @@ -43,48 +50,119 @@ namespace osu.Game.Tests.Visual.Gameplay scrollContainers[0] = new ScrollingTestContainer(ScrollingDirection.Up) { RelativeSizeAxes = Axes.Both, - Child = playfields[0] = new TestPlayfield() + Child = playfields[0] = new TestPlayfield(), + TimeRange = time_range }, - scrollContainers[1] = new ScrollingTestContainer(ScrollingDirection.Up) + scrollContainers[1] = new ScrollingTestContainer(ScrollingDirection.Down) { RelativeSizeAxes = Axes.Both, - Child = playfields[1] = new TestPlayfield() + Child = playfields[1] = new TestPlayfield(), + TimeRange = time_range }, }, new Drawable[] { - scrollContainers[2] = new ScrollingTestContainer(ScrollingDirection.Up) + scrollContainers[2] = new ScrollingTestContainer(ScrollingDirection.Left) { RelativeSizeAxes = Axes.Both, - Child = playfields[2] = new TestPlayfield() + Child = playfields[2] = new TestPlayfield(), + TimeRange = time_range }, - scrollContainers[3] = new ScrollingTestContainer(ScrollingDirection.Up) + scrollContainers[3] = new ScrollingTestContainer(ScrollingDirection.Right) { RelativeSizeAxes = Axes.Both, - Child = playfields[3] = new TestPlayfield() + Child = playfields[3] = new TestPlayfield(), + TimeRange = time_range } } } - }); + }; - AddStep("Constant scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Constant)); - AddStep("Overlapping scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Overlapping)); - AddStep("Sequential scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Sequential)); + hitObjectSpawnDelegate?.Cancel(); + }); - AddSliderStep("Time range", 100, 10000, 5000, v => scrollContainers.ForEach(c => c.TimeRange = v)); - AddStep("Add control point", () => addControlPoint(Time.Current + 5000)); - } - - protected override void LoadComplete() + private void setUpHitObjects() => AddStep("set up hit objects", () => { - base.LoadComplete(); - scrollContainers.ForEach(c => c.ControlPoints.Add(new MultiplierControlPoint(0))); - for (int i = 0; i <= 5000; i += 1000) + for (int i = spawn_rate / 2; i <= time_range; i += spawn_rate) addHitObject(Time.Current + i); - Scheduler.AddDelayed(() => addHitObject(Time.Current + 5000), 1000, true); + hitObjectSpawnDelegate = Scheduler.AddDelayed(() => addHitObject(Time.Current + time_range), spawn_rate, true); + }); + + private IList testControlPoints => new List + { + new MultiplierControlPoint(time_range) { DifficultyPoint = { SpeedMultiplier = 1.25 } }, + new MultiplierControlPoint(1.5 * time_range) { DifficultyPoint = { SpeedMultiplier = 1 } }, + new MultiplierControlPoint(2 * time_range) { DifficultyPoint = { SpeedMultiplier = 1.5 } } + }; + + [Test] + public void TestScrollAlgorithms() + { + setUpHitObjects(); + + AddStep("constant scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Constant)); + AddStep("overlapping scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Overlapping)); + AddStep("sequential scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Sequential)); + + AddSliderStep("time range", 100, 10000, time_range, v => scrollContainers.Where(c => c != null).ForEach(c => c.TimeRange = v)); + + AddStep("add control points", () => addControlPoints(testControlPoints, Time.Current)); + } + + [Test] + public void TestConstantScrollLifetime() + { + setUpHitObjects(); + + AddStep("set constant scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Constant)); + // scroll container time range must be less than the rate of spawning hitobjects + // otherwise the hitobjects will spawn already partly visible on screen and look wrong + AddStep("set time range", () => scrollContainers.ForEach(c => c.TimeRange = time_range / 2.0)); + } + + [Test] + public void TestSequentialScrollLifetime() + { + setUpHitObjects(); + + AddStep("set sequential scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Sequential)); + AddStep("set time range", () => scrollContainers.ForEach(c => c.TimeRange = time_range / 2.0)); + AddStep("add control points", () => addControlPoints(testControlPoints, Time.Current)); + } + + [Test] + public void TestSlowSequentialScroll() + { + AddStep("set sequential scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Sequential)); + AddStep("set time range", () => scrollContainers.ForEach(c => c.TimeRange = time_range)); + AddStep("add control points", () => addControlPoints( + new List + { + new MultiplierControlPoint { Velocity = 0.1 } + }, + Time.Current + time_range)); + + // All of the hit objects added below should be immediately visible on screen + AddStep("add hit objects", () => + { + for (int i = 0; i < 20; ++i) + { + addHitObject(Time.Current + time_range * (2 + 0.1 * i)); + } + }); + } + + [Test] + public void TestOverlappingScrollLifetime() + { + setUpHitObjects(); + + AddStep("set overlapping scroll", () => setScrollAlgorithm(ScrollVisualisationMethod.Overlapping)); + AddStep("set time range", () => scrollContainers.ForEach(c => c.TimeRange = time_range / 2.0)); + AddStep("add control points", () => addControlPoints(testControlPoints, Time.Current)); } private void addHitObject(double time) @@ -98,28 +176,27 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private void addControlPoint(double time) + private TestDrawableControlPoint createDrawablePoint(TestPlayfield playfield, double t) { - scrollContainers.ForEach(c => + var obj = new TestDrawableControlPoint(playfield.Direction, t); + setAnchor(obj, playfield); + return obj; + } + + private void addControlPoints(IList controlPoints, double sequenceStartTime) + { + controlPoints.ForEach(point => point.StartTime += sequenceStartTime); + + scrollContainers.ForEach(container => { - c.ControlPoints.Add(new MultiplierControlPoint(time) { DifficultyPoint = { SpeedMultiplier = 3 } }); - c.ControlPoints.Add(new MultiplierControlPoint(time + 2000) { DifficultyPoint = { SpeedMultiplier = 2 } }); - c.ControlPoints.Add(new MultiplierControlPoint(time + 3000) { DifficultyPoint = { SpeedMultiplier = 1 } }); + container.ControlPoints.AddRange(controlPoints); }); - playfields.ForEach(p => + foreach (var playfield in playfields) { - TestDrawableControlPoint createDrawablePoint(double t) - { - var obj = new TestDrawableControlPoint(p.Direction, t); - setAnchor(obj, p); - return obj; - } - - p.Add(createDrawablePoint(time)); - p.Add(createDrawablePoint(time + 2000)); - p.Add(createDrawablePoint(time + 3000)); - }); + foreach (var controlPoint in controlPoints) + playfield.Add(createDrawablePoint(playfield, controlPoint.StartTime)); + } } private void setAnchor(DrawableHitObject obj, TestPlayfield playfield) @@ -174,7 +251,7 @@ namespace osu.Game.Tests.Visual.Gameplay private class TestDrawableControlPoint : DrawableHitObject { public TestDrawableControlPoint(ScrollingDirection direction, double time) - : base(new HitObject { StartTime = time }) + : base(new HitObject { StartTime = time, HitWindows = HitWindows.Empty }) { Origin = Anchor.Centre; @@ -200,25 +277,23 @@ namespace osu.Game.Tests.Visual.Gameplay break; } } - - protected override void UpdateState(ArmedState state) - { - } } private class TestDrawableHitObject : DrawableHitObject { public TestDrawableHitObject(double time) - : base(new HitObject { StartTime = time }) + : base(new HitObject { StartTime = time, HitWindows = HitWindows.Empty }) { - Origin = Anchor.Centre; + Origin = Anchor.Custom; + OriginPosition = new Vector2(75 / 4.0f); + AutoSizeAxes = Axes.Both; - AddInternal(new Box { Size = new Vector2(75) }); - } - - protected override void UpdateState(ArmedState state) - { + AddInternal(new Box + { + Size = new Vector2(75), + Colour = new Color4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1) + }); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinReloadable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinReloadable.cs deleted file mode 100644 index c7a0df6e9f..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinReloadable.cs +++ /dev/null @@ -1,145 +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.Framework.Audio.Sample; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Textures; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Skinning; -using osuTK.Graphics; - -namespace osu.Game.Tests.Visual.Gameplay -{ - public class TestSceneSkinReloadable : OsuTestScene - { - [Test] - public void TestInitialLoad() - { - var secondarySource = new SecondarySource(); - SkinConsumer consumer = null; - - AddStep("setup layout", () => - { - Child = new SkinSourceContainer - { - RelativeSizeAxes = Axes.Both, - Child = new LocalSkinOverrideContainer(secondarySource) - { - RelativeSizeAxes = Axes.Both, - Child = consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation"), source => true) - } - }; - }); - - AddAssert("consumer using override source", () => consumer.Drawable is SecondarySourceBox); - AddAssert("skinchanged only called once", () => consumer.SkinChangedCount == 1); - } - - [Test] - public void TestOverride() - { - var secondarySource = new SecondarySource(); - - SkinConsumer consumer = null; - Container target = null; - - AddStep("setup layout", () => - { - Child = new SkinSourceContainer - { - RelativeSizeAxes = Axes.Both, - Child = target = new LocalSkinOverrideContainer(secondarySource) - { - RelativeSizeAxes = Axes.Both, - } - }; - }); - - AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation"), source => true))); - AddAssert("consumer using override source", () => consumer.Drawable is SecondarySourceBox); - AddAssert("skinchanged only called once", () => consumer.SkinChangedCount == 1); - } - - private class NamedBox : Container - { - public NamedBox(string name) - { - Children = new Drawable[] - { - new Box - { - Colour = Color4.Black, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Font = OsuFont.Default.With(size: 40), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = name - } - }; - } - } - - private class SkinConsumer : SkinnableDrawable - { - public new Drawable Drawable => base.Drawable; - public int SkinChangedCount { get; private set; } - - public SkinConsumer(string name, Func defaultImplementation, Func allowFallback = null, bool restrictSize = true) - : base(name, defaultImplementation, allowFallback, restrictSize) - { - } - - protected override void SkinChanged(ISkinSource skin, bool allowFallback) - { - base.SkinChanged(skin, allowFallback); - SkinChangedCount++; - } - } - - private class BaseSourceBox : NamedBox - { - public BaseSourceBox() - : base("Base Source") - { - } - } - - private class SecondarySourceBox : NamedBox - { - public SecondarySourceBox() - : base("Secondary Source") - { - } - } - - private class SecondarySource : ISkin - { - public Drawable GetDrawableComponent(string componentName) => new SecondarySourceBox(); - - public Texture GetTexture(string componentName) => throw new NotImplementedException(); - - public SampleChannel GetSample(string sampleName) => throw new NotImplementedException(); - - public TValue GetValue(Func query) where TConfiguration : SkinConfiguration => throw new NotImplementedException(); - } - - private class SkinSourceContainer : Container, ISkin - { - public Drawable GetDrawableComponent(string componentName) => new BaseSourceBox(); - - public Texture GetTexture(string componentName) => throw new NotImplementedException(); - - public SampleChannel GetSample(string sampleName) => throw new NotImplementedException(); - - public TValue GetValue(Func query) where TConfiguration : SkinConfiguration => throw new NotImplementedException(); - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs new file mode 100644 index 0000000000..709929dcb0 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.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 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 TestSceneSkinnableAccuracyCounter : SkinnableTestScene + { + private IEnumerable accuracyCounters => CreatedDrawables.OfType(); + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Create combo counters", () => SetContents(() => + { + var accuracyCounter = new SkinnableAccuracyCounter(); + + accuracyCounter.Current.Value = 1; + + return accuracyCounter; + })); + } + + [Test] + public void TestChangingAccuracy() + { + AddStep(@"Reset all", delegate + { + foreach (var s in accuracyCounters) + s.Current.Value = 1; + }); + + AddStep(@"Hit! :D", delegate + { + foreach (var s in accuracyCounters) + s.Current.Value -= 0.023f; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs new file mode 100644 index 0000000000..bed48f3d86 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs @@ -0,0 +1,347 @@ +// 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 System.Linq; +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.Game.Audio; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinnableDrawable : OsuTestScene + { + [Test] + public void TestConfineScaleDown() + { + FillFlowContainer fill = null; + + AddStep("setup layout larger source", () => + { + Child = new SkinProvidingContainer(new SizedSource(50)) + { + RelativeSizeAxes = Axes.Both, + Child = fill = new FillFlowContainer + { + Size = new Vector2(30), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(10), + Children = new[] + { + new ExposedSkinnableDrawable("default", _ => new DefaultBox(), _ => true), + new ExposedSkinnableDrawable("available", _ => new DefaultBox(), _ => true), + new ExposedSkinnableDrawable("available", _ => new DefaultBox(), _ => true, ConfineMode.NoScaling) + } + }, + }; + }); + + AddAssert("check sizes", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 30, 30, 50 })); + AddStep("adjust scale", () => fill.Scale = new Vector2(2)); + AddAssert("check sizes unchanged by scale", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 30, 30, 50 })); + } + + [Test] + public void TestConfineScaleUp() + { + FillFlowContainer fill = null; + + AddStep("setup layout larger source", () => + { + Child = new SkinProvidingContainer(new SizedSource(30)) + { + RelativeSizeAxes = Axes.Both, + Child = fill = new FillFlowContainer + { + Size = new Vector2(50), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(10), + Children = new[] + { + new ExposedSkinnableDrawable("default", _ => new DefaultBox(), _ => true), + new ExposedSkinnableDrawable("available", _ => new DefaultBox(), _ => true, ConfineMode.ScaleToFit), + new ExposedSkinnableDrawable("available", _ => new DefaultBox(), _ => true, ConfineMode.NoScaling) + } + }, + }; + }); + + AddAssert("check sizes", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 50, 50, 30 })); + AddStep("adjust scale", () => fill.Scale = new Vector2(2)); + AddAssert("check sizes unchanged by scale", () => fill.Children.Select(c => c.Drawable.DrawWidth).SequenceEqual(new float[] { 50, 50, 30 })); + } + + [Test] + public void TestInitialLoad() + { + var secondarySource = new SecondarySource(); + SkinConsumer consumer = null; + + AddStep("setup layout", () => + { + Child = new SkinSourceContainer + { + RelativeSizeAxes = Axes.Both, + Child = new SkinProvidingContainer(secondarySource) + { + RelativeSizeAxes = Axes.Both, + Child = consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation"), source => true) + } + }; + }); + + AddAssert("consumer using override source", () => consumer.Drawable is SecondarySourceBox); + AddAssert("skinchanged only called once", () => consumer.SkinChangedCount == 1); + } + + [Test] + public void TestOverride() + { + var secondarySource = new SecondarySource(); + + SkinConsumer consumer = null; + Container target = null; + + AddStep("setup layout", () => + { + Child = new SkinSourceContainer + { + RelativeSizeAxes = Axes.Both, + Child = target = new SkinProvidingContainer(secondarySource) + { + RelativeSizeAxes = Axes.Both, + } + }; + }); + + AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation"), source => true))); + AddAssert("consumer using override source", () => consumer.Drawable is SecondarySourceBox); + AddAssert("skinchanged only called once", () => consumer.SkinChangedCount == 1); + } + + [Test] + public void TestSwitchOff() + { + SkinConsumer consumer = null; + SwitchableSkinProvidingContainer target = null; + + AddStep("setup layout", () => + { + Child = new SkinSourceContainer + { + RelativeSizeAxes = Axes.Both, + Child = target = new SwitchableSkinProvidingContainer(new SecondarySource()) + { + RelativeSizeAxes = Axes.Both, + } + }; + }); + + AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation"), source => true))); + AddAssert("consumer using override source", () => consumer.Drawable is SecondarySourceBox); + AddStep("disable", () => target.Disable()); + AddAssert("consumer using base source", () => consumer.Drawable is BaseSourceBox); + } + + private class SwitchableSkinProvidingContainer : SkinProvidingContainer + { + private bool allow = true; + + protected override bool AllowDrawableLookup(ISkinComponent component) => allow; + + public void Disable() + { + allow = false; + TriggerSourceChanged(); + } + + public SwitchableSkinProvidingContainer(ISkin skin) + : base(skin) + { + } + } + + private class ExposedSkinnableDrawable : SkinnableDrawable + { + public new Drawable Drawable => base.Drawable; + + public ExposedSkinnableDrawable(string name, Func defaultImplementation, Func allowFallback = null, + ConfineMode confineMode = ConfineMode.ScaleToFit) + : base(new TestSkinComponent(name), defaultImplementation, allowFallback, confineMode) + { + } + } + + private class DefaultBox : DrawWidthBox + { + public DefaultBox() + { + RelativeSizeAxes = Axes.Both; + } + } + + private class DrawWidthBox : Container + { + private readonly OsuSpriteText text; + + public DrawWidthBox() + { + Children = new Drawable[] + { + new Box + { + Colour = Color4.Gray, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + text.Text = DrawWidth.ToString(CultureInfo.InvariantCulture); + } + } + + private class NamedBox : Container + { + public NamedBox(string name) + { + Children = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Font = OsuFont.Default.With(size: 40), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = name + } + }; + } + } + + private class SkinConsumer : SkinnableDrawable + { + public new Drawable Drawable => base.Drawable; + public int SkinChangedCount { get; private set; } + + public SkinConsumer(string name, Func defaultImplementation, Func allowFallback = null) + : base(new TestSkinComponent(name), defaultImplementation, allowFallback) + { + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + SkinChangedCount++; + } + } + + private class BaseSourceBox : NamedBox + { + public BaseSourceBox() + : base("Base Source") + { + } + } + + private class SecondarySourceBox : NamedBox + { + public SecondarySourceBox() + : base("Secondary Source") + { + } + } + + private class SizedSource : ISkin + { + private readonly float size; + + public SizedSource(float size) + { + this.size = size; + } + + public Drawable GetDrawableComponent(ISkinComponent componentName) => + componentName.LookupName == "available" + ? new DrawWidthBox + { + Colour = Color4.Yellow, + Size = new Vector2(size) + } + : null; + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); + + public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + + public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + } + + private class SecondarySource : ISkin + { + public Drawable GetDrawableComponent(ISkinComponent componentName) => new SecondarySourceBox(); + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); + + public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + + public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + } + + [Cached(typeof(ISkinSource))] + private class SkinSourceContainer : Container, ISkinSource + { + public Drawable GetDrawableComponent(ISkinComponent componentName) => new BaseSourceBox(); + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => throw new NotImplementedException(); + + public SampleChannel GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + + public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + + public event Action SourceChanged + { + add { } + remove { } + } + } + + private class TestSkinComponent : ISkinComponent + { + public TestSkinComponent(string name) + { + LookupName = name; + } + + public string ComponentGroup => string.Empty; + + public string LookupName { get; } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs new file mode 100644 index 0000000000..fec1610160 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.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 System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinnableHUDOverlay : SkinnableTestScene + { + private HUDOverlay hudOverlay; + + private IEnumerable hudOverlays => CreatedDrawables.OfType(); + + // best way to check without exposing. + private Drawable hideTarget => hudOverlay.KeyCounter; + private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); + + [Resolved] + private OsuConfigManager config { get; set; } + + [Test] + public void TestComboCounterIncrementing() + { + createNew(); + + AddRepeatStep("increase combo", () => + { + foreach (var hud in hudOverlays) + hud.ComboCounter.Current.Value++; + }, 10); + + AddStep("reset combo", () => + { + foreach (var hud in hudOverlays) + hud.ComboCounter.Current.Value = 0; + }); + } + + [Test] + public void TestFadesInOnLoadComplete() + { + float? initialAlpha = null; + + createNew(h => h.OnLoadComplete += _ => initialAlpha = hideTarget.Alpha); + AddUntilStep("wait for load", () => hudOverlay.IsAlive); + AddAssert("initial alpha was less than 1", () => initialAlpha < 1); + } + + [Test] + public void TestHideExternally() + { + createNew(); + + AddStep("set showhud false", () => hudOverlays.ForEach(h => h.ShowHud.Value = false)); + + AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); + AddAssert("pause button is still visible", () => hudOverlay.HoldToQuit.IsPresent); + + // Key counter flow container should not be affected by this, only the key counter display will be hidden as checked above. + AddAssert("key counter flow not affected", () => keyCounterFlow.IsPresent); + } + + private void createNew(Action action = null) + { + AddStep("create overlay", () => + { + SetContents(() => + { + hudOverlay = new HUDOverlay(null, null, 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; + }); + }); + } + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs new file mode 100644 index 0000000000..e1b0820662 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.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 System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +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.Screens.Play; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinnableHealthDisplay : SkinnableTestScene + { + private IEnumerable healthDisplays => CreatedDrawables.OfType(); + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Create health displays", () => + { + SetContents(() => new SkinnableHealthDisplay()); + }); + AddStep(@"Reset all", delegate + { + foreach (var s in healthDisplays) + s.Current.Value = 1; + }); + } + + [Test] + public void TestHealthDisplayIncrementing() + { + AddRepeatStep(@"decrease hp", delegate + { + foreach (var healthDisplay in healthDisplays) + healthDisplay.Current.Value -= 0.08f; + }, 10); + + AddRepeatStep(@"increase hp without flash", delegate + { + foreach (var healthDisplay in healthDisplays) + healthDisplay.Current.Value += 0.1f; + }, 3); + + AddRepeatStep(@"increase hp with flash", delegate + { + foreach (var healthDisplay in healthDisplays) + { + healthDisplay.Current.Value += 0.1f; + healthDisplay.Flash(new JudgementResult(null, new OsuJudgement())); + } + }, 3); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs new file mode 100644 index 0000000000..e212ceeba7 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.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 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.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinnableScoreCounter : SkinnableTestScene + { + private IEnumerable scoreCounters => CreatedDrawables.OfType(); + + 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; + })); + } + + [Test] + public void TestScoreCounterIncrementing() + { + AddStep(@"Reset all", delegate + { + foreach (var s in scoreCounters) + s.Current.Value = 0; + }); + + AddStep(@"Hit! :D", delegate + { + foreach (var s in scoreCounters) + s.Current.Value += 300; + }); + } + + [Test] + public void TestVeryLargeScore() + { + AddStep("set large score", () => scoreCounters.ForEach(counter => counter.Current.Value = 1_000_000_000)); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs new file mode 100644 index 0000000000..fc0cda2c1f --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs @@ -0,0 +1,167 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Audio; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.Screens.Play; +using osu.Game.Skinning; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinnableSound : OsuTestScene + { + private TestSkinSourceContainer skinSource; + private PausableSkinnableSound skinnableSound; + + [SetUp] + public void SetUpSteps() + { + AddStep("setup hierarchy", () => + { + Children = new Drawable[] + { + skinSource = new TestSkinSourceContainer + { + RelativeSizeAxes = Axes.Both, + Child = skinnableSound = new PausableSkinnableSound(new SampleInfo("Gameplay/normal-sliderslide")) + }, + }; + }); + } + + [Test] + public void TestStoppedSoundDoesntResumeAfterPause() + { + DrawableSample sample = null; + AddStep("start sample with looping", () => + { + sample = skinnableSound.ChildrenOfType().First(); + + skinnableSound.Looping = true; + skinnableSound.Play(); + }); + + AddUntilStep("wait for sample to start playing", () => sample.Playing); + + AddStep("stop sample", () => skinnableSound.Stop()); + + AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + + AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); + + AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); + + AddWaitStep("wait a bit", 5); + AddAssert("sample not playing", () => !sample.Playing); + } + + [Test] + public void TestLoopingSoundResumesAfterPause() + { + DrawableSample sample = null; + AddStep("start sample with looping", () => + { + skinnableSound.Looping = true; + skinnableSound.Play(); + sample = skinnableSound.ChildrenOfType().First(); + }); + + AddUntilStep("wait for sample to start playing", () => sample.Playing); + + AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); + AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + + AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); + AddUntilStep("wait for sample to start playing", () => sample.Playing); + } + + [Test] + public void TestNonLoopingStopsWithPause() + { + DrawableSample sample = null; + AddStep("start sample", () => + { + skinnableSound.Play(); + sample = skinnableSound.ChildrenOfType().First(); + }); + + AddAssert("sample playing", () => sample.Playing); + + AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); + + AddUntilStep("sample not playing", () => !sample.Playing); + + AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); + + AddAssert("sample not playing", () => !sample.Playing); + AddAssert("sample not playing", () => !sample.Playing); + AddAssert("sample not playing", () => !sample.Playing); + } + + [Test] + public void TestSkinChangeDoesntPlayOnPause() + { + DrawableSample sample = null; + AddStep("start sample", () => + { + skinnableSound.Play(); + sample = skinnableSound.ChildrenOfType().Single(); + }); + + AddAssert("sample playing", () => sample.Playing); + + AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); + AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + + AddStep("trigger skin change", () => skinSource.TriggerSourceChanged()); + + AddAssert("retrieve and ensure current sample is different", () => + { + DrawableSample oldSample = sample; + sample = skinnableSound.ChildrenOfType().Single(); + return sample != oldSample; + }); + + AddAssert("new sample stopped", () => !sample.Playing); + AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); + + AddWaitStep("wait a bit", 5); + AddAssert("new sample not played", () => !sample.Playing); + } + + [Cached(typeof(ISkinSource))] + [Cached(typeof(ISamplePlaybackDisabler))] + private class TestSkinSourceContainer : Container, ISkinSource, ISamplePlaybackDisabler + { + [Resolved] + private ISkinSource source { get; set; } + + public event Action SourceChanged; + + public Bindable SamplePlaybackDisabled { get; } = new Bindable(); + + IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => SamplePlaybackDisabled; + + public Drawable GetDrawableComponent(ISkinComponent component) => source?.GetDrawableComponent(component); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => source?.GetTexture(componentName, wrapModeS, wrapModeT); + public SampleChannel GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); + public IBindable GetConfig(TLookup lookup) => source?.GetConfig(lookup); + + public void TriggerSourceChanged() + { + SourceChanged?.Invoke(); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs index b152c21454..841722a8f1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkipOverlay.cs @@ -1,11 +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 NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Timing; +using osu.Game.Rulesets.Osu; using osu.Game.Screens.Play; using osuTK; using osuTK.Input; @@ -13,49 +12,64 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] - public class TestSceneSkipOverlay : ManualInputManagerTestScene + public class TestSceneSkipOverlay : OsuManualInputManagerTestScene { - private SkipOverlay skip; + private TestSkipOverlay skip; private int requestCount; + private double increment; + + private GameplayClockContainer gameplayClockContainer; + private GameplayClock gameplayClock; + + private const double skip_time = 6000; + [SetUp] public void SetUp() => Schedule(() => { requestCount = 0; - Child = new Container + increment = skip_time; + + var working = CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)); + working.LoadTrack(); + + Child = gameplayClockContainer = new GameplayClockContainer(working, 0) { RelativeSizeAxes = Axes.Both, - Clock = new FramedOffsetClock(Clock) - { - Offset = -Clock.CurrentTime, - }, Children = new Drawable[] { - skip = new SkipOverlay(6000) + skip = new TestSkipOverlay(skip_time) { - RequestSeek = _ => requestCount++ + RequestSkip = () => + { + requestCount++; + gameplayClockContainer.Seek(gameplayClock.CurrentTime + increment); + } } }, }; + + gameplayClockContainer.Start(); + gameplayClock = gameplayClockContainer.GameplayClock; }); [Test] public void TestFadeOnIdle() { AddStep("move mouse", () => InputManager.MoveMouseTo(Vector2.Zero)); - AddUntilStep("fully visible", () => skip.Children.First().Alpha == 1); - AddUntilStep("wait for fade", () => skip.Children.First().Alpha < 1); + AddUntilStep("fully visible", () => skip.FadingContent.Alpha == 1); + AddUntilStep("wait for fade", () => skip.FadingContent.Alpha < 1); AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("fully visible", () => skip.Children.First().Alpha == 1); - AddUntilStep("wait for fade", () => skip.Children.First().Alpha < 1); + AddUntilStep("fully visible", () => skip.FadingContent.Alpha == 1); + AddUntilStep("wait for fade", () => skip.FadingContent.Alpha < 1); } [Test] public void TestClickableAfterFade() { AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); - AddUntilStep("wait for fade", () => skip.Children.First().Alpha == 0); + AddUntilStep("wait for fade", () => skip.FadingContent.Alpha == 0); AddStep("click", () => InputManager.Click(MouseButton.Left)); checkRequestCount(1); } @@ -64,25 +78,53 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestClickOnlyActuatesOnce() { AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); - AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("click", () => + { + increment = skip_time - gameplayClock.CurrentTime - GameplayClockContainer.MINIMUM_SKIP_TIME / 2; + InputManager.Click(MouseButton.Left); + }); AddStep("click", () => InputManager.Click(MouseButton.Left)); AddStep("click", () => InputManager.Click(MouseButton.Left)); AddStep("click", () => InputManager.Click(MouseButton.Left)); checkRequestCount(1); } + [Test] + public void TestClickOnlyActuatesMultipleTimes() + { + AddStep("set increment lower", () => increment = 3000); + AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + checkRequestCount(2); + } + [Test] public void TestDoesntFadeOnMouseDown() { AddStep("move mouse", () => InputManager.MoveMouseTo(skip.ScreenSpaceDrawQuad.Centre)); AddStep("button down", () => InputManager.PressButton(MouseButton.Left)); - AddUntilStep("wait for overlay disapper", () => !skip.IsAlive); - AddAssert("ensure button didn't disappear", () => skip.Children.First().Alpha > 0); + AddUntilStep("wait for overlay disappear", () => !skip.OverlayContent.IsPresent); + AddAssert("ensure button didn't disappear", () => skip.FadingContent.Alpha > 0); AddStep("button up", () => InputManager.ReleaseButton(MouseButton.Left)); checkRequestCount(0); } private void checkRequestCount(int expected) => AddAssert($"request count is {expected}", () => requestCount == expected); + + private class TestSkipOverlay : SkipOverlay + { + public TestSkipOverlay(double startTime) + : base(startTime) + { + } + + public Drawable OverlayContent => InternalChild; + + public Drawable FadingContent => (OverlayContent as Container)?.Child; + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs new file mode 100644 index 0000000000..606395c289 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSliderPath.cs @@ -0,0 +1,193 @@ +// 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.Graphics; +using osu.Framework.Graphics.Lines; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSliderPath : OsuTestScene + { + private readonly SmoothPath drawablePath; + private SliderPath path; + + public TestSceneSliderPath() + { + Child = drawablePath = new SmoothPath + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + } + + [SetUp] + public void Setup() => Schedule(() => + { + path = new SliderPath(); + }); + + protected override void Update() + { + base.Update(); + + if (path != null) + { + List vertices = new List(); + path.GetPathToProgress(vertices, 0, 1); + + drawablePath.Vertices = vertices; + } + } + + [Test] + public void TestEmptyPath() + { + } + + [TestCase(PathType.Linear)] + [TestCase(PathType.Bezier)] + [TestCase(PathType.Catmull)] + [TestCase(PathType.PerfectCurve)] + public void TestSingleSegment(PathType type) + => AddStep("create path", () => path.ControlPoints.AddRange(createSegment(type, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + + [TestCase(PathType.Linear)] + [TestCase(PathType.Bezier)] + [TestCase(PathType.Catmull)] + [TestCase(PathType.PerfectCurve)] + public void TestMultipleSegment(PathType type) + { + AddStep("create path", () => + { + path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero)); + path.ControlPoints.AddRange(createSegment(type, new Vector2(0, 100), new Vector2(100), Vector2.Zero)); + }); + } + + [Test] + public void TestAddControlPoint() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100)))); + AddStep("add point", () => path.ControlPoints.Add(new PathControlPoint { Position = { Value = new Vector2(100) } })); + } + + [Test] + public void TestInsertControlPoint() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(100)))); + AddStep("insert point", () => path.ControlPoints.Insert(1, new PathControlPoint { Position = { Value = new Vector2(0, 100) } })); + } + + [Test] + public void TestRemoveControlPoint() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + AddStep("remove second point", () => path.ControlPoints.RemoveAt(1)); + } + + [Test] + public void TestChangePathType() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + AddStep("change type to bezier", () => path.ControlPoints[0].Type.Value = PathType.Bezier); + } + + [Test] + public void TestAddSegmentByChangingType() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0)))); + AddStep("change second point type to bezier", () => path.ControlPoints[1].Type.Value = PathType.Bezier); + } + + [Test] + public void TestRemoveSegmentByChangingType() + { + AddStep("create path", () => + { + path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0))); + path.ControlPoints[1].Type.Value = PathType.Bezier; + }); + + AddStep("change second point type to null", () => path.ControlPoints[1].Type.Value = null); + } + + [Test] + public void TestRemoveSegmentByRemovingControlPoint() + { + AddStep("create path", () => + { + path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0))); + path.ControlPoints[1].Type.Value = PathType.Bezier; + }); + + AddStep("remove second point", () => path.ControlPoints.RemoveAt(1)); + } + + [TestCase(2)] + [TestCase(4)] + public void TestPerfectCurveFallbackScenarios(int points) + { + AddStep("create path", () => + { + switch (points) + { + case 2: + path.ControlPoints.AddRange(createSegment(PathType.PerfectCurve, Vector2.Zero, new Vector2(0, 100))); + break; + + case 4: + path.ControlPoints.AddRange(createSegment(PathType.PerfectCurve, Vector2.Zero, new Vector2(0, 100), new Vector2(100), new Vector2(100, 0))); + break; + } + }); + } + + [Test] + public void TestLengthenLastSegment() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + AddStep("lengthen last segment", () => path.ExpectedDistance.Value = 300); + } + + [Test] + public void TestShortenLastSegment() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + AddStep("shorten last segment", () => path.ExpectedDistance.Value = 150); + } + + [Test] + public void TestShortenFirstSegment() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + AddStep("shorten first segment", () => path.ExpectedDistance.Value = 50); + } + + [Test] + public void TestShortenToZeroLength() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + AddStep("shorten to 0 length", () => path.ExpectedDistance.Value = 0); + } + + [Test] + public void TestShortenToNegativeLength() + { + AddStep("create path", () => path.ControlPoints.AddRange(createSegment(PathType.Linear, Vector2.Zero, new Vector2(0, 100), new Vector2(100)))); + AddStep("shorten to -10 length", () => path.ExpectedDistance.Value = -10); + } + + private List createSegment(PathType type, params Vector2[] controlPoints) + { + var points = controlPoints.Select(p => new PathControlPoint { Position = { Value = p } }).ToList(); + points[0].Type.Value = type; + return points; + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs index af21007efe..733e8f4290 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs @@ -5,8 +5,12 @@ using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Framework.Timing; +using osu.Game.Graphics; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Play; @@ -15,63 +19,120 @@ namespace osu.Game.Tests.Visual.Gameplay [TestFixture] public class TestSceneSongProgress : OsuTestScene { - private readonly SongProgress progress; - private readonly TestSongProgressGraph graph; + private SongProgress progress; + private TestSongProgressGraph graph; + private readonly Container progressContainer; private readonly StopwatchClock clock; + private readonly FramedClock framedClock; [Cached] private readonly GameplayClock gameplayClock; - private readonly FramedClock framedClock; - public TestSceneSongProgress() { - clock = new StopwatchClock(true); - + clock = new StopwatchClock(); gameplayClock = new GameplayClock(framedClock = new FramedClock(clock)); - Add(progress = new SongProgress + Add(progressContainer = new Container { RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Height = 100, + Y = -100, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(1), + } }); - - Add(graph = new TestSongProgressGraph - { - RelativeSizeAxes = Axes.X, - Height = 200, - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - }); - - AddWaitStep("wait some", 5); - AddAssert("ensure not created", () => graph.CreationCount == 0); - - AddStep("display values", displayNewValues); - AddWaitStep("wait some", 5); - AddUntilStep("wait for creation count", () => graph.CreationCount == 1); - - AddStep("Toggle Bar", () => progress.AllowSeeking = !progress.AllowSeeking); - AddWaitStep("wait some", 5); - AddUntilStep("wait for creation count", () => graph.CreationCount == 1); - - AddStep("Toggle Bar", () => progress.AllowSeeking = !progress.AllowSeeking); - AddWaitStep("wait some", 5); - AddUntilStep("wait for creation count", () => graph.CreationCount == 1); - AddRepeatStep("New Values", displayNewValues, 5); - - AddWaitStep("wait some", 5); - AddAssert("ensure debounced", () => graph.CreationCount == 2); } - private void displayNewValues() + [SetUpSteps] + public void SetupSteps() { - List objects = new List(); + AddStep("add new song progress", () => + { + if (progress != null) + { + progress.Expire(); + progress = null; + } + + progressContainer.Add(progress = new SongProgress + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }); + }); + + AddStep("add new big graph", () => + { + if (graph != null) + { + graph.Expire(); + graph = null; + } + + Add(graph = new TestSongProgressGraph + { + RelativeSizeAxes = Axes.X, + Height = 200, + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + }); + }); + + AddStep("reset clock", clock.Reset); + } + + [Test] + public void TestGraphRecreation() + { + AddAssert("ensure not created", () => graph.CreationCount == 0); + AddStep("display values", displayRandomValues); + AddUntilStep("wait for creation count", () => graph.CreationCount == 1); + AddRepeatStep("new values", displayRandomValues, 5); + AddWaitStep("wait some", 5); + AddAssert("ensure recreation debounced", () => graph.CreationCount == 2); + } + + [Test] + public void TestDisplay() + { + AddStep("display max values", displayMaxValues); + AddUntilStep("wait for graph", () => graph.CreationCount == 1); + AddStep("start", clock.Start); + AddStep("allow seeking", () => progress.AllowSeeking.Value = true); + AddStep("hide graph", () => progress.ShowGraph.Value = false); + AddStep("disallow seeking", () => progress.AllowSeeking.Value = false); + AddStep("allow seeking", () => progress.AllowSeeking.Value = true); + AddStep("show graph", () => progress.ShowGraph.Value = true); + AddStep("stop", clock.Stop); + } + + private void displayRandomValues() + { + var objects = new List(); for (double i = 0; i < 5000; i += RNG.NextDouble() * 10 + i / 1000) objects.Add(new HitObject { StartTime = i }); + replaceObjects(objects); + } + + private void displayMaxValues() + { + var objects = new List(); + for (double i = 0; i < 5000; i++) + objects.Add(new HitObject { StartTime = i }); + + replaceObjects(objects); + } + + private void replaceObjects(List objects) + { progress.Objects = objects; graph.Objects = objects; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs new file mode 100644 index 0000000000..72c6fd8d44 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -0,0 +1,304 @@ +// 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.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.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.Screens.Play; +using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSpectator : ScreenTestScene + { + [Cached(typeof(SpectatorStreamingClient))] + private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient(); + + // used just to show beatmap card for the time being. + protected override bool UseOnlineAPI => true; + + private Spectator spectatorScreen; + + [Resolved] + private OsuGameBase game { get; set; } + + private int nextFrame; + + private BeatmapSetInfo importedBeatmap; + + private int importedBeatmapId; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("reset sent frames", () => nextFrame = 0); + + AddStep("import beatmap", () => + { + importedBeatmap = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result; + importedBeatmapId = importedBeatmap.Beatmaps.First(b => b.RulesetID == 0).OnlineBeatmapID ?? -1; + }); + + AddStep("add streaming client", () => + { + Remove(testSpectatorStreamingClient); + Add(testSpectatorStreamingClient); + }); + + finish(); + } + + [Test] + public void TestFrameStarvationAndResume() + { + loadSpectatingScreen(); + + AddAssert("screen hasn't changed", () => Stack.CurrentScreen is Spectator); + + start(); + sendFrames(); + + waitForPlayer(); + AddAssert("ensure frames arrived", () => replayHandler.HasFrames); + + AddUntilStep("wait for frame starvation", () => replayHandler.NextFrame == null); + checkPaused(true); + + double? pausedTime = null; + + AddStep("store time", () => pausedTime = currentFrameStableTime); + + sendFrames(); + + AddUntilStep("wait for frame starvation", () => replayHandler.NextFrame == null); + checkPaused(true); + + AddAssert("time advanced", () => currentFrameStableTime > pausedTime); + } + + [Test] + public void TestPlayStartsWithNoFrames() + { + loadSpectatingScreen(); + + start(); + waitForPlayer(); + checkPaused(true); + + sendFrames(1000); // send enough frames to ensure play won't be paused + + checkPaused(false); + } + + [Test] + public void TestSpectatingDuringGameplay() + { + start(); + + loadSpectatingScreen(); + + AddStep("advance frame count", () => nextFrame = 300); + sendFrames(); + + waitForPlayer(); + + AddUntilStep("playing from correct point in time", () => player.ChildrenOfType().First().FrameStableClock.CurrentTime > 30000); + } + + [Test] + public void TestHostRetriesWhileWatching() + { + loadSpectatingScreen(); + + start(); + sendFrames(); + + waitForPlayer(); + + Player lastPlayer = null; + AddStep("store first player", () => lastPlayer = player); + + start(); + sendFrames(); + + waitForPlayer(); + AddAssert("player is different", () => lastPlayer != player); + } + + [Test] + public void TestHostFails() + { + loadSpectatingScreen(); + + start(); + + waitForPlayer(); + checkPaused(true); + + finish(); + + checkPaused(false); + // TODO: should replay until running out of frames then fail + } + + [Test] + public void TestStopWatchingDuringPlay() + { + loadSpectatingScreen(); + + start(); + sendFrames(); + waitForPlayer(); + + AddStep("stop spectating", () => (Stack.CurrentScreen as Player)?.Exit()); + AddUntilStep("spectating stopped", () => spectatorScreen.GetChildScreen() == null); + } + + [Test] + public void TestStopWatchingThenHostRetries() + { + loadSpectatingScreen(); + + start(); + sendFrames(); + waitForPlayer(); + + AddStep("stop spectating", () => (Stack.CurrentScreen as Player)?.Exit()); + AddUntilStep("spectating stopped", () => spectatorScreen.GetChildScreen() == null); + + // host starts playing a new session + start(); + waitForPlayer(); + } + + [Test] + public void TestWatchingBeatmapThatDoesntExistLocally() + { + loadSpectatingScreen(); + + start(-1234); + sendFrames(); + + AddAssert("screen didn't change", () => Stack.CurrentScreen is Spectator); + } + + private OsuFramedReplayInputHandler replayHandler => + (OsuFramedReplayInputHandler)Stack.ChildrenOfType().First().ReplayInputHandler; + + private Player player => Stack.CurrentScreen as Player; + + private double currentFrameStableTime + => player.ChildrenOfType().First().FrameStableClock.CurrentTime; + + 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 finish(int? beatmapId = null) => AddStep("end play", () => testSpectatorStreamingClient.EndPlay(beatmapId ?? importedBeatmapId)); + + private void checkPaused(bool state) => + AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state); + + private void sendFrames(int count = 10) + { + AddStep("send frames", () => + { + testSpectatorStreamingClient.SendFrames(nextFrame, count); + nextFrame += count; + }); + } + + private void loadSpectatingScreen() + { + AddStep("load screen", () => LoadScreen(spectatorScreen = new Spectator(testSpectatorStreamingClient.StreamingUser))); + AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded); + } + + public class TestSpectatorStreamingClient : SpectatorStreamingClient + { + public readonly User StreamingUser = new User { Id = 1234, Username = "Test user" }; + + public new BindableList PlayingUsers => (BindableList)base.PlayingUsers; + + private int beatmapId; + + protected override Task Connect() + { + return Task.CompletedTask; + } + + 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(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, + }); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs new file mode 100644 index 0000000000..35473ee76c --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -0,0 +1,361 @@ +// 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 NUnit.Framework; +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.Bindings; +using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges; +using osu.Framework.Logging; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; +using osu.Game.Online.Spectator; +using osu.Game.Replays; +using osu.Game.Replays.Legacy; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Replays.Types; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSpectatorPlayback : OsuManualInputManagerTestScene + { + protected override bool UseOnlineAPI => true; + + private TestRulesetInputManager playbackManager; + private TestRulesetInputManager recordingManager; + + private Replay replay; + + private readonly IBindableList users = new BindableList(); + + private TestReplayRecorder recorder; + + private readonly ManualClock manualClock = new ManualClock(); + + private OsuSpriteText latencyDisplay; + + private TestFramedReplayInputHandler replayHandler; + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private SpectatorStreamingClient streamingClient { get; set; } + + [Cached] + private GameplayBeatmap gameplayBeatmap = new GameplayBeatmap(new Beatmap()); + + [SetUp] + public void SetUp() => Schedule(() => + { + replay = new Replay(); + + users.BindTo(streamingClient.PlayingUsers); + users.BindCollectionChanged((obj, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (int user in args.NewItems) + { + if (user == api.LocalUser.Value.Id) + streamingClient.WatchUser(user); + } + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (int user in args.OldItems) + { + if (user == api.LocalUser.Value.Id) + streamingClient.StopWatchingUser(user); + } + + break; + } + }, true); + + streamingClient.OnNewFrames += onNewFrames; + + Add(new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + recordingManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + Recorder = recorder = new TestReplayRecorder + { + ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Brown, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Sending", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + }, + new Drawable[] + { + playbackManager = new TestRulesetInputManager(new TestSceneModSettings.TestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + Clock = new FramedClock(manualClock), + ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(replay) + { + GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.DarkBlue, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Receiving", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + } + } + }); + + Add(latencyDisplay = new OsuSpriteText()); + }); + + private void onNewFrames(int userId, FrameDataBundle frames) + { + Logger.Log($"Received {frames.Frames.Count()} new frames ({string.Join(',', frames.Frames.Select(f => ((int)f.Time).ToString()))})"); + + foreach (var legacyFrame in frames.Frames) + { + var frame = new TestReplayFrame(); + frame.FromLegacy(legacyFrame, null, null); + replay.Frames.Add(frame); + } + } + + [Test] + public void TestBasic() + { + } + + private double latency = SpectatorStreamingClient.TIME_BETWEEN_SENDS; + + protected override void Update() + { + base.Update(); + + if (latencyDisplay == null) return; + + // propagate initial time value + if (manualClock.CurrentTime == 0) + { + manualClock.CurrentTime = Time.Current; + return; + } + + if (replayHandler.NextFrame != null) + { + var lastFrame = replay.Frames.LastOrDefault(); + + // 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); + + latencyDisplay.Text = $"latency: {latency:N1}"; + + double proposedTime = Time.Current - latency + Time.Elapsed; + + // this will either advance by one or zero frames. + double? time = replayHandler.SetFrameFromTime(proposedTime); + + if (time == null) + return; + + manualClock.CurrentTime = time.Value; + } + } + + [TearDownSteps] + public void TearDown() + { + AddStep("stop recorder", () => + { + recorder.Expire(); + streamingClient.OnNewFrames -= onNewFrames; + }); + } + + public class TestFramedReplayInputHandler : FramedReplayInputHandler + { + public TestFramedReplayInputHandler(Replay replay) + : base(replay) + { + } + + public override void CollectPendingInputs(List inputs) + { + inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) }); + inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); + } + } + + public class TestInputConsumer : CompositeDrawable, IKeyBindingHandler + { + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos); + + private readonly Box box; + + public TestInputConsumer() + { + Size = new Vector2(30); + + Origin = Anchor.Centre; + + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + Position = e.MousePosition; + return base.OnMouseMove(e); + } + + public bool OnPressed(TestAction action) + { + box.Colour = Color4.White; + return true; + } + + public void OnReleased(TestAction action) + { + box.Colour = Color4.Black; + } + } + + public class TestRulesetInputManager : RulesetInputManager + { + public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + : base(ruleset, variant, unique) + { + } + + protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + => new TestKeyBindingContainer(); + + internal class TestKeyBindingContainer : KeyBindingContainer + { + public override IEnumerable DefaultKeyBindings => new[] + { + new KeyBinding(InputKey.MouseLeft, TestAction.Down), + }; + } + } + + public class TestReplayFrame : ReplayFrame, IConvertibleReplayFrame + { + public Vector2 Position; + + public List Actions = new List(); + + public TestReplayFrame(double time, Vector2 position, params TestAction[] actions) + : base(time) + { + Position = position; + Actions.AddRange(actions); + } + + public TestReplayFrame() + { + } + + public void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null) + { + Position = currentFrame.Position; + Time = currentFrame.Time; + if (currentFrame.MouseLeft) + Actions.Add(TestAction.Down); + } + + public LegacyReplayFrame ToLegacy(IBeatmap beatmap) + { + ReplayButtonState state = ReplayButtonState.None; + + if (Actions.Contains(TestAction.Down)) + state |= ReplayButtonState.Left1; + + return new LegacyReplayFrame(Time, Position.X, Position.Y, state); + } + } + + public enum TestAction + { + Down, + } + + internal class TestReplayRecorder : ReplayRecorder + { + public TestReplayRecorder() + : base(new Replay()) + { + } + + protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) + { + return new TestReplayFrame(Time.Current, mousePosition, actions.ToArray()); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs new file mode 100644 index 0000000000..709e71d195 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [TestFixture] + public class TestSceneStarCounter : OsuTestScene + { + public TestSceneStarCounter() + { + StarCounter stars = new StarCounter + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Current = 5, + }; + + Add(stars); + + SpriteText starsLabel = new OsuSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Scale = new Vector2(2), + Y = 50, + Text = stars.Current.ToString("0.00"), + }; + + Add(starsLabel); + + AddRepeatStep(@"random value", delegate + { + stars.Current = RNG.NextSingle() * (stars.StarCount + 1); + starsLabel.Text = stars.Current.ToString("0.00"); + }, 10); + + AddStep(@"Stop animation", delegate + { + stars.StopAnimation(); + }); + + AddStep(@"Reset", delegate + { + stars.Current = 0; + starsLabel.Text = stars.Current.ToString("0.00"); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs index ead7a4b7fc..5a2b8d22fd 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs @@ -9,8 +9,12 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; using osu.Game.Overlays; +using osu.Game.Storyboards; using osu.Game.Storyboards.Drawables; +using osu.Game.Tests.Resources; using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay @@ -18,37 +22,12 @@ namespace osu.Game.Tests.Visual.Gameplay [TestFixture] public class TestSceneStoryboard : OsuTestScene { - private readonly Container storyboardContainer; + private Container storyboardContainer; private DrawableStoryboard storyboard; - public TestSceneStoryboard() + [Test] + public void TestStoryboard() { - Clock = new FramedClock(); - - Add(new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - }, - storyboardContainer = new Container - { - RelativeSizeAxes = Axes.Both, - }, - }, - }); - - Add(new MusicController - { - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - State = { Value = Visibility.Visible }, - }); - AddStep("Restart", restart); AddToggleStep("Passing", passing => { @@ -56,14 +35,47 @@ namespace osu.Game.Tests.Visual.Gameplay }); } + [Test] + public void TestStoryboardMissingVideo() + { + AddStep("Load storyboard with missing video", loadStoryboardNoVideo); + } + [BackgroundDependencyLoader] private void load() { - Beatmap.ValueChanged += beatmapChanged; + Clock = new FramedClock(); + + AddRange(new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + storyboardContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + }, + }, + new NowPlayingOverlay + { + Origin = Anchor.TopRight, + Anchor = Anchor.TopRight, + State = { Value = Visibility.Visible }, + } + }); + + Beatmap.BindValueChanged(beatmapChanged, true); } - private void beatmapChanged(ValueChangedEvent e) - => loadStoryboard(e.NewValue); + private void beatmapChanged(ValueChangedEvent e) => loadStoryboard(e.NewValue); private void restart() { @@ -88,5 +100,28 @@ namespace osu.Game.Tests.Visual.Gameplay storyboardContainer.Add(storyboard); decoupledClock.ChangeSource(working.Track); } + + private void loadStoryboardNoVideo() + { + if (storyboard != null) + storyboardContainer.Remove(storyboard); + + var decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = true }; + storyboardContainer.Clock = decoupledClock; + + Storyboard sb; + + using (var str = TestResources.OpenResource("storyboard_no_video.osu")) + using (var bfr = new LineBufferedReader(str)) + { + var decoder = new LegacyStoryboardDecoder(); + sb = decoder.Decode(bfr); + } + + storyboard = sb.CreateDrawable(Beatmap.Value); + + storyboardContainer.Add(storyboard); + decoupledClock.ChangeSource(Beatmap.Value.Track); + } } } diff --git a/osu.Game.Tests/Visual/Menus/IntroTestScene.cs b/osu.Game.Tests/Visual/Menus/IntroTestScene.cs new file mode 100644 index 0000000000..f71d13ed35 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/IntroTestScene.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Screens; +using osu.Game.Screens; +using osu.Game.Screens.Menu; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Menus +{ + [TestFixture] + public abstract class IntroTestScene : OsuTestScene + { + [Cached] + private OsuLogo logo; + + protected OsuScreenStack IntroStack; + + protected IntroTestScene() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Colour = Color4.Black, + }, + logo = new OsuLogo + { + Alpha = 0, + RelativePositionAxes = Axes.Both, + Depth = float.MinValue, + Position = new Vector2(0.5f), + } + }; + + AddStep("restart sequence", () => + { + logo.FinishTransforms(); + logo.IsTracking = false; + + IntroStack?.Expire(); + + Add(IntroStack = new OsuScreenStack + { + RelativeSizeAxes = Axes.Both, + }); + + IntroStack.Push(CreateScreen()); + }); + + AddUntilStep("wait for menu", () => IntroStack.CurrentScreen is MainMenu); + } + + protected abstract IScreen CreateScreen(); + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs b/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs index f2718b8e80..49fab08ded 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneDisclaimer.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Game.Online.API; using osu.Game.Screens.Menu; using osu.Game.Users; @@ -10,23 +9,18 @@ namespace osu.Game.Tests.Visual.Menus { public class TestSceneDisclaimer : ScreenTestScene { - [Cached(typeof(IAPIProvider))] - private readonly DummyAPIAccess api = new DummyAPIAccess(); - [BackgroundDependencyLoader] private void load() { - Add(api); - AddStep("load disclaimer", () => LoadScreen(new Disclaimer())); AddStep("toggle support", () => { - api.LocalUser.Value = new User + API.LocalUser.Value = new User { - Username = api.LocalUser.Value.Username, - Id = api.LocalUser.Value.Id, - IsSupporter = !api.LocalUser.Value.IsSupporter, + Username = API.LocalUser.Value.Username, + Id = API.LocalUser.Value.Id + 1, + IsSupporter = !API.LocalUser.Value.IsSupporter, }; }); } diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.cs new file mode 100644 index 0000000000..107734cc8d --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroCircles.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 NUnit.Framework; +using osu.Framework.Screens; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Menus +{ + [TestFixture] + public class TestSceneIntroCircles : IntroTestScene + { + protected override IScreen CreateScreen() => new IntroCircles(); + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroSequence.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroSequence.cs deleted file mode 100644 index b59fb18428..0000000000 --- a/osu.Game.Tests/Visual/Menus/TestSceneIntroSequence.cs +++ /dev/null @@ -1,54 +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.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Timing; -using osu.Game.Screens.Menu; -using osuTK.Graphics; - -namespace osu.Game.Tests.Visual.Menus -{ - [TestFixture] - public class TestSceneIntroSequence : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(OsuLogo), - }; - - public TestSceneIntroSequence() - { - OsuLogo logo; - - var rateAdjustClock = new StopwatchClock(true); - var framedClock = new FramedClock(rateAdjustClock); - framedClock.ProcessFrame(); - - Add(new Container - { - RelativeSizeAxes = Axes.Both, - Clock = framedClock, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - }, - logo = new OsuLogo - { - Anchor = Anchor.Centre, - } - } - }); - - AddStep(@"Restart", logo.PlayIntro); - AddSliderStep("Playback speed", 0.0, 2.0, 1, v => rateAdjustClock.Rate = v); - } - } -} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.cs new file mode 100644 index 0000000000..df79584167 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroTriangles.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 NUnit.Framework; +using osu.Framework.Screens; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Menus +{ + [TestFixture] + public class TestSceneIntroTriangles : IntroTestScene + { + protected override IScreen CreateScreen() => new IntroTriangles(); + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs b/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.cs new file mode 100644 index 0000000000..5f135febf4 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneIntroWelcome.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 NUnit.Framework; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Menus +{ + [TestFixture] + public class TestSceneIntroWelcome : IntroTestScene + { + protected override IScreen CreateScreen() => new IntroWelcome(); + + public TestSceneIntroWelcome() + { + AddUntilStep("wait for load", () => MusicController.TrackLoaded); + AddAssert("correct track", () => Precision.AlmostEquals(MusicController.CurrentTrack.Length, 48000, 1)); + AddAssert("check if menu music loops", () => MusicController.CurrentTrack.Looping); + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneLoaderAnimation.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoader.cs similarity index 71% rename from osu.Game.Tests/Visual/Menus/TestSceneLoaderAnimation.cs rename to osu.Game.Tests/Visual/Menus/TestSceneLoader.cs index 000832b784..c44363d9ea 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneLoaderAnimation.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoader.cs @@ -1,12 +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.Linq; using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Screens; using osu.Game.Screens.Menu; using osuTK.Graphics; @@ -14,14 +17,14 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneLoaderAnimation : ScreenTestScene + public class TestSceneLoader : ScreenTestScene { private TestLoader loader; [Cached] private OsuLogo logo; - public TestSceneLoaderAnimation() + public TestSceneLoader() { Child = logo = new OsuLogo { @@ -33,9 +36,7 @@ namespace osu.Game.Tests.Visual.Menus [Test] public void TestInstantLoad() { - bool logoVisible = false; - - AddStep("begin loading", () => + AddStep("load immediately", () => { loader = new TestLoader(); loader.AllowLoad.Set(); @@ -43,40 +44,37 @@ namespace osu.Game.Tests.Visual.Menus LoadScreen(loader); }); - AddUntilStep("loaded", () => - { - logoVisible = loader.Logo?.Alpha > 0; - return loader.Logo != null && loader.ScreenLoaded; - }); + spinnerNotPresentOrHidden(); - AddAssert("logo was not visible", () => !logoVisible); + AddUntilStep("loaded", () => loader.ScreenLoaded); + AddUntilStep("not current", () => !loader.IsCurrentScreen()); + + spinnerNotPresentOrHidden(); } + private void spinnerNotPresentOrHidden() => + AddAssert("spinner did not display", () => loader.LoadingSpinner == null || loader.LoadingSpinner.Alpha == 0); + [Test] public void TestDelayedLoad() { AddStep("begin loading", () => LoadScreen(loader = new TestLoader())); - AddUntilStep("wait for logo visible", () => loader.Logo?.Alpha > 0); + AddUntilStep("wait for spinner visible", () => loader.LoadingSpinner?.Alpha > 0); AddStep("finish loading", () => loader.AllowLoad.Set()); - AddAssert("loaded", () => loader.Logo != null && loader.ScreenLoaded); - AddUntilStep("logo gone", () => loader.Logo?.Alpha == 0); + AddUntilStep("spinner gone", () => loader.LoadingSpinner?.Alpha == 0); + AddUntilStep("loaded", () => loader.ScreenLoaded); + AddUntilStep("not current", () => !loader.IsCurrentScreen()); } private class TestLoader : Loader { public readonly ManualResetEventSlim AllowLoad = new ManualResetEventSlim(); - public OsuLogo Logo; + public LoadingSpinner LoadingSpinner => this.ChildrenOfType().FirstOrDefault(); private TestScreen screen; public bool ScreenLoaded => screen.IsCurrentScreen(); - protected override void LogoArriving(OsuLogo logo, bool resuming) - { - Logo = logo; - base.LogoArriving(logo, resuming); - } - protected override OsuScreen CreateLoadableScreen() => screen = new TestScreen(); protected override ShaderPrecompiler CreateShaderPrecompiler() => new TestShaderPrecompiler(AllowLoad); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs new file mode 100644 index 0000000000..b34e027e9c --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Input.Bindings; +using osu.Game.Overlays; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.Navigation; + +namespace osu.Game.Tests.Visual.Menus +{ + public class TestSceneMusicActionHandling : OsuGameTestScene + { + private GlobalActionContainer globalActionContainer => Game.ChildrenOfType().First(); + + [Test] + public void TestMusicPlayAction() + { + AddStep("ensure playing something", () => Game.MusicController.EnsurePlayingSomething()); + AddStep("toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); + AddAssert("music paused", () => !Game.MusicController.IsPlaying && Game.MusicController.UserPauseRequested); + AddStep("toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay)); + AddAssert("music resumed", () => Game.MusicController.IsPlaying && !Game.MusicController.UserPauseRequested); + } + + [Test] + public void TestMusicNavigationActions() + { + int importId = 0; + Queue<(WorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null; + + // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. + AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(new BeatmapSetInfo + { + Beatmaps = new List + { + new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty(), + } + }, + Metadata = new BeatmapMetadata + { + Artist = $"a test map {importId++}", + Title = "title", + } + }).Wait(), 5); + + AddStep("import beatmap with track", () => + { + var setWithTrack = Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).Result; + Beatmap.Value = Game.BeatmapManager.GetWorkingBeatmap(setWithTrack.Beatmaps.First()); + }); + + AddStep("bind to track change", () => + { + trackChangeQueue = new Queue<(WorkingBeatmap, TrackChangeDirection)>(); + Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection)); + }); + + AddStep("seek track to 6 second", () => Game.MusicController.SeekTo(6000)); + AddUntilStep("wait for current time to update", () => Game.MusicController.CurrentTrack.CurrentTime > 5000); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddAssert("no track change", () => trackChangeQueue.Count == 0); + AddUntilStep("track restarted", () => Game.MusicController.CurrentTrack.CurrentTime < 5000); + + AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev)); + AddAssert("track changed to previous", () => + trackChangeQueue.Count == 1 && + trackChangeQueue.Dequeue().changeDirection == TrackChangeDirection.Prev); + + AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext)); + AddAssert("track changed to next", () => + trackChangeQueue.Count == 1 && + trackChangeQueue.Dequeue().changeDirection == TrackChangeDirection.Next); + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneSongTicker.cs b/osu.Game.Tests/Visual/Menus/TestSceneSongTicker.cs new file mode 100644 index 0000000000..4b22af38c5 --- /dev/null +++ b/osu.Game.Tests/Visual/Menus/TestSceneSongTicker.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 osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Menus +{ + public class TestSceneSongTicker : OsuTestScene + { + public TestSceneSongTicker() + { + AddRange(new Drawable[] + { + new SongTicker + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new NowPlayingOverlay + { + Origin = Anchor.TopRight, + Anchor = Anchor.TopRight, + State = { Value = Visibility.Visible } + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs index f24589ed35..57d60cea9e 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs @@ -1,44 +1,95 @@ // 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.Bindables; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Overlays; using osu.Game.Overlays.Toolbar; +using osu.Game.Rulesets; +using osuTK.Input; namespace osu.Game.Tests.Visual.Menus { [TestFixture] - public class TestSceneToolbar : OsuTestScene + public class TestSceneToolbar : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ToolbarButton), - typeof(ToolbarRulesetSelector), - typeof(ToolbarRulesetTabButton), - typeof(ToolbarNotificationButton), - }; + private TestToolbar toolbar; - public TestSceneToolbar() + [Resolved] + private RulesetStore rulesets { get; set; } + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = toolbar = new TestToolbar { State = { Value = Visibility.Visible } }; + }); + + [Test] + public void TestNotificationCounter() { - var toolbar = new Toolbar { State = { Value = Visibility.Visible } }; ToolbarNotificationButton notificationButton = null; - AddStep("create toolbar", () => - { - Add(toolbar); - notificationButton = toolbar.Children.OfType().Last().Children.OfType().First(); - }); - - void setNotifications(int count) => AddStep($"set notification count to {count}", () => notificationButton.NotificationCount.Value = count); + AddStep("retrieve notification button", () => notificationButton = toolbar.ChildrenOfType().Single()); setNotifications(1); setNotifications(2); setNotifications(3); setNotifications(0); setNotifications(144); + + void setNotifications(int count) + => AddStep($"set notification count to {count}", + () => notificationButton.NotificationCount.Value = count); + } + + [TestCase(false)] + [TestCase(true)] + public void TestRulesetSwitchingShortcut(bool toolbarHidden) + { + ToolbarRulesetSelector rulesetSelector = null; + + if (toolbarHidden) + AddStep("hide toolbar", () => toolbar.Hide()); + + AddStep("retrieve ruleset selector", () => rulesetSelector = toolbar.ChildrenOfType().Single()); + + for (int i = 0; i < 4; i++) + { + var expected = rulesets.AvailableRulesets.ElementAt(i); + var numberKey = Key.Number1 + i; + + AddStep($"switch to ruleset {i} via shortcut", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(numberKey); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddUntilStep("ruleset switched", () => rulesetSelector.Current.Value.Equals(expected)); + } + } + + [TestCase(OverlayActivation.All)] + [TestCase(OverlayActivation.Disabled)] + public void TestRespectsOverlayActivation(OverlayActivation mode) + { + AddStep($"set activation mode to {mode}", () => toolbar.OverlayActivationMode.Value = mode); + AddStep("hide toolbar", () => toolbar.Hide()); + AddStep("try to show toolbar", () => toolbar.Show()); + + if (mode == OverlayActivation.Disabled) + AddAssert("toolbar still hidden", () => toolbar.State.Value == Visibility.Hidden); + else + AddAssert("toolbar is visible", () => toolbar.State.Value == Visibility.Visible); + } + + public class TestToolbar : Toolbar + { + public new Bindable OverlayActivationMode => base.OverlayActivationMode as Bindable; } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.cs new file mode 100644 index 0000000000..8b7e0fd9da --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.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 System; +using osu.Framework.Allocation; +using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; +using osu.Game.Screens.Multi; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public abstract class RoomManagerTestScene : MultiplayerTestScene + { + [Cached(Type = typeof(IRoomManager))] + protected TestRoomManager RoomManager { get; } = new TestRoomManager(); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("clear rooms", () => RoomManager.Rooms.Clear()); + } + + protected void AddRooms(int count, RulesetInfo ruleset = null) + { + AddStep("add rooms", () => + { + for (int i = 0; i < count; i++) + { + var room = new Room + { + RoomID = { Value = i }, + Name = { Value = $"Room {i}" }, + Host = { Value = new User { Username = "Host" } }, + EndDate = { Value = DateTimeOffset.Now + TimeSpan.FromSeconds(10) }, + Category = { Value = i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal } + }; + + if (ruleset != null) + { + room.Playlist.Add(new PlaylistItem + { + Ruleset = { Value = ruleset }, + Beatmap = + { + Value = new BeatmapInfo + { + Metadata = new BeatmapMetadata() + } + } + }); + } + + RoomManager.Rooms.Add(room); + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs b/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.cs new file mode 100644 index 0000000000..67a53307fc --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.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; +using osu.Framework.Bindables; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Multi; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestRoomManager : IRoomManager + { + public event Action RoomsUpdated + { + add { } + remove { } + } + + public readonly BindableList Rooms = new BindableList(); + + public Bindable InitialRoomsReceived { get; } = new Bindable(true); + + IBindableList IRoomManager.Rooms => Rooms; + + public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) => Rooms.Add(room); + + public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) + { + } + + public void PartRoom() + { + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs new file mode 100644 index 0000000000..55b026eff6 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -0,0 +1,321 @@ +// 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.Audio; +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.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Multi; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneDrawableRoomPlaylist : OsuManualInputManagerTestScene + { + private TestPlaylist playlist; + + private BeatmapManager manager; + private RulesetStore rulesets; + + [BackgroundDependencyLoader] + 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)); + + manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait(); + } + + [Test] + public void TestNonEditableNonSelectable() + { + createPlaylist(false, false); + + moveToItem(0); + assertHandleVisibility(0, false); + assertDeleteButtonVisibility(0, false); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddAssert("no item selected", () => playlist.SelectedItem.Value == null); + } + + [Test] + public void TestEditable() + { + createPlaylist(true, false); + + moveToItem(0); + assertHandleVisibility(0, true); + assertDeleteButtonVisibility(0, true); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddAssert("no item selected", () => playlist.SelectedItem.Value == null); + } + + [Test] + public void TestSelectable() + { + createPlaylist(false, true); + + moveToItem(0); + assertHandleVisibility(0, false); + assertDeleteButtonVisibility(0, false); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + } + + [Test] + public void TestEditableSelectable() + { + createPlaylist(true, true); + + moveToItem(0); + assertHandleVisibility(0, true); + assertDeleteButtonVisibility(0, true); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + } + + [Test] + public void TestSelectionNotLostAfterRearrangement() + { + createPlaylist(true, true); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + moveToDragger(0); + AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); + moveToDragger(1, new Vector2(0, 5)); + AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddAssert("item 1 is selected", () => playlist.SelectedItem.Value == playlist.Items[1]); + } + + [Test] + public void TestItemRemovedOnDeletion() + { + PlaylistItem selectedItem = null; + + createPlaylist(true, true); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("retrieve selection", () => selectedItem = playlist.SelectedItem.Value); + + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item removed", () => !playlist.Items.Contains(selectedItem)); + } + + [Test] + public void TestNextItemSelectedAfterDeletion() + { + createPlaylist(true, true); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + } + + [Test] + public void TestLastItemSelectedAfterLastItemDeleted() + { + createPlaylist(true, true); + + AddWaitStep("wait for flow", 5); // Items may take 1 update frame to flow. A wait count of 5 is guaranteed to result in the flow being updated as desired. + AddStep("scroll to bottom", () => playlist.ChildrenOfType>().First().ScrollToEnd(false)); + + moveToItem(19); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + moveToDeleteButton(19); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item 18 is selected", () => playlist.SelectedItem.Value == playlist.Items[18]); + } + + [Test] + public void TestSelectionResetWhenAllItemsDeleted() + { + createPlaylist(true, true); + + AddStep("remove all but one item", () => + { + playlist.Items.RemoveRange(1, playlist.Items.Count - 1); + }); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("no item selected", () => playlist.SelectedItem.Value == null); + } + + // Todo: currently not possible due to bindable list shortcomings (https://github.com/ppy/osu-framework/issues/3081) + // [Test] + public void TestNextItemSelectedAfterExternalDeletion() + { + createPlaylist(true, true); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("remove item 0", () => playlist.Items.RemoveAt(0)); + + AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + } + + [Test] + public void TestChangeBeatmapAndRemove() + { + createPlaylist(true, true); + + AddStep("change beatmap of first item", () => playlist.Items[0].BeatmapID = 30); + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + } + + [Test] + public void TestDownloadButtonHiddenInitiallyWhenBeatmapExists() + { + createPlaylist(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo); + + AddAssert("download button hidden", () => !playlist.ChildrenOfType().Single().IsPresent); + } + + [Test] + public void TestDownloadButtonVisibleInitiallyWhenBeatmapDoesNotExist() + { + var byOnlineId = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo; + byOnlineId.BeatmapSet.OnlineBeatmapSetID = 1337; // Some random ID that does not exist locally. + + var byChecksum = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo; + byChecksum.MD5Hash = "1337"; // Some random checksum that does not exist locally. + + createPlaylist(byOnlineId, byChecksum); + + AddAssert("download buttons shown", () => playlist.ChildrenOfType().All(d => d.IsPresent)); + } + + private void moveToItem(int index, Vector2? offset = null) + => AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType>().ElementAt(index), offset)); + + private void moveToDragger(int index, Vector2? offset = null) => AddStep($"move mouse to dragger {index}", () => + { + var item = playlist.ChildrenOfType>().ElementAt(index); + InputManager.MoveMouseTo(item.ChildrenOfType.PlaylistItemHandle>().Single(), offset); + }); + + private void moveToDeleteButton(int index, Vector2? offset = null) => AddStep($"move mouse to delete button {index}", () => + { + var item = playlist.ChildrenOfType>().ElementAt(index); + InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset); + }); + + private void assertHandleVisibility(int index, bool visible) + => AddAssert($"handle {index} {(visible ? "is" : "is not")} visible", + () => (playlist.ChildrenOfType.PlaylistItemHandle>().ElementAt(index).Alpha > 0) == visible); + + private void assertDeleteButtonVisibility(int index, bool visible) + => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible); + + private void createPlaylist(bool allowEdit, bool allowSelection) + { + AddStep("create playlist", () => + { + Child = playlist = new TestPlaylist(allowEdit, allowSelection) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 300) + }; + + for (int i = 0; i < 20; i++) + { + playlist.Items.Add(new PlaylistItem + { + ID = i, + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + RequiredMods = + { + new OsuModHardRock(), + new OsuModDoubleTime(), + new OsuModAutoplay() + } + }); + } + }); + + AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); + } + + private void createPlaylist(params BeatmapInfo[] beatmaps) + { + AddStep("create playlist", () => + { + Child = playlist = new TestPlaylist(false, false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 300) + }; + + int index = 0; + + foreach (var b in beatmaps) + { + playlist.Items.Add(new PlaylistItem + { + ID = index++, + Beatmap = { Value = b }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + RequiredMods = + { + new OsuModHardRock(), + new OsuModDoubleTime(), + new OsuModAutoplay() + } + }); + } + }); + + AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); + } + + private class TestPlaylist : DrawableRoomPlaylist + { + public new IReadOnlyDictionary> ItemMap => base.ItemMap; + + public TestPlaylist(bool allowEdit, bool allowSelection) + : base(allowEdit, allowSelection) + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeFilterControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeFilterControl.cs new file mode 100644 index 0000000000..7c0c2797f5 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeFilterControl.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.Screens.Multi.Lounge.Components; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneLoungeFilterControl : OsuTestScene + { + public TestSceneLoungeFilterControl() + { + Child = new FilterControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs new file mode 100644 index 0000000000..cdad37a9ad --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.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 System; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Multiplayer.RoomStatuses; +using osu.Game.Screens.Multi.Lounge.Components; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneLoungeRoomInfo : MultiplayerTestScene + { + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room(); + + Child = new RoomInfo + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500 + }; + }); + + public override void SetUpSteps() + { + // Todo: Temp + } + + [Test] + public void TestNonSelectedRoom() + { + AddStep("set null room", () => Room.RoomID.Value = null); + } + + [Test] + public void TestOpenRoom() + { + AddStep("set open room", () => + { + Room.RoomID.Value = 0; + Room.Name.Value = "Room 0"; + Room.Host.Value = new User { Username = "peppy", Id = 2 }; + Room.EndDate.Value = DateTimeOffset.Now.AddMonths(1); + Room.Status.Value = new RoomStatusOpen(); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index eb4dc909df..e33d15cfff 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -1,37 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -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.Graphics; using osu.Game.Online.Multiplayer; -using osu.Game.Screens.Multi; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Osu; using osu.Game.Screens.Multi.Lounge.Components; -using osu.Game.Users; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneLoungeRoomsContainer : MultiplayerTestScene + public class TestSceneLoungeRoomsContainer : RoomManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(RoomsContainer), - typeof(DrawableRoom) - }; - - [Cached(Type = typeof(IRoomManager))] - private TestRoomManager roomManager = new TestRoomManager(); + private RoomsContainer container; [BackgroundDependencyLoader] private void load() { - RoomsContainer container; - Child = container = new RoomsContainer { Anchor = Anchor.Centre, @@ -39,59 +29,88 @@ namespace osu.Game.Tests.Visual.Multiplayer Width = 0.5f, JoinRequested = joinRequested }; + } - AddStep("clear rooms", () => roomManager.Rooms.Clear()); + [Test] + public void TestBasicListChanges() + { + AddRooms(3); - AddStep("add rooms", () => - { - for (int i = 0; i < 3; i++) - { - roomManager.Rooms.Add(new Room - { - RoomID = { Value = i }, - Name = { Value = $"Room {i}" }, - Host = { Value = new User { Username = "Host" } }, - EndDate = { Value = DateTimeOffset.Now + TimeSpan.FromSeconds(10) } - }); - } - }); - - AddAssert("has 2 rooms", () => container.Rooms.Count == 3); - AddStep("remove first room", () => roomManager.Rooms.Remove(roomManager.Rooms.FirstOrDefault())); + AddAssert("has 3 rooms", () => container.Rooms.Count == 3); + AddStep("remove first room", () => RoomManager.Rooms.Remove(RoomManager.Rooms.FirstOrDefault())); AddAssert("has 2 rooms", () => container.Rooms.Count == 2); AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID.Value != 0)); AddStep("select first room", () => container.Rooms.First().Action?.Invoke()); - AddAssert("first room selected", () => Room == roomManager.Rooms.First()); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); AddStep("join first room", () => container.Rooms.First().Action?.Invoke()); - AddAssert("first room joined", () => roomManager.Rooms.First().Status.Value is JoinedRoomStatus); + AddAssert("first room joined", () => RoomManager.Rooms.First().Status.Value is JoinedRoomStatus); } + [Test] + public void TestKeyboardNavigation() + { + AddRooms(3); + + AddAssert("no selection", () => checkRoomSelected(null)); + + press(Key.Down); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + + press(Key.Up); + AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); + + press(Key.Down); + press(Key.Down); + AddAssert("last room selected", () => checkRoomSelected(RoomManager.Rooms.Last())); + + press(Key.Enter); + AddAssert("last room joined", () => RoomManager.Rooms.Last().Status.Value is JoinedRoomStatus); + } + + private void press(Key down) + { + AddStep($"press {down}", () => InputManager.Key(down)); + } + + [Test] + public void TestStringFiltering() + { + AddRooms(4); + + AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); + + AddStep("filter one room", () => container.Filter(new FilterCriteria { SearchString = "1" })); + + AddUntilStep("1 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 1); + + AddStep("remove filter", () => container.Filter(null)); + + AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); + } + + [Test] + public void TestRulesetFiltering() + { + AddRooms(2, new OsuRuleset().RulesetInfo); + AddRooms(3, new CatchRuleset().RulesetInfo); + + AddUntilStep("5 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 5); + + AddStep("filter osu! rooms", () => container.Filter(new FilterCriteria { Ruleset = new OsuRuleset().RulesetInfo })); + + AddUntilStep("2 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 2); + + AddStep("filter catch rooms", () => container.Filter(new FilterCriteria { Ruleset = new CatchRuleset().RulesetInfo })); + + AddUntilStep("3 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 3); + } + + private bool checkRoomSelected(Room room) => Room == room; + private void joinRequested(Room room) => room.Status.Value = new JoinedRoomStatus(); - private class TestRoomManager : IRoomManager - { - public event Action RoomsUpdated - { - add { } - remove { } - } - - public readonly BindableList Rooms = new BindableList(); - IBindableList IRoomManager.Rooms => Rooms; - - public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) => Rooms.Add(room); - - public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) - { - } - - public void PartRoom() - { - } - } - private class JoinedRoomStatus : RoomStatus { public override string Message => "Joined"; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeSubScreen.cs new file mode 100644 index 0000000000..68987127d2 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeSubScreen.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 System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers; +using osu.Game.Screens.Multi.Lounge; +using osu.Game.Screens.Multi.Lounge.Components; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneLoungeSubScreen : RoomManagerTestScene + { + private LoungeSubScreen loungeScreen; + + [BackgroundDependencyLoader] + private void load() + { + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("push screen", () => LoadScreen(loungeScreen = new LoungeSubScreen + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + })); + + AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); + } + + private RoomsContainer roomsContainer => loungeScreen.ChildrenOfType().First(); + + [Test] + public void TestScrollSelectedIntoView() + { + AddRooms(30); + + AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms.First())); + + AddStep("select last room", () => roomsContainer.Rooms.Last().Action?.Invoke()); + + AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms.First())); + AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms.Last())); + } + + private bool checkRoomVisible(DrawableRoom room) => + loungeScreen.ChildrenOfType().First().ScreenSpaceDrawQuad + .Contains(room.ScreenSpaceDrawQuad.Centre); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs new file mode 100644 index 0000000000..01cd26fbe5 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs @@ -0,0 +1,56 @@ +// 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.Beatmaps; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Multi.Components; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMatchBeatmapDetailArea : MultiplayerTestScene + { + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + [Resolved] + private RulesetStore rulesetStore { get; set; } + + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room(); + + Child = new MatchBeatmapDetailArea + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500), + CreateNewItem = createNewItem + }; + }); + + private void createNewItem() + { + Room.Playlist.Add(new PlaylistItem + { + ID = Room.Playlist.Count, + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + RequiredMods = + { + new OsuModHardRock(), + new OsuModDoubleTime(), + new OsuModAutoplay() + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs index e42042f2ea..e5943105b7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs @@ -1,38 +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 System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Online.Multiplayer; -using osu.Game.Online.Multiplayer.GameTypes; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.Multi.Match.Components; +using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMatchHeader : MultiplayerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Header) - }; - public TestSceneMatchHeader() { + Room = new Room(); Room.Playlist.Add(new PlaylistItem { - Beatmap = new BeatmapInfo + Beatmap = { - Metadata = new BeatmapMetadata + Value = new BeatmapInfo { - Title = "Title", - Artist = "Artist", - AuthorString = "Author", - }, - Version = "Version", - Ruleset = new OsuRuleset().RulesetInfo + Metadata = new BeatmapMetadata + { + Title = "Title", + Artist = "Artist", + AuthorString = "Author", + }, + Version = "Version", + Ruleset = new OsuRuleset().RulesetInfo + } }, RequiredMods = { @@ -42,7 +39,8 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - Room.Type.Value = new GameTypeTimeshift(); + Room.Name.Value = "A very awesome room"; + Room.Host.Value = new User { Id = 2, Username = "peppy" }; Child = new Header(); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHostInfo.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHostInfo.cs deleted file mode 100644 index 808a45cdf0..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHostInfo.cs +++ /dev/null @@ -1,35 +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 osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Screens.Multi.Match.Components; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public class TestSceneMatchHostInfo : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(HostInfo) - }; - - private readonly Bindable host = new Bindable(new User { Username = "SomeHost" }); - - public TestSceneMatchHostInfo() - { - HostInfo hostInfo; - - Child = hostInfo = new HostInfo - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }; - - hostInfo.Host.BindTo(host); - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchInfo.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchInfo.cs deleted file mode 100644 index 3f0c0b07b7..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchInfo.cs +++ /dev/null @@ -1,78 +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.Framework.Allocation; -using osu.Game.Beatmaps; -using osu.Game.Online.Multiplayer; -using osu.Game.Online.Multiplayer.RoomStatuses; -using osu.Game.Rulesets; -using osu.Game.Screens.Multi.Match.Components; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - [TestFixture] - public class TestSceneMatchInfo : MultiplayerTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Info), - typeof(HeaderButton), - typeof(ReadyButton), - typeof(ViewBeatmapButton) - }; - - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) - { - Add(new Info()); - - AddStep(@"set name", () => Room.Name.Value = @"Room Name?"); - AddStep(@"set availability", () => Room.Availability.Value = RoomAvailability.FriendsOnly); - AddStep(@"set status", () => Room.Status.Value = new RoomStatusPlaying()); - AddStep(@"set beatmap", () => - { - Room.Playlist.Clear(); - Room.Playlist.Add(new PlaylistItem - { - Beatmap = new BeatmapInfo - { - StarDifficulty = 2.4, - Ruleset = rulesets.GetRuleset(0), - Metadata = new BeatmapMetadata - { - Title = @"My Song", - Artist = @"VisualTests", - AuthorString = @"osu!lazer", - }, - } - }); - }); - - AddStep(@"change name", () => Room.Name.Value = @"Room Name!"); - AddStep(@"change availability", () => Room.Availability.Value = RoomAvailability.InviteOnly); - AddStep(@"change status", () => Room.Status.Value = new RoomStatusOpen()); - AddStep(@"null beatmap", () => Room.Playlist.Clear()); - AddStep(@"change beatmap", () => - { - Room.Playlist.Clear(); - Room.Playlist.Add(new PlaylistItem - { - Beatmap = new BeatmapInfo - { - StarDifficulty = 4.2, - Ruleset = rulesets.GetRuleset(3), - Metadata = new BeatmapMetadata - { - Title = @"Your Song", - Artist = @"Tester", - AuthorString = @"Someone", - }, - } - }); - }); - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs index fa3c392b2e..c24c6c4ba3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Online.API; +using osu.Game.Online.Multiplayer; using osu.Game.Screens.Multi.Match.Components; using osu.Game.Users; using osuTK; @@ -14,9 +15,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMatchLeaderboard : MultiplayerTestScene { + protected override bool UseOnlineAPI => true; + public TestSceneMatchLeaderboard() { - Room.RoomID.Value = 3; + Room = new Room { RoomID = { Value = 3 } }; Add(new MatchLeaderboard { @@ -27,11 +30,8 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } - [Resolved] - private IAPIProvider api { get; set; } - [BackgroundDependencyLoader] - private void load() + private void load(IAPIProvider api) { var req = new GetRoomScoresRequest(); req.Success += v => { }; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchParticipants.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchParticipants.cs deleted file mode 100644 index 50df4022dc..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchParticipants.cs +++ /dev/null @@ -1,52 +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.Graphics; -using osu.Game.Screens.Multi.Match.Components; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - [TestFixture] - public class TestSceneMatchParticipants : MultiplayerTestScene - { - public TestSceneMatchParticipants() - { - Add(new Participants { RelativeSizeAxes = Axes.Both }); - - AddStep(@"set max to null", () => Room.MaxParticipants.Value = null); - AddStep(@"set users", () => Room.Participants.Value = new[] - { - new User - { - Username = @"Feppla", - Id = 4271601, - Country = new Country { FlagName = @"SE" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", - IsSupporter = true, - }, - new User - { - Username = @"Xilver", - Id = 3099689, - Country = new Country { FlagName = @"IL" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", - IsSupporter = true, - }, - new User - { - Username = @"Wucki", - Id = 5287410, - Country = new Country { FlagName = @"FI" }, - CoverUrl = @"https://assets.ppy.sh/user-profile-covers/5287410/5cfeaa9dd41cbce038ecdc9d781396ed4b0108089170bf7f50492ef8eadeb368.jpeg", - IsSupporter = true, - }, - }); - - AddStep(@"set max", () => Room.MaxParticipants.Value = 10); - AddStep(@"clear users", () => Room.Participants.Value = new User[] { }); - AddStep(@"set max to null", () => Room.MaxParticipants.Value = null); - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchResults.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchResults.cs deleted file mode 100644 index 7915a981dd..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchResults.cs +++ /dev/null @@ -1,106 +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.Linq; -using osu.Framework.Allocation; -using osu.Game.Beatmaps; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Scoring; -using osu.Game.Screens.Multi.Match.Components; -using osu.Game.Screens.Multi.Ranking; -using osu.Game.Screens.Multi.Ranking.Pages; -using osu.Game.Screens.Multi.Ranking.Types; -using osu.Game.Screens.Ranking; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public class TestSceneMatchResults : MultiplayerTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(MatchResults), - typeof(RoomLeaderboardPageInfo), - typeof(RoomLeaderboardPage) - }; - - [Resolved] - private BeatmapManager beatmaps { get; set; } - - [BackgroundDependencyLoader] - private void load() - { - var beatmapInfo = beatmaps.QueryBeatmap(b => b.RulesetID == 0); - if (beatmapInfo != null) - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo); - - Room.RoomID.Value = 1; - Room.Name.Value = "an awesome room"; - - LoadScreen(new TestMatchResults(new ScoreInfo - { - User = new User { Id = 10 }, - })); - } - - private class TestMatchResults : MatchResults - { - public TestMatchResults(ScoreInfo score) - : base(score) - { - } - - protected override IEnumerable CreateResultPages() => new[] { new TestRoomLeaderboardPageInfo(Score, Beatmap.Value) }; - } - - private class TestRoomLeaderboardPageInfo : RoomLeaderboardPageInfo - { - private readonly ScoreInfo score; - private readonly WorkingBeatmap beatmap; - - public TestRoomLeaderboardPageInfo(ScoreInfo score, WorkingBeatmap beatmap) - : base(score, beatmap) - { - this.score = score; - this.beatmap = beatmap; - } - - public override ResultsPage CreatePage() => new TestRoomLeaderboardPage(score, beatmap); - } - - private class TestRoomLeaderboardPage : RoomLeaderboardPage - { - public TestRoomLeaderboardPage(ScoreInfo score, WorkingBeatmap beatmap) - : base(score, beatmap) - { - } - - protected override MatchLeaderboard CreateLeaderboard() => new TestMatchLeaderboard(); - } - - private class TestMatchLeaderboard : RoomLeaderboardPage.ResultsMatchLeaderboard - { - protected override APIRequest FetchScores(Action> scoresCallback) - { - var scores = Enumerable.Range(0, 50).Select(createRoomScore).ToArray(); - - scoresCallback?.Invoke(scores); - ScoresLoaded?.Invoke(scores); - - return null; - } - - private APIRoomScoreInfo createRoomScore(int id) => new APIRoomScoreInfo - { - User = new User { Id = id, Username = $"User {id}" }, - Accuracy = 0.98, - TotalScore = 987654, - TotalAttempts = 13, - CompletedBeatmaps = 5 - }; - } - } -} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs index 8d842fc865..07ff56b5c3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSettingsOverlay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -18,11 +17,6 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMatchSettingsOverlay : MultiplayerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(MatchSettingsOverlay) - }; - [Cached(Type = typeof(IRoomManager))] private TestRoomManager roomManager = new TestRoomManager(); @@ -56,7 +50,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("set name", () => Room.Name.Value = "Room name"); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); - AddStep("set beatmap", () => Room.Playlist.Add(new PlaylistItem { Beatmap = CreateBeatmap(Ruleset.Value).BeatmapInfo })); + AddStep("set beatmap", () => Room.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } })); AddAssert("button enabled", () => settings.ApplyButton.Enabled.Value); AddStep("clear name", () => Room.Name.Value = ""); @@ -75,6 +69,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { settings.NameField.Current.Value = expected_name; settings.DurationField.Current.Value = expectedDuration; + Room.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } }); roomManager.CreateRequested = r => { @@ -95,6 +90,9 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("setup", () => { + Room.Name.Value = "Test Room"; + Room.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } }); + fail = true; roomManager.CreateRequested = _ => !fail; }); @@ -135,7 +133,9 @@ namespace osu.Game.Tests.Visual.Multiplayer remove { } } - public IBindableList Rooms { get; } = null; + public Bindable InitialRoomsReceived { get; } = new Bindable(true); + + public IBindableList Rooms => null; public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs new file mode 100644 index 0000000000..55b8902d7b --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs @@ -0,0 +1,191 @@ +// 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.Text; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Multi.Components; +using osu.Game.Screens.Select; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMatchSongSelect : MultiplayerTestScene + { + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + private BeatmapManager manager; + + private RulesetStore rulesets; + + private TestMatchSongSelect songSelect; + + [BackgroundDependencyLoader] + 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)); + + var beatmaps = new List(); + + for (int i = 0; i < 6; i++) + { + int beatmapId = 10 * 10 + i; + + int length = RNG.Next(30000, 200000); + double bpm = RNG.NextSingle(80, 200); + + beatmaps.Add(new BeatmapInfo + { + Ruleset = new OsuRuleset().RulesetInfo, + OnlineBeatmapID = beatmapId, + Version = $"{beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})", + Length = length, + BPM = bpm, + BaseDifficulty = new BeatmapDifficulty + { + OverallDifficulty = 3.5f, + }, + }); + } + + manager.Import(new BeatmapSetInfo + { + OnlineBeatmapSetID = 10, + Hash = new MemoryStream(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())).ComputeMD5Hash(), + Metadata = new BeatmapMetadata + { + // Create random metadata, then we can check if sorting works based on these + Artist = "Some Artist " + RNG.Next(0, 9), + Title = $"Some Song (set id 10), max bpm {beatmaps.Max(b => b.BPM):0.#})", + AuthorString = "Some Guy " + RNG.Next(0, 9), + }, + Beatmaps = beatmaps, + DateAdded = DateTimeOffset.UtcNow, + }).Wait(); + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("reset", () => + { + Ruleset.Value = new OsuRuleset().RulesetInfo; + Beatmap.SetDefault(); + }); + + AddStep("create song select", () => LoadScreen(songSelect = new TestMatchSongSelect())); + AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); + } + + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room(); + }); + + [Test] + public void TestItemAddedIfEmptyOnStart() + { + AddStep("finalise selection", () => songSelect.FinaliseSelection()); + AddAssert("playlist has 1 item", () => Room.Playlist.Count == 1); + } + + [Test] + public void TestItemAddedWhenCreateNewItemClicked() + { + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddAssert("playlist has 1 item", () => Room.Playlist.Count == 1); + } + + [Test] + public void TestItemNotAddedIfExistingOnStart() + { + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("finalise selection", () => songSelect.FinaliseSelection()); + AddAssert("playlist has 1 item", () => Room.Playlist.Count == 1); + } + + [Test] + public void TestAddSameItemMultipleTimes() + { + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddAssert("playlist has 2 items", () => Room.Playlist.Count == 2); + } + + [Test] + public void TestAddItemAfterRearrangement() + { + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("rearrange", () => + { + var item = Room.Playlist[0]; + Room.Playlist.RemoveAt(0); + Room.Playlist.Add(item); + }); + + AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddAssert("new item has id 2", () => Room.Playlist.Last().ID == 2); + } + + /// + /// Tests that the same instances are not shared between two playlist items. + /// + [Test] + public void TestNewItemHasNewModInstances() + { + AddStep("set dt mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); + AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem()); + AddStep("change mod rate", () => ((OsuModDoubleTime)SelectedMods.Value[0]).SpeedChange.Value = 2); + AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem()); + + AddAssert("item 1 has rate 1.5", () => Precision.AlmostEquals(1.5, ((OsuModDoubleTime)Room.Playlist.First().RequiredMods[0]).SpeedChange.Value)); + AddAssert("item 2 has rate 2", () => Precision.AlmostEquals(2, ((OsuModDoubleTime)Room.Playlist.Last().RequiredMods[0]).SpeedChange.Value)); + } + + /// + /// Tests that the global mod instances are not retained by the rooms, as global mod instances are retained and re-used by the mod select overlay. + /// + [Test] + public void TestGlobalModInstancesNotRetained() + { + OsuModDoubleTime mod = null; + + AddStep("set dt mod and store", () => + { + SelectedMods.Value = new[] { new OsuModDoubleTime() }; + + // Mod select overlay replaces our mod. + mod = (OsuModDoubleTime)SelectedMods.Value[0]; + }); + + AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem()); + + AddStep("change stored mod rate", () => mod.SpeedChange.Value = 2); + AddAssert("item has rate 1.5", () => Precision.AlmostEquals(1.5, ((OsuModDoubleTime)Room.Playlist.First().RequiredMods[0]).SpeedChange.Value)); + } + + private class TestMatchSongSelect : MatchSongSelect + { + public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs new file mode 100644 index 0000000000..2e22317539 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSubScreen.cs @@ -0,0 +1,177 @@ +// 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.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Multi; +using osu.Game.Screens.Multi.Match; +using osu.Game.Screens.Multi.Match.Components; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMatchSubScreen : MultiplayerTestScene + { + protected override bool UseOnlineAPI => true; + + [Cached(typeof(IRoomManager))] + private readonly TestRoomManager roomManager = new TestRoomManager(); + + private BeatmapManager manager; + private RulesetStore rulesets; + + private TestMatchSubScreen match; + + [BackgroundDependencyLoader] + 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)); + + manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait(); + } + + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room(); + }); + + [SetUpSteps] + public void SetupSteps() + { + AddStep("load match", () => LoadScreen(match = new TestMatchSubScreen(Room))); + AddUntilStep("wait for load", () => match.IsCurrentScreen()); + } + + [Test] + public void TestLoadSimpleMatch() + { + AddStep("set room properties", () => + { + Room.RoomID.Value = 1; + Room.Name.Value = "my awesome room"; + Room.Host.Value = new User { Id = 2, Username = "peppy" }; + Room.RecentParticipants.Add(Room.Host.Value); + Room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + }); + }); + } + + [Test] + public void TestPlaylistItemSelectedOnCreate() + { + AddStep("set room properties", () => + { + Room.Name.Value = "my awesome room"; + Room.Host.Value = new User { Id = 2, Username = "peppy" }; + Room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + }); + }); + + AddStep("move mouse to create button", () => + { + var footer = match.ChildrenOfType
().Single(); + InputManager.MoveMouseTo(footer.ChildrenOfType().Single()); + }); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("first playlist item selected", () => match.SelectedItem.Value == Room.Playlist[0]); + } + + [Test] + public void TestBeatmapUpdatedOnReImport() + { + BeatmapSetInfo importedSet = null; + + AddStep("import altered beatmap", () => + { + var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo); + beatmap.BeatmapInfo.BaseDifficulty.CircleSize = 1; + + importedSet = manager.Import(beatmap.BeatmapInfo.BeatmapSet).Result; + }); + + AddStep("load room", () => + { + Room.Name.Value = "my awesome room"; + Room.Host.Value = new User { Id = 2, Username = "peppy" }; + Room.Playlist.Add(new PlaylistItem + { + Beatmap = { Value = importedSet.Beatmaps[0] }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + }); + }); + + AddStep("create room", () => + { + InputManager.MoveMouseTo(match.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("match has altered beatmap", () => match.Beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty.CircleSize == 1); + + AddStep("re-import original beatmap", () => manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait()); + + AddAssert("match has original beatmap", () => match.Beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty.CircleSize != 1); + } + + private class TestMatchSubScreen : MatchSubScreen + { + public new Bindable SelectedItem => base.SelectedItem; + + public new Bindable Beatmap => base.Beatmap; + + public TestMatchSubScreen(Room room) + : base(room) + { + } + } + + private class TestRoomManager : IRoomManager + { + public event Action RoomsUpdated + { + add => throw new NotImplementedException(); + remove => throw new NotImplementedException(); + } + + public Bindable InitialRoomsReceived { get; } = new Bindable(true); + + public IBindableList Rooms { get; } = new BindableList(); + + public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) + { + room.RoomID.Value = 1; + onSuccess?.Invoke(room); + } + + public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) => onSuccess?.Invoke(room); + + public void PartRoom() + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs index 3f89f636b1..76ab402b72 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiHeader.cs @@ -16,7 +16,9 @@ namespace osu.Game.Tests.Visual.Multiplayer { int index = 0; - OsuScreenStack screenStack = new OsuScreenStack(new TestMultiplayerSubScreen(index)) { RelativeSizeAxes = Axes.Both }; + OsuScreenStack screenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }; + + screenStack.Push(new TestMultiplayerSubScreen(index)); Children = new Drawable[] { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs index 069e133c2b..3924b0333f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiScreen.cs @@ -1,29 +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; -using System.Collections.Generic; using NUnit.Framework; -using osu.Game.Screens.Multi.Lounge; -using osu.Game.Screens.Multi.Lounge.Components; +using osu.Framework.Allocation; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Multiplayer { [TestFixture] public class TestSceneMultiScreen : ScreenTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Screens.Multi.Multiplayer), - typeof(LoungeSubScreen), - typeof(FilterControl) - }; + protected override bool UseOnlineAPI => true; + + [Cached] + private MusicController musicController { get; set; } = new MusicController(); public TestSceneMultiScreen() { Screens.Multi.Multiplayer multi = new Screens.Multi.Multiplayer(); - AddStep(@"show", () => LoadScreen(multi)); + AddStep("show", () => LoadScreen(multi)); + AddUntilStep("wait for loaded", () => multi.IsLoaded); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedParticipants.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedParticipants.cs new file mode 100644 index 0000000000..b6bfa7c93a --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedParticipants.cs @@ -0,0 +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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Multi.Components; +using osuTK; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneOverlinedParticipants : MultiplayerTestScene + { + protected override bool UseOnlineAPI => true; + + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room { RoomID = { Value = 7 } }; + }); + + [Test] + public void TestHorizontalLayout() + { + AddStep("create component", () => + { + Child = new ParticipantsDisplay(Direction.Horizontal) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + }; + }); + } + + [Test] + public void TestVerticalLayout() + { + AddStep("create component", () => + { + Child = new ParticipantsDisplay(Direction.Vertical) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500) + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedPlaylist.cs new file mode 100644 index 0000000000..14984988cb --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneOverlinedPlaylist.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 osu.Framework.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Multi; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneOverlinedPlaylist : MultiplayerTestScene + { + protected override bool UseOnlineAPI => true; + + public TestSceneOverlinedPlaylist() + { + Room = new Room { RoomID = { Value = 7 } }; + + for (int i = 0; i < 10; i++) + { + Room.Playlist.Add(new PlaylistItem + { + ID = i, + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + }); + } + + Add(new DrawableRoomPlaylist(false, false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500), + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneParticipantsList.cs new file mode 100644 index 0000000000..f71c5fc5d2 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneParticipantsList.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.Online.Multiplayer; +using osu.Game.Screens.Multi.Components; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneParticipantsList : MultiplayerTestScene + { + protected override bool UseOnlineAPI => true; + + [SetUp] + public void Setup() => Schedule(() => + { + Room = new Room { RoomID = { Value = 7 } }; + }); + + public TestSceneParticipantsList() + { + Add(new ParticipantsList { RelativeSizeAxes = Axes.Both }); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs index 74d1645f6d..1925e0ef4f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Online.Multiplayer; @@ -13,13 +11,6 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneRoomStatus : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(RoomStatusEnded), - typeof(RoomStatusOpen), - typeof(RoomStatusPlaying) - }; - public TestSceneRoomStatus() { Child = new FillFlowContainer diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs new file mode 100644 index 0000000000..03fd2b968c --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTimeshiftResultsScreen.cs @@ -0,0 +1,376 @@ +// 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.Net; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Multi.Ranking; +using osu.Game.Screens.Ranking; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneTimeshiftResultsScreen : ScreenTestScene + { + private const int scores_per_result = 10; + + private TestResultsScreen resultsScreen; + private int currentScoreId; + private bool requestComplete; + + [SetUp] + public void Setup() => Schedule(() => + { + currentScoreId = 0; + requestComplete = false; + bindHandler(); + }); + + [Test] + public void TestShowWithUserScore() + { + ScoreInfo userScore = null; + + AddStep("bind user score info handler", () => + { + userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + bindHandler(userScore: userScore); + }); + + createResults(() => userScore); + waitForDisplay(); + + AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); + } + + [Test] + public void TestShowNullUserScore() + { + createResults(); + waitForDisplay(); + + AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); + } + + [Test] + public void TestShowUserScoreWithDelay() + { + ScoreInfo userScore = null; + + AddStep("bind user score info handler", () => + { + userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + bindHandler(3000, userScore); + }); + + createResults(() => userScore); + waitForDisplay(); + + AddAssert("more than 1 panel displayed", () => this.ChildrenOfType().Count() > 1); + AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); + } + + [Test] + public void TestShowNullUserScoreWithDelay() + { + AddStep("bind delayed handler", () => bindHandler(3000)); + + createResults(); + waitForDisplay(); + + AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); + } + + [Test] + public void TestFetchWhenScrolledToTheRight() + { + createResults(); + waitForDisplay(); + + AddStep("bind delayed handler", () => bindHandler(3000)); + + for (int i = 0; i < 2; i++) + { + int beforePanelCount = 0; + + AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); + AddStep("scroll to right", () => resultsScreen.ScorePanelList.ChildrenOfType().Single().ScrollToEnd(false)); + + AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible); + waitForDisplay(); + + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); + AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden); + } + } + + [Test] + public void TestFetchWhenScrolledToTheLeft() + { + ScoreInfo userScore = null; + + AddStep("bind user score info handler", () => + { + userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + bindHandler(userScore: userScore); + }); + + createResults(() => userScore); + waitForDisplay(); + + AddStep("bind delayed handler", () => bindHandler(3000)); + + for (int i = 0; i < 2; i++) + { + int beforePanelCount = 0; + + AddStep("get panel count", () => beforePanelCount = this.ChildrenOfType().Count()); + AddStep("scroll to left", () => resultsScreen.ScorePanelList.ChildrenOfType().Single().ScrollToStart(false)); + + AddAssert("left loading spinner shown", () => resultsScreen.LeftSpinner.State.Value == Visibility.Visible); + waitForDisplay(); + + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); + AddAssert("left loading spinner hidden", () => resultsScreen.LeftSpinner.State.Value == Visibility.Hidden); + } + } + + private void createResults(Func getScore = null) + { + AddStep("load results", () => + { + LoadScreen(resultsScreen = new TestResultsScreen(getScore?.Invoke(), 1, new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo } + })); + }); + } + + private void waitForDisplay() + { + AddUntilStep("wait for request to complete", () => requestComplete); + AddWaitStep("wait for display", 5); + } + + private void bindHandler(double delay = 0, ScoreInfo userScore = null, bool failRequests = false) => ((DummyAPIAccess)API).HandleRequest = request => + { + requestComplete = false; + + if (failRequests) + { + triggerFail(request, delay); + return; + } + + switch (request) + { + case ShowPlaylistUserScoreRequest s: + if (userScore == null) + triggerFail(s, delay); + else + triggerSuccess(s, createUserResponse(userScore), delay); + break; + + case IndexPlaylistScoresRequest i: + triggerSuccess(i, createIndexResponse(i), delay); + break; + } + }; + + private void triggerSuccess(APIRequest req, T result, double delay) + 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); + } + } + + private void triggerFail(APIRequest req, double delay) + { + 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.")); + } + } + + private MultiplayerScore createUserResponse([NotNull] ScoreInfo userScore) + { + var multiplayerUserScore = new MultiplayerScore + { + ID = (int)(userScore.OnlineScoreID ?? currentScoreId++), + Accuracy = userScore.Accuracy, + EndedAt = userScore.Date, + Passed = userScore.Passed, + Rank = userScore.Rank, + Position = 200, + MaxCombo = userScore.MaxCombo, + TotalScore = userScore.TotalScore, + User = userScore.User, + Statistics = userScore.Statistics, + ScoresAround = new MultiplayerScoresAround + { + Higher = new MultiplayerScores(), + Lower = new MultiplayerScores() + } + }; + + for (int i = 1; i <= scores_per_result; i++) + { + multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = userScore.Accuracy, + EndedAt = userScore.Date, + Passed = true, + Rank = userScore.Rank, + MaxCombo = userScore.MaxCombo, + TotalScore = userScore.TotalScore - i, + User = new User + { + Id = 2, + Username = $"peppy{i}", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, + Statistics = userScore.Statistics + }); + + multiplayerUserScore.ScoresAround.Higher.Scores.Add(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = userScore.Accuracy, + EndedAt = userScore.Date, + Passed = true, + Rank = userScore.Rank, + MaxCombo = userScore.MaxCombo, + TotalScore = userScore.TotalScore + i, + User = new User + { + Id = 2, + Username = $"peppy{i}", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, + Statistics = userScore.Statistics + }); + } + + addCursor(multiplayerUserScore.ScoresAround.Lower); + addCursor(multiplayerUserScore.ScoresAround.Higher); + + return multiplayerUserScore; + } + + private IndexedMultiplayerScores createIndexResponse(IndexPlaylistScoresRequest req) + { + var result = new IndexedMultiplayerScores(); + + long startTotalScore = req.Cursor?.Properties["total_score"].ToObject() ?? 1000000; + string sort = req.IndexParams?.Properties["sort"].ToObject() ?? "score_desc"; + + for (int i = 1; i <= scores_per_result; i++) + { + result.Scores.Add(new MultiplayerScore + { + ID = currentScoreId++, + Accuracy = 1, + EndedAt = DateTimeOffset.Now, + Passed = true, + Rank = ScoreRank.X, + MaxCombo = 1000, + TotalScore = startTotalScore + (sort == "score_asc" ? i : -i), + User = new User + { + Id = 2, + Username = $"peppy{i}", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, + Statistics = new Dictionary + { + { HitResult.Miss, 1 }, + { HitResult.Meh, 50 }, + { HitResult.Good, 100 }, + { HitResult.Great, 300 } + } + }); + } + + addCursor(result); + + return result; + } + + private void addCursor(MultiplayerScores scores) + { + scores.Cursor = new Cursor + { + Properties = new Dictionary + { + { "total_score", JToken.FromObject(scores.Scores[^1].TotalScore) }, + { "score_id", JToken.FromObject(scores.Scores[^1].ID) }, + } + }; + + scores.Params = new IndexScoresParams + { + Properties = new Dictionary + { + { "sort", JToken.FromObject(scores.Scores[^1].TotalScore > scores.Scores[^2].TotalScore ? "score_asc" : "score_desc") } + } + }; + } + + private class TestResultsScreen : TimeshiftResultsScreen + { + public new LoadingSpinner LeftSpinner => base.LeftSpinner; + public new LoadingSpinner CentreSpinner => base.CentreSpinner; + public new LoadingSpinner RightSpinner => base.RightSpinner; + public new ScorePanelList ScorePanelList => base.ScorePanelList; + + public TestResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) + : base(score, roomId, playlistItem, allowRetry) + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs b/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs new file mode 100644 index 0000000000..4c18cfa61c --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/OsuGameTestScene.cs @@ -0,0 +1,150 @@ +// 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.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens; +using osu.Game.Screens.Menu; +using osuTK.Graphics; +using IntroSequence = osu.Game.Configuration.IntroSequence; + +namespace osu.Game.Tests.Visual.Navigation +{ + /// + /// A scene which tests full game flow. + /// + public abstract class OsuGameTestScene : OsuManualInputManagerTestScene + { + private GameHost host; + + protected TestOsuGame Game; + + protected override bool UseFreshStoragePerRun => true; + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + this.host = host; + + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }; + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Create new game instance", () => + { + if (Game != null) + { + Remove(Game); + Game.Dispose(); + } + + RecycleLocalStorage(); + + // see MouseSettings + var frameworkConfig = host.Dependencies.Get(); + frameworkConfig.GetBindable(FrameworkSetting.CursorSensitivity).Disabled = false; + + CreateGame(); + }); + + AddUntilStep("Wait for load", () => Game.IsLoaded); + AddUntilStep("Wait for intro", () => Game.ScreenStack.CurrentScreen is IntroScreen); + + ConfirmAtMainMenu(); + } + + protected void CreateGame() + { + Game = new TestOsuGame(LocalStorage, API); + Game.SetHost(host); + + // 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); + + Add(Game); + } + + protected void PushAndConfirm(Func newScreen) + { + Screen screen = null; + AddStep("Push new screen", () => Game.ScreenStack.Push(screen = newScreen())); + AddUntilStep("Wait for new screen", () => Game.ScreenStack.CurrentScreen == screen && screen.IsLoaded); + } + + protected void ConfirmAtMainMenu() => AddUntilStep("Wait for main menu", () => Game.ScreenStack.CurrentScreen is MainMenu menu && menu.IsLoaded); + + public class TestOsuGame : OsuGame + { + public new ScreenStack ScreenStack => base.ScreenStack; + + public new BackButton BackButton => base.BackButton; + + public new BeatmapManager BeatmapManager => base.BeatmapManager; + + public new ScoreManager ScoreManager => base.ScoreManager; + + public new SettingsPanel Settings => base.Settings; + + public new MusicController MusicController => base.MusicController; + + public new OsuConfigManager LocalConfig => base.LocalConfig; + + public new Bindable Beatmap => base.Beatmap; + + public new Bindable Ruleset => base.Ruleset; + + // if we don't do this, when running under nUnit the version that gets populated is that of nUnit. + public override string Version => "test game"; + + protected override Loader CreateLoader() => new TestLoader(); + + public new void PerformFromScreen(Action action, IEnumerable validScreens = null) => base.PerformFromScreen(action, validScreens); + + public TestOsuGame(Storage storage, IAPIProvider api) + { + Storage = storage; + API = api; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + API.Login("Rhythm Champion", "osu!"); + + Dependencies.Get().Set(Static.MutedAudioNotificationShownOnce, true); + } + } + + public class TestLoader : Loader + { + protected override ShaderPrecompiler CreateShaderPrecompiler() => new TestShaderPrecompiler(); + + private class TestShaderPrecompiler : ShaderPrecompiler + { + protected override bool AllLoaded => true; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs new file mode 100644 index 0000000000..75c6a2b733 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.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.Screens.Menu; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestScenePerformFromScreen : OsuGameTestScene + { + [Test] + public void TestPerformAtMenu() + { + AddAssert("could perform immediately", () => + { + bool actionPerformed = false; + Game.PerformFromScreen(_ => actionPerformed = true); + return actionPerformed; + }); + } + + [Test] + public void TestPerformAtSongSelect() + { + PushAndConfirm(() => new PlaySongSelect()); + + AddAssert("could perform immediately", () => + { + bool actionPerformed = false; + Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) }); + return actionPerformed; + }); + } + + [Test] + public void TestPerformAtMenuFromSongSelect() + { + PushAndConfirm(() => new PlaySongSelect()); + + bool actionPerformed = false; + AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); + AddUntilStep("returned to menu", () => Game.ScreenStack.CurrentScreen is MainMenu); + AddAssert("did perform", () => actionPerformed); + } + + [Test] + public void TestPerformAtSongSelectFromPlayerLoader() + { + PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new PlayerLoader(() => new Player())); + + bool actionPerformed = false; + AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(PlaySongSelect) })); + AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("did perform", () => actionPerformed); + } + + [Test] + public void TestPerformAtMenuFromPlayerLoader() + { + PushAndConfirm(() => new PlaySongSelect()); + PushAndConfirm(() => new PlayerLoader(() => new Player())); + + bool actionPerformed = false; + AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); + AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is MainMenu); + AddAssert("did perform", () => actionPerformed); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs new file mode 100644 index 0000000000..a003b9ae4d --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs @@ -0,0 +1,150 @@ +// 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.Screens; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Menu; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestScenePresentBeatmap : OsuGameTestScene + { + [Test] + public void TestFromMainMenu() + { + var firstImport = importBeatmap(1); + var secondimport = importBeatmap(3); + + presentAndConfirm(firstImport); + returnToMenu(); + presentAndConfirm(secondimport); + returnToMenu(); + presentSecondDifficultyAndConfirm(firstImport, 1); + returnToMenu(); + presentSecondDifficultyAndConfirm(secondimport, 3); + } + + [Test] + public void TestFromMainMenuDifferentRuleset() + { + var firstImport = importBeatmap(1); + var secondimport = importBeatmap(3, new ManiaRuleset().RulesetInfo); + + presentAndConfirm(firstImport); + returnToMenu(); + presentAndConfirm(secondimport); + returnToMenu(); + presentSecondDifficultyAndConfirm(firstImport, 1); + returnToMenu(); + presentSecondDifficultyAndConfirm(secondimport, 3); + } + + [Test] + public void TestFromSongSelect() + { + var firstImport = importBeatmap(1); + presentAndConfirm(firstImport); + + var secondimport = importBeatmap(3); + presentAndConfirm(secondimport); + + presentSecondDifficultyAndConfirm(firstImport, 1); + presentSecondDifficultyAndConfirm(secondimport, 3); + } + + [Test] + public void TestFromSongSelectDifferentRuleset() + { + var firstImport = importBeatmap(1); + presentAndConfirm(firstImport); + + var secondimport = importBeatmap(3, new ManiaRuleset().RulesetInfo); + presentAndConfirm(secondimport); + + presentSecondDifficultyAndConfirm(firstImport, 1); + presentSecondDifficultyAndConfirm(secondimport, 3); + } + + private void returnToMenu() + { + // if we don't pause, there's a chance the track may change at the main menu out of our control (due to reaching the end of the track). + AddStep("pause audio", () => + { + if (Game.MusicController.IsPlaying) + Game.MusicController.TogglePause(); + }); + + AddStep("return to menu", () => Game.ScreenStack.CurrentScreen.Exit()); + AddUntilStep("wait for menu", () => Game.ScreenStack.CurrentScreen is MainMenu); + } + + private Func importBeatmap(int i, RulesetInfo ruleset = null) + { + BeatmapSetInfo imported = null; + AddStep($"import beatmap {i}", () => + { + var difficulty = new BeatmapDifficulty(); + var metadata = new BeatmapMetadata + { + Artist = "SomeArtist", + AuthorString = "SomeAuthor", + Title = $"import {i}" + }; + + imported = Game.BeatmapManager.Import(new BeatmapSetInfo + { + Hash = Guid.NewGuid().ToString(), + OnlineBeatmapSetID = i, + Metadata = metadata, + Beatmaps = new List + { + new BeatmapInfo + { + OnlineBeatmapID = i * 1024, + Metadata = metadata, + BaseDifficulty = difficulty, + Ruleset = ruleset ?? new OsuRuleset().RulesetInfo + }, + new BeatmapInfo + { + OnlineBeatmapID = i * 2048, + Metadata = metadata, + BaseDifficulty = difficulty, + Ruleset = ruleset ?? new OsuRuleset().RulesetInfo + }, + } + }).Result; + }); + + AddAssert($"import {i} succeeded", () => imported != null); + + return () => imported; + } + + private void presentAndConfirm(Func getImport) + { + AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); + + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect); + AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapSetInfo.ID == getImport().ID); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Beatmaps.First().Ruleset.ID); + } + + private void presentSecondDifficultyAndConfirm(Func getImport, int importedID) + { + Predicate pred = b => b.OnlineBeatmapID == importedID * 2048; + AddStep("present difficulty", () => Game.PresentBeatmap(getImport(), pred)); + + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect); + AddUntilStep("correct beatmap displayed", () => Game.Beatmap.Value.BeatmapInfo.OnlineBeatmapID == importedID * 2048); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Beatmaps.First().Ruleset.ID); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs new file mode 100644 index 0000000000..52b577b402 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -0,0 +1,171 @@ +// 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.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestScenePresentScore : OsuGameTestScene + { + private BeatmapSetInfo beatmap; + + [SetUpSteps] + public new void SetUpSteps() + { + AddStep("import beatmap", () => + { + var difficulty = new BeatmapDifficulty(); + var metadata = new BeatmapMetadata + { + Artist = "SomeArtist", + AuthorString = "SomeAuthor", + Title = "import" + }; + + beatmap = Game.BeatmapManager.Import(new BeatmapSetInfo + { + Hash = Guid.NewGuid().ToString(), + OnlineBeatmapSetID = 1, + Metadata = metadata, + Beatmaps = new List + { + new BeatmapInfo + { + OnlineBeatmapID = 1 * 1024, + Metadata = metadata, + BaseDifficulty = difficulty, + Ruleset = new OsuRuleset().RulesetInfo + }, + new BeatmapInfo + { + OnlineBeatmapID = 1 * 2048, + Metadata = metadata, + BaseDifficulty = difficulty, + Ruleset = new OsuRuleset().RulesetInfo + }, + } + }).Result; + }); + } + + [Test] + public void TestFromMainMenu([Values] ScorePresentType type) + { + var firstImport = importScore(1); + var secondimport = importScore(3); + + presentAndConfirm(firstImport, type); + returnToMenu(); + presentAndConfirm(secondimport, type); + returnToMenu(); + returnToMenu(); + } + + [Test] + public void TestFromMainMenuDifferentRuleset([Values] ScorePresentType type) + { + var firstImport = importScore(1); + var secondimport = importScore(3, new ManiaRuleset().RulesetInfo); + + presentAndConfirm(firstImport, type); + returnToMenu(); + presentAndConfirm(secondimport, type); + returnToMenu(); + returnToMenu(); + } + + [Test] + public void TestFromSongSelect([Values] ScorePresentType type) + { + var firstImport = importScore(1); + presentAndConfirm(firstImport, type); + + var secondimport = importScore(3); + presentAndConfirm(secondimport, type); + } + + [Test] + public void TestFromSongSelectDifferentRuleset([Values] ScorePresentType type) + { + var firstImport = importScore(1); + presentAndConfirm(firstImport, type); + + var secondimport = importScore(3, new ManiaRuleset().RulesetInfo); + presentAndConfirm(secondimport, type); + } + + private void returnToMenu() + { + // if we don't pause, there's a chance the track may change at the main menu out of our control (due to reaching the end of the track). + AddStep("pause audio", () => + { + if (Game.MusicController.IsPlaying) + Game.MusicController.TogglePause(); + }); + + AddStep("return to menu", () => Game.ScreenStack.CurrentScreen.Exit()); + AddUntilStep("wait for menu", () => Game.ScreenStack.CurrentScreen is MainMenu); + } + + private Func importScore(int i, RulesetInfo ruleset = null) + { + ScoreInfo imported = null; + AddStep($"import score {i}", () => + { + imported = Game.ScoreManager.Import(new ScoreInfo + { + Hash = Guid.NewGuid().ToString(), + OnlineScoreID = i, + Beatmap = beatmap.Beatmaps.First(), + Ruleset = ruleset ?? new OsuRuleset().RulesetInfo + }).Result; + }); + + AddAssert($"import {i} succeeded", () => imported != null); + + return () => imported; + } + + /// + /// Some tests test waiting for a particular screen twice in a row, but expect a new instance each time. + /// There's a case where they may succeed incorrectly if we don't compare against the previous instance. + /// + private IScreen lastWaitedScreen; + + private void presentAndConfirm(Func getImport, ScorePresentType type) + { + AddStep("present score", () => Game.PresentScore(getImport(), type)); + + switch (type) + { + case ScorePresentType.Results: + AddUntilStep("wait for results", () => lastWaitedScreen != Game.ScreenStack.CurrentScreen && Game.ScreenStack.CurrentScreen is ResultsScreen); + AddStep("store last waited screen", () => lastWaitedScreen = Game.ScreenStack.CurrentScreen); + AddUntilStep("correct score displayed", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score.ID == getImport().ID); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Ruleset.ID); + break; + + case ScorePresentType.Gameplay: + AddUntilStep("wait for player loader", () => lastWaitedScreen != Game.ScreenStack.CurrentScreen && Game.ScreenStack.CurrentScreen is ReplayPlayerLoader); + AddStep("store last waited screen", () => lastWaitedScreen = Game.ScreenStack.CurrentScreen); + AddUntilStep("correct score displayed", () => ((ReplayPlayerLoader)Game.ScreenStack.CurrentScreen).Score.ID == getImport().ID); + AddAssert("correct ruleset selected", () => Game.Ruleset.Value.ID == getImport().Ruleset.ID); + break; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs new file mode 100644 index 0000000000..d87854a7ea --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -0,0 +1,214 @@ +// 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.Containers; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Toolbar; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Options; +using osu.Game.Tests.Beatmaps.IO; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestSceneScreenNavigation : OsuGameTestScene + { + private const float click_padding = 25; + + private Vector2 backButtonPosition => Game.ToScreenSpace(new Vector2(click_padding, Game.LayoutRectangle.Bottom - click_padding)); + + private Vector2 optionsButtonPosition => Game.ToScreenSpace(new Vector2(click_padding, click_padding)); + + [Test] + public void TestExitSongSelectWithEscape() + { + TestSongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestSongSelect()); + AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); + AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + pushEscape(); + AddAssert("Overlay was hidden", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); + exitViaEscapeAndConfirm(); + } + + [TestCase(true)] + [TestCase(false)] + public void TestSongContinuesAfterExitPlayer(bool withUserPause) + { + Player player = null; + + WorkingBeatmap beatmap() => Game.Beatmap.Value; + + PushAndConfirm(() => new TestSongSelect()); + + AddStep("import beatmap", () => ImportBeatmapTest.LoadOszIntoOsu(Game, virtualTrack: true).Wait()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + if (withUserPause) + AddStep("pause", () => Game.Dependencies.Get().Stop(true)); + + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null); + AddUntilStep("wait for fail", () => player.HasFailed); + + AddUntilStep("wait for track stop", () => !Game.MusicController.IsPlaying); + AddAssert("Ensure time before preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().Metadata.PreviewTime); + + pushEscape(); + + AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying); + AddAssert("Ensure time wasn't reset to preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().Metadata.PreviewTime); + } + + [Test] + public void TestMenuMakesMusic() + { + TestSongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestSongSelect()); + + AddUntilStep("wait for no track", () => Game.MusicController.CurrentTrack.IsDummyDevice); + + AddStep("return to menu", () => songSelect.Exit()); + + AddUntilStep("wait for track", () => !Game.MusicController.CurrentTrack.IsDummyDevice && Game.MusicController.IsPlaying); + } + + [Test] + public void TestExitSongSelectWithClick() + { + TestSongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestSongSelect()); + 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)); + + AddStep("Click back button", () => InputManager.Click(MouseButton.Left)); + AddUntilStep("Overlay was hidden", () => songSelect.ModSelectOverlay.State.Value == Visibility.Hidden); + exitViaBackButtonAndConfirm(); + } + + [Test] + public void TestExitMultiWithEscape() + { + PushAndConfirm(() => new Screens.Multi.Multiplayer()); + exitViaEscapeAndConfirm(); + } + + [Test] + public void TestExitMultiWithBackButton() + { + PushAndConfirm(() => new Screens.Multi.Multiplayer()); + exitViaBackButtonAndConfirm(); + } + + [Test] + public void TestOpenOptionsAndExitWithEscape() + { + AddUntilStep("Wait for options to load", () => Game.Settings.IsLoaded); + AddStep("Enter menu", () => InputManager.Key(Key.Enter)); + AddStep("Move mouse to options overlay", () => InputManager.MoveMouseTo(optionsButtonPosition)); + AddStep("Click options overlay", () => InputManager.Click(MouseButton.Left)); + AddAssert("Options overlay was opened", () => Game.Settings.State.Value == Visibility.Visible); + AddStep("Hide options overlay using escape", () => InputManager.Key(Key.Escape)); + AddAssert("Options overlay was closed", () => Game.Settings.State.Value == Visibility.Hidden); + } + + [Test] + public void TestWaitForNextTrackInMenu() + { + bool trackCompleted = false; + + AddUntilStep("Wait for music controller", () => Game.MusicController.IsLoaded); + AddStep("Seek close to end", () => + { + Game.MusicController.SeekTo(Game.MusicController.CurrentTrack.Length - 1000); + Game.MusicController.CurrentTrack.Completed += () => trackCompleted = true; + }); + + AddUntilStep("Track was completed", () => trackCompleted); + AddUntilStep("Track was restarted", () => Game.MusicController.IsPlaying); + } + + [Test] + public void TestModSelectInput() + { + TestSongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestSongSelect()); + + AddStep("Show mods overlay", () => songSelect.ModSelectOverlay.Show()); + + AddStep("Change ruleset to osu!taiko", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Number2); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddAssert("Ruleset changed to osu!taiko", () => Game.Toolbar.ChildrenOfType().Single().Current.Value.ID == 1); + + AddAssert("Mods overlay still visible", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); + } + + [Test] + public void TestBeatmapOptionsInput() + { + TestSongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestSongSelect()); + + AddStep("Show options overlay", () => songSelect.BeatmapOptionsOverlay.Show()); + + AddStep("Change ruleset to osu!taiko", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Number2); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddAssert("Ruleset changed to osu!taiko", () => Game.Toolbar.ChildrenOfType().Single().Current.Value.ID == 1); + + AddAssert("Options overlay still visible", () => songSelect.BeatmapOptionsOverlay.State.Value == Visibility.Visible); + } + + private void pushEscape() => + AddStep("Press escape", () => InputManager.Key(Key.Escape)); + + private void exitViaEscapeAndConfirm() + { + pushEscape(); + ConfirmAtMainMenu(); + } + + private void exitViaBackButtonAndConfirm() + { + AddStep("Move mouse to backButton", () => InputManager.MoveMouseTo(backButtonPosition)); + AddStep("Click back button", () => InputManager.Click(MouseButton.Left)); + ConfirmAtMainMenu(); + } + + private class TestSongSelect : PlaySongSelect + { + public ModSelectOverlay ModSelectOverlay => ModSelect; + + public BeatmapOptionsOverlay BeatmapOptionsOverlay => BeatmapOptions; + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.cs new file mode 100644 index 0000000000..c0b77b580e --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSettingsMigration.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 NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Configuration; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestSettingsMigration : OsuGameTestScene + { + public override void RecycleLocalStorage() + { + base.RecycleLocalStorage(); + + using (var config = new OsuConfigManager(LocalStorage)) + { + config.Set(OsuSetting.Version, "2020.101.0"); + config.Set(OsuSetting.DisplayStarsMaximum, 10.0); + } + } + + [Test] + public void TestDisplayStarsMigration() + { + 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("force save config", () => Game.LocalConfig.Save()); + + AddStep("remove game", () => Remove(Game)); + + AddStep("create game again", CreateGame); + + AddUntilStep("Wait for load", () => Game.IsLoaded); + + AddAssert("config did not migrate value", () => Precision.AlmostEquals(Game.LocalConfig.Get(OsuSetting.DisplayStarsMaximum), 10)); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs index 35449f5687..6c8ec917ba 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs @@ -1,41 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Online.API; using osu.Game.Overlays; -using osu.Game.Overlays.AccountCreation; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { public class TestSceneAccountCreationOverlay : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ErrorTextFlowContainer), - typeof(AccountCreationBackground), - typeof(ScreenEntry), - typeof(ScreenWarning), - typeof(ScreenWelcome), - typeof(AccountCreationScreen), - }; + private readonly Container userPanelArea; - [Cached(typeof(IAPIProvider))] - private DummyAPIAccess api = new DummyAPIAccess(); + private Bindable localUser; public TestSceneAccountCreationOverlay() { - Container userPanelArea; AccountCreationOverlay accountCreation; Children = new Drawable[] { - api, accountCreation = new AccountCreationOverlay(), userPanelArea = new Container { @@ -46,11 +32,18 @@ namespace osu.Game.Tests.Visual.Online }, }; - api.Logout(); - api.LocalUser.BindValueChanged(user => { userPanelArea.Child = new UserPanel(user.NewValue) { Width = 200 }; }, true); - AddStep("show", () => accountCreation.Show()); - AddStep("logout", () => api.Logout()); + } + + [BackgroundDependencyLoader] + private void load() + { + API.Logout(); + + localUser = API.LocalUser.GetBoundCopy(); + localUser.BindValueChanged(user => { userPanelArea.Child = new UserGridPanel(user.NewValue) { Width = 200 }; }, true); + + AddStep("logout", API.Logout); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs new file mode 100644 index 0000000000..6cb1687d1f --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.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.Game.Overlays; +using NUnit.Framework; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneBeatmapListingOverlay : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + private readonly BeatmapListingOverlay overlay; + + public TestSceneBeatmapListingOverlay() + { + Add(overlay = new BeatmapListingOverlay()); + } + + [Test] + public void TestShow() + { + AddStep("Show", overlay.Show); + } + + [Test] + public void TestHide() + { + AddStep("Hide", overlay.Hide); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs new file mode 100644 index 0000000000..eb34187cd6 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapSet; +using osu.Game.Rulesets; +using System.Collections.Generic; +using System.Linq; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneBeatmapRulesetSelector : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly TestRulesetSelector selector; + + public TestSceneBeatmapRulesetSelector() + { + Add(selector = new TestRulesetSelector()); + } + + [Resolved] + private RulesetStore rulesets { get; set; } + + [Test] + public void TestMultipleRulesetsBeatmapSet() + { + var enabledRulesets = rulesets.AvailableRulesets.Skip(1).Take(2); + + AddStep("load multiple rulesets beatmapset", () => + { + selector.BeatmapSet = new BeatmapSetInfo + { + Beatmaps = enabledRulesets.Select(r => new BeatmapInfo { Ruleset = r }).ToList() + }; + }); + + var tabItems = selector.TabContainer.TabItems; + AddAssert("other rulesets disabled", () => tabItems.Except(tabItems.Where(t => enabledRulesets.Any(r => r.Equals(t.Value)))).All(t => !t.Enabled.Value)); + AddAssert("left-most ruleset selected", () => tabItems.First(t => t.Enabled.Value).Active.Value); + } + + [Test] + public void TestSingleRulesetBeatmapSet() + { + var enabledRuleset = rulesets.AvailableRulesets.Last(); + + AddStep("load single ruleset beatmapset", () => + { + selector.BeatmapSet = new BeatmapSetInfo + { + Beatmaps = new List + { + new BeatmapInfo + { + Ruleset = enabledRuleset + } + } + }; + }); + + AddAssert("single ruleset selected", () => selector.SelectedTab.Value.Equals(enabledRuleset)); + } + + [Test] + public void TestEmptyBeatmapSet() + { + AddStep("load empty beatmapset", () => selector.BeatmapSet = new BeatmapSetInfo + { + Beatmaps = new List() + }); + + AddAssert("no ruleset selected", () => selector.SelectedTab == null); + AddAssert("all rulesets disabled", () => selector.TabContainer.TabItems.All(t => !t.Enabled.Value)); + } + + [Test] + public void TestNullBeatmapSet() + { + AddStep("load null beatmapset", () => selector.BeatmapSet = null); + + AddAssert("no ruleset selected", () => selector.SelectedTab == null); + AddAssert("all rulesets disabled", () => selector.TabContainer.TabItems.All(t => !t.Enabled.Value)); + } + + private class TestRulesetSelector : BeatmapRulesetSelector + { + public new TabItem SelectedTab => base.SelectedTab; + + public new TabFillFlowContainer TabContainer => base.TabContainer; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index daee419b52..c5d1fd6887 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -6,8 +6,6 @@ using osu.Framework.Allocation; using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; -using osu.Game.Overlays.BeatmapSet.Buttons; -using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Rulesets; using osu.Game.Users; using System; @@ -21,41 +19,15 @@ namespace osu.Game.Tests.Visual.Online { private readonly TestBeatmapSetOverlay overlay; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Header), - typeof(ScoreTable), - typeof(ScoreTableRowBackground), - typeof(DrawableTopScore), - typeof(ScoresContainer), - typeof(AuthorInfo), - typeof(BasicStats), - typeof(BeatmapPicker), - typeof(Details), - typeof(HeaderDownloadButton), - typeof(FavouriteButton), - typeof(Header), - typeof(HeaderButton), - typeof(Info), - typeof(PreviewButton), - typeof(SuccessRate), - typeof(BeatmapAvailability), - }; - - private RulesetInfo taikoRuleset; - private RulesetInfo maniaRuleset; + protected override bool UseOnlineAPI => true; public TestSceneBeatmapSetOverlay() { Add(overlay = new TestBeatmapSetOverlay()); } - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) - { - taikoRuleset = rulesets.GetRuleset(1); - maniaRuleset = rulesets.GetRuleset(3); - } + [Resolved] + private RulesetStore rulesets { get; set; } [Test] public void TestLoading() @@ -109,7 +81,7 @@ namespace osu.Game.Tests.Visual.Online StarDifficulty = 9.99, Version = @"TEST", Length = 456000, - Ruleset = maniaRuleset, + Ruleset = rulesets.GetRuleset(3), BaseDifficulty = new BeatmapDifficulty { CircleSize = 1, @@ -135,6 +107,9 @@ namespace osu.Game.Tests.Visual.Online }); downloadAssert(true); + + AddStep("show many difficulties", () => overlay.ShowBeatmapSet(createManyDifficultiesBeatmapSet())); + downloadAssert(true); } [Test] @@ -173,6 +148,8 @@ namespace osu.Game.Tests.Visual.Online HasVideo = true, HasStoryboard = true, Covers = new BeatmapSetOnlineCovers(), + Language = new BeatmapSetOnlineLanguage { Id = 3, Name = "English" }, + Genre = new BeatmapSetOnlineGenre { Id = 4, Name = "Rock" }, }, Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() }, Beatmaps = new List @@ -182,7 +159,7 @@ namespace osu.Game.Tests.Visual.Online StarDifficulty = 5.67, Version = @"ANOTHER TEST", Length = 123000, - Ruleset = taikoRuleset, + Ruleset = rulesets.GetRuleset(1), BaseDifficulty = new BeatmapDifficulty { CircleSize = 9, @@ -210,6 +187,54 @@ namespace osu.Game.Tests.Visual.Online downloadAssert(false); } + [Test] + public void TestMultipleRulesets() + { + AddStep("show multiple rulesets beatmap", () => + { + var beatmaps = new List(); + + foreach (var ruleset in rulesets.AvailableRulesets.Skip(1)) + { + beatmaps.Add(new BeatmapInfo + { + Version = ruleset.Name, + Ruleset = ruleset, + BaseDifficulty = new BeatmapDifficulty(), + OnlineInfo = new BeatmapOnlineInfo(), + Metrics = new BeatmapMetrics + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, + }); + } + + overlay.ShowBeatmapSet(new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + Title = @"multiple rulesets beatmap", + Artist = @"none", + Author = new User + { + Username = "BanchoBot", + Id = 3, + } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers(), + }, + Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() }, + Beatmaps = beatmaps + }); + }); + + AddAssert("shown beatmaps of current ruleset", () => overlay.Header.Picker.Difficulties.All(b => b.Beatmap.Ruleset.Equals(overlay.Header.RulesetSelector.Current.Value))); + AddAssert("left-most beatmap selected", () => overlay.Header.Picker.Difficulties.First().State == BeatmapPicker.DifficultySelectorState.Selected); + } + [Test] public void TestHide() { @@ -222,14 +247,64 @@ namespace osu.Game.Tests.Visual.Online AddStep(@"show without reload", overlay.Show); } + private BeatmapSetInfo createManyDifficultiesBeatmapSet() + { + var beatmaps = new List(); + + for (int i = 1; i < 41; i++) + { + beatmaps.Add(new BeatmapInfo + { + OnlineBeatmapID = i * 10, + Version = $"Test #{i}", + Ruleset = Ruleset.Value, + StarDifficulty = 2 + i * 0.1, + BaseDifficulty = new BeatmapDifficulty + { + OverallDifficulty = 3.5f, + }, + OnlineInfo = new BeatmapOnlineInfo(), + Metrics = new BeatmapMetrics + { + Fails = Enumerable.Range(1, 100).Select(j => j % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(j => j % 12 - 6).ToArray(), + }, + }); + } + + return new BeatmapSetInfo + { + OnlineBeatmapSetID = 123, + Metadata = new BeatmapMetadata + { + Title = @"many difficulties beatmap", + Artist = @"none", + Author = new User + { + Username = @"BanchoBot", + Id = 3, + }, + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Preview = @"https://b.ppy.sh/preview/123.mp3", + HasVideo = true, + HasStoryboard = true, + Covers = new BeatmapSetOnlineCovers(), + }, + Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() }, + Beatmaps = beatmaps, + }; + } + private void downloadAssert(bool shown) { - AddAssert($"is download button {(shown ? "shown" : "hidden")}", () => overlay.DownloadButtonsVisible == shown); + AddAssert($"is download button {(shown ? "shown" : "hidden")}", () => overlay.Header.DownloadButtonsVisible == shown); } private class TestBeatmapSetOverlay : BeatmapSetOverlay { - public bool DownloadButtonsVisible => Header.DownloadButtonsVisible; + public new Header Header => base.Header; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs index 2a45e68c0a..f7099b0615 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs @@ -1,13 +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 System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Screens.Select.Details; @@ -15,13 +16,11 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneBeatmapSetOverlayDetails : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Details) - }; - private RatingsExposingDetails details; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [SetUp] public void Setup() => Schedule(() => { @@ -44,7 +43,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("set second set", () => details.BeatmapSet = secondSet); AddAssert("ratings set", () => details.Ratings.Metrics == secondSet.Metrics); - BeatmapSetInfo createSet() => new BeatmapSetInfo + static BeatmapSetInfo createSet() => new BeatmapSetInfo { Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).Select(_ => RNG.Next(10)).ToArray() }, Beatmaps = new List @@ -55,8 +54,12 @@ namespace osu.Game.Tests.Visual.Online { Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(), Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(), - } + }, } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Status = BeatmapSetOnlineStatus.Ranked } }; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs index 05f5c117e4..fd5c188b94 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs @@ -1,15 +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; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.MathUtils; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Screens.Select.Details; using osuTK; @@ -19,13 +21,11 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneBeatmapSetOverlaySuccessRate : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Details) - }; - private GraphExposingSuccessRate successRate; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [SetUp] public void Setup() => Schedule(() => { @@ -64,7 +64,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("set second set", () => successRate.Beatmap = secondBeatmap); AddAssert("ratings set", () => successRate.Graph.Metrics == secondBeatmap.Metrics); - BeatmapInfo createBeatmap() => new BeatmapInfo + static BeatmapInfo createBeatmap() => new BeatmapInfo { Metrics = new BeatmapMetrics { @@ -74,6 +74,32 @@ namespace osu.Game.Tests.Visual.Online }; } + [Test] + public void TestOnlyFailMetrics() + { + AddStep("set beatmap", () => successRate.Beatmap = new BeatmapInfo + { + Metrics = new BeatmapMetrics + { + Fails = Enumerable.Range(1, 100).ToArray(), + } + }); + AddAssert("graph max values correct", + () => successRate.ChildrenOfType().All(graph => graph.MaxValue == 100)); + } + + [Test] + public void TestEmptyMetrics() + { + AddStep("set beatmap", () => successRate.Beatmap = new BeatmapInfo + { + Metrics = new BeatmapMetrics() + }); + + AddAssert("graph max values correct", + () => successRate.ChildrenOfType().All(graph => graph.MaxValue == 0)); + } + private class GraphExposingSuccessRate : SuccessRate { public new FailRetryGraph Graph => base.Graph; diff --git a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs index cf8bac7642..02f6de2269 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChangelogOverlay.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using NUnit.Framework; using osu.Game.Online.API.Requests.Responses; @@ -13,37 +12,44 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneChangelogOverlay : OsuTestScene { - private ChangelogOverlay changelog; + private TestChangelogOverlay changelog; - public override IReadOnlyList RequiredTypes => new[] + protected override bool UseOnlineAPI => true; + + [SetUp] + public void SetUp() => Schedule(() => { - typeof(UpdateStreamBadgeArea), - typeof(UpdateStreamBadge), - typeof(ChangelogHeader), - typeof(ChangelogContent), - typeof(ChangelogListing), - typeof(ChangelogSingleBuild), - typeof(ChangelogBuild), - typeof(Comments), - }; + Child = changelog = new TestChangelogOverlay(); + }); - protected override void LoadComplete() + [Test] + public void ShowWithNoFetch() { - base.LoadComplete(); + AddStep(@"Show", () => changelog.Show()); + AddUntilStep(@"wait for streams", () => changelog.Streams?.Count > 0); + AddAssert(@"listing displayed", () => changelog.Current.Value == null); + AddAssert(@"no stream selected", () => changelog.Header.Streams.Current.Value == null); + } - Add(changelog = new ChangelogOverlay()); - AddStep(@"Show", changelog.Show); - AddStep(@"Hide", changelog.Hide); - - AddWaitStep("wait for hide", 3); + [Test] + public void ShowWithListing() + { + AddStep(@"Show with listing", () => changelog.ShowListing()); + AddUntilStep(@"wait for streams", () => changelog.Streams?.Count > 0); + AddAssert(@"listing displayed", () => changelog.Current.Value == null); + AddAssert(@"no stream selected", () => changelog.Header.Streams.Current.Value == null); + } + [Test] + public void ShowWithBuild() + { AddStep(@"Show with Lazer 2018.712.0", () => { changelog.ShowBuild(new APIChangelogBuild { Version = "2018.712.0", DisplayVersion = "2018.712.0", - UpdateStream = new APIUpdateStream { Name = OsuGameBase.CLIENT_STREAM_NAME }, + UpdateStream = new APIUpdateStream { Id = 7, Name = OsuGameBase.CLIENT_STREAM_NAME }, ChangelogEntries = new List { new APIChangelogEntry @@ -54,18 +60,50 @@ namespace osu.Game.Tests.Visual.Online } } }); - changelog.Show(); }); - AddWaitStep("wait for show", 3); - AddStep(@"Hide", changelog.Hide); - AddWaitStep("wait for hide", 3); + AddUntilStep(@"wait for streams", () => changelog.Streams?.Count > 0); + AddAssert(@"correct build displayed", () => changelog.Current.Value.Version == "2018.712.0"); + AddAssert(@"correct stream selected", () => changelog.Header.Streams.Current.Value.Id == 7); + } - AddStep(@"Show with listing", () => + [Test] + public void TestHTMLUnescaping() + { + AddStep(@"Ensure HTML string unescaping", () => { - changelog.ShowListing(); - changelog.Show(); + changelog.ShowBuild(new APIChangelogBuild + { + Version = "2019.920.0", + DisplayVersion = "2019.920.0", + UpdateStream = new APIUpdateStream + { + Name = "Test", + DisplayName = "Test" + }, + ChangelogEntries = new List + { + new APIChangelogEntry + { + Category = "Testing HTML strings unescaping", + Title = "Ensuring HTML strings are being unescaped", + MessageHtml = """"This text should appear triple-quoted""" >_<", + GithubUser = new APIChangelogUser + { + DisplayName = "Dummy", + OsuUsername = "Dummy", + } + }, + } + }); }); } + + private class TestChangelogOverlay : ChangelogOverlay + { + public new List Streams => base.Streams; + + public new ChangelogHeader Header => base.Header; + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelTabControl.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelTabControl.cs index 16e47c5df9..73e1fc9b35 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChannelTabControl.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChannelTabControl.cs @@ -1,15 +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.Linq; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; using osu.Game.Overlays.Chat.Tabs; @@ -20,12 +20,7 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneChannelTabControl : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ChannelTabControl), - }; - - private readonly ChannelTabControl channelTabControl; + private readonly TestTabControl channelTabControl; public TestSceneChannelTabControl() { @@ -37,7 +32,7 @@ namespace osu.Game.Tests.Visual.Online Anchor = Anchor.Centre, Children = new Drawable[] { - channelTabControl = new ChannelTabControl + channelTabControl = new TestTabControl { RelativeSizeAxes = Axes.X, Origin = Anchor.Centre, @@ -73,32 +68,40 @@ namespace osu.Game.Tests.Visual.Online channelTabControl.Current.ValueChanged += channel => currentText.Text = "Currently selected channel: " + channel.NewValue; AddStep("Add random private channel", addRandomPrivateChannel); - AddAssert("There is only one channels", () => channelTabControl.Items.Count() == 2); + AddAssert("There is only one channels", () => channelTabControl.Items.Count == 2); AddRepeatStep("Add 3 random private channels", addRandomPrivateChannel, 3); - AddAssert("There are four channels", () => channelTabControl.Items.Count() == 5); + AddAssert("There are four channels", () => channelTabControl.Items.Count == 5); AddStep("Add random public channel", () => addChannel(RNG.Next().ToString())); - AddRepeatStep("Select a random channel", () => channelTabControl.Current.Value = channelTabControl.Items.ElementAt(RNG.Next(channelTabControl.Items.Count() - 1)), 20); + AddRepeatStep("Select a random channel", () => + { + List validChannels = channelTabControl.Items.Where(c => !(c is ChannelSelectorTabItem.ChannelSelectorTabChannel)).ToList(); + channelTabControl.SelectChannel(validChannels[RNG.Next(0, validChannels.Count)]); + }, 20); - Channel channelBefore = channelTabControl.Items.First(); - AddStep("set first channel", () => channelTabControl.Current.Value = channelBefore); + Channel channelBefore = null; + AddStep("set first channel", () => channelTabControl.SelectChannel(channelBefore = channelTabControl.Items.First(c => !(c is ChannelSelectorTabItem.ChannelSelectorTabChannel)))); - AddStep("select selector tab", () => channelTabControl.Current.Value = channelTabControl.Items.Last()); + AddStep("select selector tab", () => channelTabControl.SelectChannel(channelTabControl.Items.Single(c => c is ChannelSelectorTabItem.ChannelSelectorTabChannel))); AddAssert("selector tab is active", () => channelTabControl.ChannelSelectorActive.Value); AddAssert("check channel unchanged", () => channelBefore == channelTabControl.Current.Value); - AddStep("set second channel", () => channelTabControl.Current.Value = channelTabControl.Items.Skip(1).First()); + AddStep("set second channel", () => channelTabControl.SelectChannel(channelTabControl.Items.GetNext(channelBefore))); AddAssert("selector tab is inactive", () => !channelTabControl.ChannelSelectorActive.Value); AddUntilStep("remove all channels", () => { - var first = channelTabControl.Items.First(); - if (first is ChannelSelectorTabItem.ChannelSelectorTabChannel) - return true; + foreach (var item in channelTabControl.Items.ToList()) + { + if (item is ChannelSelectorTabItem.ChannelSelectorTabChannel) + continue; - channelTabControl.RemoveChannel(first); - return false; + channelTabControl.RemoveChannel(item); + return false; + } + + return true; }); AddAssert("selector tab is active", () => channelTabControl.ChannelSelectorActive.Value); @@ -117,5 +120,10 @@ namespace osu.Game.Tests.Visual.Online Type = ChannelType.Public, Name = name }); + + private class TestTabControl : ChannelTabControl + { + public void SelectChannel(Channel channel) => base.SelectTab(TabMap[channel]); + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs new file mode 100644 index 0000000000..8408b7dd60 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLineTruncation.cs @@ -0,0 +1,98 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Chat; +using osu.Game.Overlays.Chat; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneChatLineTruncation : OsuTestScene + { + private readonly TestChatLineContainer textContainer; + + public TestSceneChatLineTruncation() + { + Add(textContainer = new TestChatLineContainer + { + Padding = new MarginPadding { Left = 20, Right = 20 }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }); + } + + [BackgroundDependencyLoader] + private void load() + { + testFormatting(); + } + + private void clear() => AddStep("clear messages", textContainer.Clear); + + private void addMessageWithChecks(string text, bool isAction = false, bool isImportant = false, string username = null) + { + int index = textContainer.Count + 1; + var newLine = new ChatLine(new DummyMessage(text, isAction, isImportant, index, username)); + textContainer.Add(newLine); + } + + private void testFormatting() + { + for (int a = 0; a < 25; a++) + addMessageWithChecks($"Wide {a} character username.", username: new string('w', a)); + addMessageWithChecks("Short name with spaces.", username: "sho rt name"); + addMessageWithChecks("Long name with spaces.", username: "long name with s p a c e s"); + } + + private class DummyMessage : Message + { + private static long messageCounter; + + internal static readonly User TEST_SENDER_BACKGROUND = new User + { + Username = @"i-am-important", + Id = 42, + Colour = "#250cc9", + }; + + internal static readonly User TEST_SENDER = new User + { + Username = @"Somebody", + Id = 1, + }; + + public new DateTimeOffset Timestamp = DateTimeOffset.Now; + + public DummyMessage(string text, bool isAction = false, bool isImportant = false, int number = 0, string username = null) + : base(messageCounter++) + { + Content = text; + IsAction = isAction; + Sender = new User + { + Username = username ?? $"user {number}", + Id = number, + Colour = isImportant ? "#250cc9" : null, + }; + } + } + + private class TestChatLineContainer : FillFlowContainer + { + protected override int Compare(Drawable x, Drawable y) + { + var xC = (ChatLine)x; + var yC = (ChatLine)y; + + return xC.Message.CompareTo(yC.Message); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs index c18e0e3064..9e69530a77 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatLink.cs @@ -2,16 +2,14 @@ // 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.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; using osu.Game.Overlays; using osu.Game.Overlays.Chat; @@ -27,16 +25,6 @@ namespace osu.Game.Tests.Visual.Online private readonly DialogOverlay dialogOverlay; private Color4 linkColour; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ChatLine), - typeof(Message), - typeof(LinkFlowContainer), - typeof(DummyEchoMessage), - typeof(LocalEchoMessage), - typeof(MessageFormatter) - }; - public TestSceneChatLink() { Add(dialogOverlay = new DialogOverlay { Depth = float.MinValue }); @@ -77,7 +65,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert($"msg #{index} has {linkAmount} link(s)", () => newLine.Message.Links.Count == linkAmount); AddAssert($"msg #{index} has the right action", hasExpectedActions); - AddAssert($"msg #{index} is " + (isAction ? "italic" : "not italic"), () => newLine.ContentFlow.Any() && isAction == isItalic()); + //AddAssert($"msg #{index} is " + (isAction ? "italic" : "not italic"), () => newLine.ContentFlow.Any() && isAction == isItalic()); AddAssert($"msg #{index} shows {linkAmount} link(s)", isShowingLinks); bool hasExpectedActions() @@ -96,13 +84,13 @@ namespace osu.Game.Tests.Visual.Online return true; } - bool isItalic() => newLine.ContentFlow.Where(d => d is OsuSpriteText).Cast().All(sprite => sprite.Font.Italics); + //bool isItalic() => newLine.ContentFlow.Where(d => d is OsuSpriteText).Cast().All(sprite => sprite.Font.Italics); bool isShowingLinks() { bool hasBackground = !string.IsNullOrEmpty(newLine.Message.Sender.Colour); - Color4 textColour = isAction && hasBackground ? OsuColour.FromHex(newLine.Message.Sender.Colour) : Color4.White; + Color4 textColour = isAction && hasBackground ? Color4Extensions.FromHex(newLine.Message.Sender.Colour) : Color4.White; var linkCompilers = newLine.ContentFlow.Where(d => d is DrawableLinkCompiler).ToList(); var linkSprites = linkCompilers.SelectMany(comp => ((DrawableLinkCompiler)comp).Parts); @@ -127,6 +115,9 @@ namespace osu.Game.Tests.Visual.Online addMessageWithChecks("is now playing [https://osu.ppy.sh/b/252238 IMAGE -MATERIAL- ]", 1, true, expectedActions: LinkAction.OpenBeatmap); addMessageWithChecks("Let's (try)[https://osu.ppy.sh/home] [https://osu.ppy.sh/b/252238 multiple links] https://osu.ppy.sh/home", 3, expectedActions: new[] { LinkAction.External, LinkAction.OpenBeatmap, LinkAction.External }); + addMessageWithChecks("[https://osu.ppy.sh/home New link format with escaped [and \\[ paired] braces]", 1, expectedActions: LinkAction.External); + addMessageWithChecks("[Markdown link format with escaped [and \\[ paired] braces](https://osu.ppy.sh/home)", 1, expectedActions: LinkAction.External); + addMessageWithChecks("(Old link format with escaped (and \\( paired) parentheses)[https://osu.ppy.sh/home] and [[also a rogue wiki link]]", 2, expectedActions: new[] { LinkAction.External, LinkAction.External }); // note that there's 0 links here (they get removed if a channel is not found) addMessageWithChecks("#lobby or #osu would be blue (and work) in the ChatDisplay test (when a proper ChatOverlay is present)."); addMessageWithChecks("I am important!", 0, false, true); diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 4d3992ce13..fca642ad6c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -1,17 +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 System; 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.Graphics.UserInterface; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Chat; using osu.Game.Overlays; -using osu.Game.Overlays.Chat; using osu.Game.Overlays.Chat.Selection; using osu.Game.Overlays.Chat.Tabs; using osu.Game.Users; @@ -19,24 +20,32 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Online { - public class TestSceneChatOverlay : ManualInputManagerTestScene + public class TestSceneChatOverlay : OsuManualInputManagerTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ChatLine), - typeof(DrawableChannel), - typeof(ChannelSelectorTabItem), - typeof(ChannelTabControl), - typeof(ChannelTabItem), - typeof(PrivateChannelTabItem), - typeof(TabCloseButton) - }; - private TestChatOverlay chatOverlay; private ChannelManager channelManager; - private readonly Channel channel1 = new Channel(new User()) { Name = "test1" }; - private readonly Channel channel2 = new Channel(new User()) { Name = "test2" }; + private IEnumerable visibleChannels => chatOverlay.ChannelTabControl.VisibleItems.Where(channel => channel.Name != "+"); + private IEnumerable joinedChannels => chatOverlay.ChannelTabControl.Items.Where(channel => channel.Name != "+"); + private readonly List channels; + + private Channel currentChannel => channelManager.CurrentChannel.Value; + private Channel nextChannel => joinedChannels.ElementAt(joinedChannels.ToList().IndexOf(currentChannel) + 1); + private Channel previousChannel => joinedChannels.ElementAt(joinedChannels.ToList().IndexOf(currentChannel) - 1); + private Channel channel1 => channels[0]; + private Channel channel2 => channels[1]; + + public TestSceneChatOverlay() + { + channels = Enumerable.Range(1, 10) + .Select(index => new Channel(new User()) + { + Name = $"Channel no. {index}", + Topic = index == 3 ? null : $"We talk about the number {index} here", + Type = index % 2 == 0 ? ChannelType.PM : ChannelType.Temporary + }) + .ToList(); + } [SetUp] public void Setup() @@ -45,7 +54,7 @@ namespace osu.Game.Tests.Visual.Online { ChannelManagerContainer container; - Child = container = new ChannelManagerContainer(new List { channel1, channel2 }) + Child = container = new ChannelManagerContainer(channels) { RelativeSizeAxes = Axes.Both, }; @@ -75,27 +84,138 @@ namespace osu.Game.Tests.Visual.Online AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); AddStep("Switch to channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); - AddAssert("Current channel is channel 1", () => channelManager.CurrentChannel.Value == channel1); + AddAssert("Current channel is channel 1", () => currentChannel == channel1); AddAssert("Channel selector was closed", () => chatOverlay.SelectionOverlayState == Visibility.Hidden); } [Test] - public void TestCloseChannelWhileSelectorClosed() + public void TestSearchInSelector() { - AddStep("Join channel 1", () => channelManager.JoinChannel(channel1)); - AddStep("Join channel 2", () => channelManager.JoinChannel(channel2)); + AddStep("Search for 'no. 2'", () => chatOverlay.ChildrenOfType().First().Text = "no. 2"); + AddUntilStep("Only channel 2 visible", () => + { + var listItems = chatOverlay.ChildrenOfType().Where(c => c.IsPresent); + return listItems.Count() == 1 && listItems.Single().Channel == channel2; + }); + } - AddStep("Switch to channel 2", () => clickDrawable(chatOverlay.TabMap[channel2])); - AddStep("Close channel 2", () => clickDrawable(((TestChannelTabItem)chatOverlay.TabMap[channel2]).CloseButton.Child)); + [Test] + public void TestChannelShortcutKeys() + { + AddStep("Join channels", () => channels.ForEach(channel => channelManager.JoinChannel(channel))); + AddStep("Close channel selector", () => InputManager.Key(Key.Escape)); + AddUntilStep("Wait for close", () => chatOverlay.SelectionOverlayState == Visibility.Hidden); + for (int zeroBasedIndex = 0; zeroBasedIndex < 10; ++zeroBasedIndex) + { + var oneBasedIndex = zeroBasedIndex + 1; + var targetNumberKey = oneBasedIndex % 10; + var targetChannel = channels[zeroBasedIndex]; + AddStep($"Press Alt+{targetNumberKey}", () => pressChannelHotkey(targetNumberKey)); + AddAssert($"Channel #{oneBasedIndex} is selected", () => currentChannel == targetChannel); + } + } + + private Channel expectedChannel; + + [Test] + public void TestCloseChannelBehaviour() + { + AddUntilStep("Join until dropdown has channels", () => + { + if (visibleChannels.Count() < joinedChannels.Count()) + return true; + + // Using temporary channels because they don't hide their names when not active + channelManager.JoinChannel(new Channel + { + Name = $"Channel no. {joinedChannels.Count() + 11}", + Type = ChannelType.Temporary + }); + + return false; + }); + + AddStep("Switch to last tab", () => clickDrawable(chatOverlay.TabMap[visibleChannels.Last()])); + AddAssert("Last visible selected", () => currentChannel == visibleChannels.Last()); + + // Closing the last channel before dropdown + AddStep("Close current channel", () => + { + expectedChannel = nextChannel; + chatOverlay.ChannelTabControl.RemoveChannel(currentChannel); + }); + AddAssert("Next channel selected", () => currentChannel == expectedChannel); AddAssert("Selector remained closed", () => chatOverlay.SelectionOverlayState == Visibility.Hidden); - AddAssert("Current channel is channel 1", () => channelManager.CurrentChannel.Value == channel1); - AddStep("Close channel 1", () => clickDrawable(((TestChannelTabItem)chatOverlay.TabMap[channel1]).CloseButton.Child)); + // Depending on the window size, one more channel might need to be closed for the selectorTab to appear + AddUntilStep("Close channels until selector visible", () => + { + if (chatOverlay.ChannelTabControl.VisibleItems.Last().Name == "+") + return true; + chatOverlay.ChannelTabControl.RemoveChannel(visibleChannels.Last()); + return false; + }); + AddAssert("Last visible selected", () => currentChannel == visibleChannels.Last()); + + // Closing the last channel with dropdown no longer present + AddStep("Close last when selector next", () => + { + expectedChannel = previousChannel; + chatOverlay.ChannelTabControl.RemoveChannel(currentChannel); + }); + AddAssert("Previous channel selected", () => currentChannel == expectedChannel); + + // Standard channel closing + AddStep("Switch to previous channel", () => chatOverlay.ChannelTabControl.SwitchTab(-1)); + AddStep("Close current channel", () => + { + expectedChannel = nextChannel; + chatOverlay.ChannelTabControl.RemoveChannel(currentChannel); + }); + AddAssert("Next channel selected", () => currentChannel == expectedChannel); + + // Selector reappearing after all channels closed + AddUntilStep("Close all channels", () => + { + if (!joinedChannels.Any()) + return true; + + chatOverlay.ChannelTabControl.RemoveChannel(joinedChannels.Last()); + return false; + }); AddAssert("Selector is visible", () => chatOverlay.SelectionOverlayState == Visibility.Visible); } + [Test] + public void TestChannelCloseButton() + { + AddStep("Join 2 channels", () => + { + channelManager.JoinChannel(channel1); + channelManager.JoinChannel(channel2); + }); + + // PM channel close button only appears when active + AddStep("Select PM channel", () => clickDrawable(chatOverlay.TabMap[channel2])); + AddStep("Click PM close button", () => clickDrawable(((TestPrivateChannelTabItem)chatOverlay.TabMap[channel2]).CloseButton.Child)); + AddAssert("PM channel closed", () => !channelManager.JoinedChannels.Contains(channel2)); + + // Non-PM chat channel close button only appears when hovered + AddStep("Hover normal channel tab", () => InputManager.MoveMouseTo(chatOverlay.TabMap[channel1])); + AddStep("Click normal close button", () => clickDrawable(((TestChannelTabItem)chatOverlay.TabMap[channel1]).CloseButton.Child)); + AddAssert("All channels closed", () => !channelManager.JoinedChannels.Any()); + } + + private void pressChannelHotkey(int number) + { + var channelKey = Key.Number0 + number; + InputManager.PressKey(Key.AltLeft); + InputManager.Key(channelKey); + InputManager.ReleaseKey(Key.AltLeft); + } + private void clickDrawable(Drawable d) { InputManager.MoveMouseTo(d); @@ -121,7 +241,12 @@ namespace osu.Game.Tests.Visual.Online { ((BindableList)ChannelManager.AvailableChannels).AddRange(channels); - Child = ChatOverlay = new TestChatOverlay { RelativeSizeAxes = Axes.Both, }; + InternalChildren = new Drawable[] + { + ChannelManager, + ChatOverlay = new TestChatOverlay { RelativeSizeAxes = Axes.Both, }, + }; + ChatOverlay.Show(); } } @@ -130,6 +255,8 @@ namespace osu.Game.Tests.Visual.Online { public Visibility SelectionOverlayState => ChannelSelectionOverlay.State.Value; + public new ChannelTabControl ChannelTabControl => base.ChannelTabControl; + public new ChannelSelectionOverlay ChannelSelectionOverlay => base.ChannelSelectionOverlay; protected override ChannelTabControl CreateChannelTabControl() => new TestTabControl(); @@ -139,12 +266,22 @@ namespace osu.Game.Tests.Visual.Online private class TestTabControl : ChannelTabControl { - protected override TabItem CreateTabItem(Channel value) => new TestChannelTabItem(value); + protected override TabItem CreateTabItem(Channel value) + { + switch (value.Type) + { + case ChannelType.PM: + return new TestPrivateChannelTabItem(value); + + default: + return new TestChannelTabItem(value); + } + } public new IReadOnlyDictionary> TabMap => base.TabMap; } - private class TestChannelTabItem : PrivateChannelTabItem + private class TestChannelTabItem : ChannelTabItem { public TestChannelTabItem(Channel channel) : base(channel) @@ -153,5 +290,15 @@ namespace osu.Game.Tests.Visual.Online public new ClickableContainer CloseButton => base.CloseButton; } + + private class TestPrivateChannelTabItem : PrivateChannelTabItem + { + public TestPrivateChannelTabItem(Channel channel) + : base(channel) + { + } + + public new ClickableContainer CloseButton => base.CloseButton; + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs new file mode 100644 index 0000000000..c2a18330c9 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.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 System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Overlays; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Comments; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneCommentsContainer : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + private CommentsContainer commentsContainer; + + [SetUp] + public void SetUp() => Schedule(() => + Child = new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = commentsContainer = new CommentsContainer() + }); + + [Test] + public void TestIdleState() + { + AddUntilStep("loading spinner shown", + () => commentsContainer.ChildrenOfType().Single().IsLoading); + } + + [Test] + public void TestSingleCommentsPage() + { + setUpCommentsResponse(exampleComments); + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + AddUntilStep("show more button hidden", + () => commentsContainer.ChildrenOfType().Single().Alpha == 0); + } + + [Test] + public void TestMultipleCommentPages() + { + var comments = exampleComments; + comments.HasMore = true; + comments.TopLevelCount = 10; + + setUpCommentsResponse(comments); + AddStep("show comments", () => commentsContainer.ShowComments(CommentableType.Beatmapset, 123)); + AddUntilStep("show more button visible", + () => commentsContainer.ChildrenOfType().Single().Alpha == 1); + } + + [Test] + public void TestMultipleLoads() + { + var comments = exampleComments; + int topLevelCommentCount = exampleComments.Comments.Count; + + AddStep("hide container", () => commentsContainer.Hide()); + setUpCommentsResponse(comments); + AddRepeatStep("show comments multiple times", + () => commentsContainer.ShowComments(CommentableType.Beatmapset, 456), 2); + AddStep("show container", () => commentsContainer.Show()); + AddUntilStep("comment count is correct", + () => commentsContainer.ChildrenOfType().Count() == topLevelCommentCount); + } + + private void setUpCommentsResponse(CommentBundle commentBundle) + => AddStep("set up response", () => + { + dummyAPI.HandleRequest = request => + { + if (!(request is GetCommentsRequest getCommentsRequest)) + return; + + getCommentsRequest.TriggerSuccess(commentBundle); + }; + }); + + private CommentBundle exampleComments => new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 1, + Message = "This is a comment", + LegacyName = "FirstUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 19, + RepliesCount = 1 + }, + new Comment + { + Id = 5, + ParentId = 1, + Message = "This is a child comment", + LegacyName = "SecondUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 4, + }, + new Comment + { + Id = 10, + Message = "This is another comment", + LegacyName = "ThirdUser", + CreatedAt = DateTimeOffset.Now, + VotesCount = 0 + }, + }, + IncludedComments = new List(), + }; + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.cs new file mode 100644 index 0000000000..03eac5d85b --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsHeader.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.Allocation; +using osu.Framework.Bindables; +using osu.Game.Overlays; +using osu.Game.Overlays.Comments; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneCommentsHeader : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly Bindable sort = new Bindable(); + private readonly BindableBool showDeleted = new BindableBool(); + + public TestSceneCommentsHeader() + { + Add(new CommentsHeader + { + Sort = { BindTarget = sort }, + ShowDeleted = { BindTarget = showDeleted } + }); + + AddStep("Trigger ShowDeleted", () => showDeleted.Value = !showDeleted.Value); + AddStep("Select old", () => sort.Value = CommentsSortCriteria.Old); + AddStep("Select new", () => sort.Value = CommentsSortCriteria.New); + AddStep("Select top", () => sort.Value = CommentsSortCriteria.Top); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsPage.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsPage.cs new file mode 100644 index 0000000000..7fdf0708e0 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsPage.cs @@ -0,0 +1,226 @@ +// 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.Overlays.Comments; +using osu.Game.Overlays; +using osu.Framework.Allocation; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osuTK; +using JetBrains.Annotations; +using NUnit.Framework; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneCommentsPage : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private readonly BindableBool showDeleted = new BindableBool(); + private readonly Container content; + + private TestCommentsPage commentsPage; + + public TestSceneCommentsPage() + { + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.Y, + Width = 200, + Child = new OsuCheckbox + { + Current = showDeleted, + LabelText = @"Show Deleted" + } + }, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } + }); + } + + [Test] + public void TestAppendDuplicatedComment() + { + AddStep("Create page", () => createPage(getCommentBundle())); + AddAssert("Dictionary length is 10", () => commentsPage?.DictionaryLength == 10); + AddStep("Append existing comment", () => commentsPage?.AppendComments(getCommentSubBundle())); + AddAssert("Dictionary length is 10", () => commentsPage?.DictionaryLength == 10); + } + + [Test] + public void TestEmptyBundle() + { + AddStep("Create page", () => createPage(getEmptyCommentBundle())); + AddAssert("Dictionary length is 0", () => commentsPage?.DictionaryLength == 0); + } + + private void createPage(CommentBundle commentBundle) + { + commentsPage = null; + content.Clear(); + content.Add(commentsPage = new TestCommentsPage(commentBundle) + { + ShowDeleted = { BindTarget = showDeleted } + }); + } + + private CommentBundle getEmptyCommentBundle() => new CommentBundle + { + Comments = new List(), + }; + + private CommentBundle getCommentBundle() => new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 1, + Message = "Simple test comment", + LegacyName = "TestUser1", + CreatedAt = DateTimeOffset.Now, + VotesCount = 5 + }, + new Comment + { + Id = 100, + Message = "This comment has \"load replies\" button because it has unloaded replies", + LegacyName = "TestUser1100", + CreatedAt = DateTimeOffset.Now, + VotesCount = 5, + RepliesCount = 2, + }, + new Comment + { + Id = 111, + Message = "This comment has \"Show More\" button because it has unloaded replies, but some of them are loaded", + LegacyName = "TestUser1111", + CreatedAt = DateTimeOffset.Now, + VotesCount = 100, + RepliesCount = 2, + }, + new Comment + { + Id = 112, + ParentId = 111, + Message = "I'm here to make my parent work", + LegacyName = "someone", + CreatedAt = DateTimeOffset.Now, + VotesCount = 2, + }, + new Comment + { + Id = 2, + Message = "This comment has been deleted :( but visible for admins", + LegacyName = "TestUser2", + CreatedAt = DateTimeOffset.Now, + DeletedAt = DateTimeOffset.Now, + VotesCount = 5 + }, + new Comment + { + Id = 3, + Message = "This comment is a top level", + LegacyName = "TestUser3", + CreatedAt = DateTimeOffset.Now, + RepliesCount = 2, + }, + new Comment + { + Id = 4, + ParentId = 3, + Message = "And this is a reply", + RepliesCount = 1, + LegacyName = "TestUser1", + CreatedAt = DateTimeOffset.Now, + }, + new Comment + { + Id = 15, + ParentId = 4, + Message = "Reply to reply", + LegacyName = "TestUser1", + CreatedAt = DateTimeOffset.Now, + }, + new Comment + { + Id = 6, + ParentId = 3, + LegacyName = "TestUser11515", + CreatedAt = DateTimeOffset.Now, + DeletedAt = DateTimeOffset.Now, + }, + new Comment + { + Id = 5, + Message = "This comment is voted and edited", + LegacyName = "BigBrainUser", + CreatedAt = DateTimeOffset.Now, + EditedAt = DateTimeOffset.Now, + VotesCount = 1000, + EditedById = 1, + } + }, + IncludedComments = new List(), + UserVotes = new List + { + 5 + }, + Users = new List + { + new User + { + Id = 1, + Username = "Good_Admin" + } + }, + }; + + private CommentBundle getCommentSubBundle() => new CommentBundle + { + Comments = new List + { + new Comment + { + Id = 1, + Message = "Simple test comment", + LegacyName = "TestUser1", + CreatedAt = DateTimeOffset.Now, + VotesCount = 5 + }, + }, + IncludedComments = new List(), + }; + + private class TestCommentsPage : CommentsPage + { + public TestCommentsPage(CommentBundle commentBundle) + : base(commentBundle) + { + } + + public new void AppendComments([NotNull] CommentBundle bundle) => base.AppendComments(bundle); + + public int DictionaryLength => CommentDictionary.Count; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs new file mode 100644 index 0000000000..7eba64f418 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.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 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.Game.Database; +using osu.Game.Online.Spectator; +using osu.Game.Overlays.Dashboard; +using osu.Game.Tests.Visual.Gameplay; +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 CurrentlyPlayingDisplay currentlyPlaying; + + [Cached(typeof(UserLookupCache))] + private UserLookupCache lookupCache = new TestUserLookupCache(); + + private Container nestedContainer; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("add streaming client", () => + { + nestedContainer?.Remove(testSpectatorStreamingClient); + Remove(lookupCache); + + Children = new Drawable[] + { + lookupCache, + nestedContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + testSpectatorStreamingClient, + currentlyPlaying = new CurrentlyPlayingDisplay + { + RelativeSizeAxes = Axes.Both, + } + } + }, + }; + }); + + AddStep("Reset players", () => testSpectatorStreamingClient.PlayingUsers.Clear()); + } + + [Test] + public void TestBasicDisplay() + { + AddStep("Add playing user", () => testSpectatorStreamingClient.PlayingUsers.Add(2)); + AddUntilStep("Panel loaded", () => currentlyPlaying.ChildrenOfType()?.FirstOrDefault()?.User.Id == 2); + AddStep("Remove playing user", () => testSpectatorStreamingClient.PlayingUsers.Remove(2)); + AddUntilStep("Panel no longer present", () => !currentlyPlaying.ChildrenOfType().Any()); + } + + internal class TestUserLookupCache : UserLookupCache + { + protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) + => Task.FromResult(new User { Username = "peppy", Id = 2 }); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs new file mode 100644 index 0000000000..960d3fa248 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.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 NUnit.Framework; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneDashboardOverlay : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + private readonly DashboardOverlay overlay; + + public TestSceneDashboardOverlay() + { + Add(overlay = new DashboardOverlay()); + } + + [Test] + public void TestShow() + { + AddStep("Show", overlay.Show); + } + + [Test] + public void TestHide() + { + AddStep("Hide", overlay.Hide); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs index 5b0c2d3c67..684ce10820 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs @@ -1,15 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Online; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Rulesets.Osu; using osu.Game.Tests.Resources; using osuTK; @@ -18,11 +16,6 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneDirectDownloadButton : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(PanelDownloadButton) - }; - private TestDownloadButton downloadButton; [Resolved] @@ -143,14 +136,14 @@ namespace osu.Game.Tests.Visual.Online return beatmap; } - private class TestDownloadButton : PanelDownloadButton + private class TestDownloadButton : BeatmapPanelDownloadButton { public new bool DownloadEnabled => base.DownloadEnabled; public DownloadState DownloadState => State.Value; - public TestDownloadButton(BeatmapSetInfo beatmapSet, bool noVideo = false) - : base(beatmapSet, noVideo) + public TestDownloadButton(BeatmapSetInfo beatmapSet) + : base(beatmapSet) { } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectOverlay.cs deleted file mode 100644 index 75c2a2a6a1..0000000000 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectOverlay.cs +++ /dev/null @@ -1,213 +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 NUnit.Framework; -using osu.Game.Beatmaps; -using osu.Game.Overlays; - -namespace osu.Game.Tests.Visual.Online -{ - [TestFixture] - public class TestSceneDirectOverlay : OsuTestScene - { - private DirectOverlay direct; - - protected override void LoadComplete() - { - base.LoadComplete(); - - Add(direct = new DirectOverlay()); - newBeatmaps(); - - AddStep(@"toggle", direct.ToggleVisibility); - AddStep(@"result counts", () => direct.ResultAmounts = new DirectOverlay.ResultCounts(1, 4, 13)); - AddStep(@"trigger disabled", () => Ruleset.Disabled = !Ruleset.Disabled); - } - - private void newBeatmaps() - { - direct.BeatmapSets = new[] - { - new BeatmapSetInfo - { - OnlineBeatmapSetID = 578332, - Metadata = new BeatmapMetadata - { - Title = @"OrVid", - Artist = @"An", - AuthorString = @"RLC", - Source = @"", - Tags = @"acuticnotes an-fillnote revid tear tearvid encrpted encryption axi axivid quad her hervid recoll", - }, - OnlineInfo = new BeatmapSetOnlineInfo - { - Covers = new BeatmapSetOnlineCovers - { - Card = @"https://assets.ppy.sh/beatmaps/578332/covers/card.jpg?1494591390", - Cover = @"https://assets.ppy.sh/beatmaps/578332/covers/cover.jpg?1494591390", - }, - Preview = @"https://b.ppy.sh/preview/578332.mp3", - PlayCount = 97, - FavouriteCount = 72, - }, - Beatmaps = new List - { - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 5.35f, - Metadata = new BeatmapMetadata(), - }, - }, - }, - new BeatmapSetInfo - { - OnlineBeatmapSetID = 599627, - Metadata = new BeatmapMetadata - { - Title = @"tiny lamp", - Artist = @"fhana", - AuthorString = @"Sotarks", - Source = @"ぎんぎつね", - Tags = @"lantis junichi sato yuxuki waga kevin mitsunaga towana gingitsune opening op full ver version kalibe collab collaboration", - }, - OnlineInfo = new BeatmapSetOnlineInfo - { - Covers = new BeatmapSetOnlineCovers - { - Card = @"https://assets.ppy.sh/beatmaps/599627/covers/card.jpg?1494539318", - Cover = @"https://assets.ppy.sh/beatmaps/599627/covers/cover.jpg?1494539318", - }, - Preview = @"https//b.ppy.sh/preview/599627.mp3", - PlayCount = 3082, - FavouriteCount = 14, - }, - Beatmaps = new List - { - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 5.81f, - Metadata = new BeatmapMetadata(), - }, - }, - }, - new BeatmapSetInfo - { - OnlineBeatmapSetID = 513268, - Metadata = new BeatmapMetadata - { - Title = @"At Gwanghwamun", - Artist = @"KYUHYUN", - AuthorString = @"Cerulean Veyron", - Source = @"", - Tags = @"soul ballad kh super junior sj suju 슈퍼주니어 kt뮤직 sm엔터테인먼트 s.m.entertainment kt music 1st mini album ep", - }, - OnlineInfo = new BeatmapSetOnlineInfo - { - Covers = new BeatmapSetOnlineCovers - { - Card = @"https://assets.ppy.sh/beatmaps/513268/covers/card.jpg?1494502863", - Cover = @"https://assets.ppy.sh/beatmaps/513268/covers/cover.jpg?1494502863", - }, - Preview = @"https//b.ppy.sh/preview/513268.mp3", - PlayCount = 2762, - FavouriteCount = 15, - }, - Beatmaps = new List - { - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 0.9f, - Metadata = new BeatmapMetadata(), - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 1.1f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 2.02f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 3.49f, - }, - }, - }, - new BeatmapSetInfo - { - OnlineBeatmapSetID = 586841, - Metadata = new BeatmapMetadata - { - Title = @"RHAPSODY OF BLUE SKY", - Artist = @"fhana", - AuthorString = @"[Kamiya]", - Source = @"小林さんちのメイドラゴン", - Tags = @"kobayashi san chi no maidragon aozora no opening anime maid dragon oblivion karen dynamix imoutosan pata-mon gxytcgxytc", - }, - OnlineInfo = new BeatmapSetOnlineInfo - { - Covers = new BeatmapSetOnlineCovers - { - Card = @"https://assets.ppy.sh/beatmaps/586841/covers/card.jpg?1494052741", - Cover = @"https://assets.ppy.sh/beatmaps/586841/covers/cover.jpg?1494052741", - }, - Preview = @"https//b.ppy.sh/preview/586841.mp3", - PlayCount = 62317, - FavouriteCount = 161, - }, - Beatmaps = new List - { - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 1.26f, - Metadata = new BeatmapMetadata(), - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 2.01f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 2.87f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 3.76f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 3.93f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 4.37f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 5.13f, - }, - new BeatmapInfo - { - Ruleset = Ruleset.Value, - StarDifficulty = 5.42f, - }, - }, - }, - }; - } - } -} diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs index 53dbaeddda..74ece5da05 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs @@ -1,30 +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 System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Audio; using osu.Game.Beatmaps; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Rulesets; -using osu.Game.Rulesets.Osu; using osu.Game.Users; using osuTK; namespace osu.Game.Tests.Visual.Online { - public class TestSceneDirectPanel : OsuTestScene + [Cached(typeof(IPreviewTrackOwner))] + public class TestSceneDirectPanel : OsuTestScene, IPreviewTrackOwner { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DirectGridPanel), - typeof(DirectListPanel), - typeof(IconPill) - }; - - private BeatmapSetInfo getUndownloadableBeatmapSet(RulesetInfo ruleset) => new BeatmapSetInfo + private BeatmapSetInfo getUndownloadableBeatmapSet() => new BeatmapSetInfo { OnlineBeatmapSetID = 123, Metadata = new BeatmapMetadata @@ -56,23 +49,62 @@ namespace osu.Game.Tests.Visual.Online { new BeatmapInfo { - Ruleset = ruleset, + Ruleset = Ruleset.Value, Version = "Test", StarDifficulty = 6.42, } } }; - [BackgroundDependencyLoader] - private void load() + private BeatmapSetInfo getManyDifficultiesBeatmapSet(RulesetStore rulesets) { - var ruleset = new OsuRuleset().RulesetInfo; + var beatmaps = new List(); - var normal = CreateWorkingBeatmap(ruleset).BeatmapSetInfo; + for (int i = 0; i < 100; i++) + { + beatmaps.Add(new BeatmapInfo + { + Ruleset = rulesets.GetRuleset(i % 4), + StarDifficulty = 2 + i % 4 * 2, + BaseDifficulty = new BeatmapDifficulty + { + OverallDifficulty = 3.5f, + } + }); + } + + return new BeatmapSetInfo + { + OnlineBeatmapSetID = 1, + Metadata = new BeatmapMetadata + { + Title = "many difficulties beatmap", + Artist = "test", + Author = new User + { + Username = "BanchoBot", + Id = 3, + } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + HasVideo = true, + HasStoryboard = true, + Covers = new BeatmapSetOnlineCovers(), + }, + Beatmaps = beatmaps, + }; + } + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + var normal = CreateWorkingBeatmap(Ruleset.Value).BeatmapSetInfo; normal.OnlineInfo.HasVideo = true; normal.OnlineInfo.HasStoryboard = true; - var undownloadable = getUndownloadableBeatmapSet(ruleset); + var undownloadable = getUndownloadableBeatmapSet(); + var manyDifficulties = getManyDifficultiesBeatmapSet(rulesets); Child = new BasicScrollContainer { @@ -81,15 +113,17 @@ namespace osu.Game.Tests.Visual.Online { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, + Direction = FillDirection.Full, Padding = new MarginPadding(20), - Spacing = new Vector2(0, 20), + Spacing = new Vector2(5, 20), Children = new Drawable[] { - new DirectGridPanel(normal), - new DirectListPanel(normal), - new DirectGridPanel(undownloadable), - new DirectListPanel(undownloadable), + new GridBeatmapPanel(normal), + new GridBeatmapPanel(undownloadable), + new GridBeatmapPanel(manyDifficulties), + new ListBeatmapPanel(normal), + new ListBeatmapPanel(undownloadable), + new ListBeatmapPanel(manyDifficulties), }, }, }; diff --git a/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs b/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs index 637b577021..31bb276cd4 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneExternalLinkButton.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Game.Graphics.UserInterface; using osuTK; @@ -10,8 +8,6 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneExternalLinkButton : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(ExternalLinkButton) }; - public TestSceneExternalLinkButton() { Child = new ExternalLinkButton("https://osu.ppy.sh/home") diff --git a/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs b/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs new file mode 100644 index 0000000000..8e2ee4e28d --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Overlays.BeatmapSet.Buttons; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneFavouriteButton : OsuTestScene + { + private FavouriteButton favourite; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create button", () => Child = favourite = new FavouriteButton + { + RelativeSizeAxes = Axes.None, + Size = new Vector2(50), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + [Test] + public void TestLoggedOutIn() + { + AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new BeatmapSetInfo { OnlineBeatmapSetID = 88 }); + AddStep("log out", () => API.Logout()); + checkEnabled(false); + AddStep("log in", () => API.Login("test", "test")); + checkEnabled(true); + } + + [Test] + public void TestBeatmapChange() + { + AddStep("log in", () => API.Login("test", "test")); + AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new BeatmapSetInfo { OnlineBeatmapSetID = 88 }); + checkEnabled(true); + AddStep("set invalid beatmap", () => favourite.BeatmapSet.Value = new BeatmapSetInfo()); + checkEnabled(false); + } + + private void checkEnabled(bool expected) + { + AddAssert("is " + (expected ? "enabled" : "disabled"), () => favourite.Enabled.Value == expected); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs new file mode 100644 index 0000000000..0cc6e9f358 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.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; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneFriendDisplay : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private TestFriendDisplay display; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = display = new TestFriendDisplay() + }; + }); + + [Test] + public void TestOffline() + { + AddStep("Populate with offline test users", () => display.Users = getUsers()); + } + + [Test] + public void TestOnline() + { + AddStep("Fetch online", () => display?.Fetch()); + } + + private List getUsers() => new List + { + new User + { + Username = "flyte", + Id = 3103765, + IsOnline = true, + CurrentModeRank = 1111, + Country = new Country { FlagName = "JP" }, + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" + }, + new User + { + Username = "peppy", + Id = 2, + IsOnline = false, + CurrentModeRank = 2222, + Country = new Country { FlagName = "AU" }, + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + IsSupporter = true, + SupportLevel = 3, + }, + new User + { + Username = "Evast", + Id = 8195163, + Country = new Country { FlagName = "BY" }, + CoverUrl = "https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + IsOnline = false, + LastVisit = DateTimeOffset.Now + } + }; + + private class TestFriendDisplay : FriendDisplay + { + public void Fetch() => PerformFetch(); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs index fe8437be17..8f20bcdcc1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFullscreenOverlay.cs @@ -12,7 +12,7 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneFullscreenOverlay : OsuTestScene { - private FullscreenOverlay overlay; + private FullscreenOverlay overlay; protected override void LoadComplete() { @@ -38,9 +38,10 @@ namespace osu.Game.Tests.Visual.Online AddAssert("fire count 3", () => fireCount == 3); } - private class TestFullscreenOverlay : FullscreenOverlay + private class TestFullscreenOverlay : FullscreenOverlay { public TestFullscreenOverlay() + : base(OverlayColourScheme.Pink, null) { Children = new Drawable[] { diff --git a/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs b/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs index 838347800f..3ecca85ef1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneHistoricalSection.cs @@ -1,15 +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 System; -using System.Collections.Generic; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Overlays; using osu.Game.Overlays.Profile.Sections; -using osu.Game.Overlays.Profile.Sections.Historical; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -17,14 +16,10 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneHistoricalSection : OsuTestScene { - public override IReadOnlyList RequiredTypes => - new[] - { - typeof(HistoricalSection), - typeof(PaginatedMostPlayedBeatmapContainer), - typeof(DrawableMostPlayedBeatmap), - typeof(DrawableProfileRow) - }; + protected override bool UseOnlineAPI => true; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); public TestSceneHistoricalSection() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneHomeNewsPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneHomeNewsPanel.cs new file mode 100644 index 0000000000..a1251ca793 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneHomeNewsPanel.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.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Allocation; +using osu.Game.Overlays; +using System; +using osu.Game.Overlays.Dashboard.Home.News; +using osuTK; +using System.Collections.Generic; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneHomeNewsPanel : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Purple); + + public TestSceneHomeNewsPanel() + { + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + Width = 500, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + new FeaturedNewsItemPanel(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.", + FirstImage = "/help/wiki/shared/news/banners/monthly-beatmapping-contest.png", + PublishedAt = DateTimeOffset.Now, + Slug = "2020-07-16-summer-theme-park-2020-voting-open" + }), + new NewsItemGroupPanel(new List + { + new APINewsPost + { + Title = "Title 1", + Slug = "2020-07-16-summer-theme-park-2020-voting-open", + PublishedAt = DateTimeOffset.Now, + }, + new APINewsPost + { + Title = "Title of this post is Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + Slug = "2020-07-16-summer-theme-park-2020-voting-open", + PublishedAt = DateTimeOffset.Now, + } + }), + new ShowMoreNewsPanel() + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs b/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs new file mode 100644 index 0000000000..2231139856 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneKudosuHistory.cs @@ -0,0 +1,241 @@ +// 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.Overlays.Profile.Sections.Kudosu; +using System.Collections.Generic; +using System; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Extensions.IEnumerableExtensions; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneKudosuHistory : OsuTestScene + { + private readonly Box background; + + public TestSceneKudosuHistory() + { + FillFlowContainer content; + + AddRange(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + content = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Width = 0.7f, + AutoSizeAxes = Axes.Y, + } + }); + + items.ForEach(t => content.Add(new DrawableKudosuHistoryItem(t))); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + background.Colour = colours.GreySeafoam; + } + + private readonly IEnumerable items = new[] + { + new APIKudosuHistory + { + Amount = 10, + CreatedAt = new DateTimeOffset(new DateTime(2011, 11, 11)), + Source = KudosuSource.DenyKudosu, + Action = KudosuAction.Reset, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 1", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username1", + Url = @"https://osu.ppy.sh/u/1234" + } + }, + new APIKudosuHistory + { + Amount = 5, + CreatedAt = new DateTimeOffset(new DateTime(2012, 10, 11)), + Source = KudosuSource.Forum, + Action = KudosuAction.Give, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 2", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username2", + Url = @"https://osu.ppy.sh/u/1234" + } + }, + new APIKudosuHistory + { + Amount = 8, + CreatedAt = new DateTimeOffset(new DateTime(2013, 9, 11)), + Source = KudosuSource.Forum, + Action = KudosuAction.Reset, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 3", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username3", + Url = @"https://osu.ppy.sh/u/1234" + } + }, + new APIKudosuHistory + { + Amount = 7, + CreatedAt = new DateTimeOffset(new DateTime(2014, 8, 11)), + Source = KudosuSource.Forum, + Action = KudosuAction.Revoke, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 4", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username4", + Url = @"https://osu.ppy.sh/u/1234" + } + }, + new APIKudosuHistory + { + Amount = 100, + CreatedAt = new DateTimeOffset(new DateTime(2015, 7, 11)), + Source = KudosuSource.Vote, + Action = KudosuAction.Give, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 5", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username5", + Url = @"https://osu.ppy.sh/u/1234" + } + }, + new APIKudosuHistory + { + Amount = 20, + CreatedAt = new DateTimeOffset(new DateTime(2016, 6, 11)), + Source = KudosuSource.Vote, + Action = KudosuAction.Reset, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 6", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username6", + Url = @"https://osu.ppy.sh/u/1234" + } + }, + new APIKudosuHistory + { + Amount = 11, + CreatedAt = new DateTimeOffset(new DateTime(2016, 6, 11)), + Source = KudosuSource.AllowKudosu, + Action = KudosuAction.Give, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 7", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username7", + Url = @"https://osu.ppy.sh/u/1234" + } + }, + new APIKudosuHistory + { + Amount = 24, + CreatedAt = new DateTimeOffset(new DateTime(2014, 6, 11)), + Source = KudosuSource.Delete, + Action = KudosuAction.Reset, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 8", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username8", + Url = @"https://osu.ppy.sh/u/1234" + } + }, + new APIKudosuHistory + { + Amount = 12, + CreatedAt = new DateTimeOffset(new DateTime(2016, 6, 11)), + Source = KudosuSource.Restore, + Action = KudosuAction.Give, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 9", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username9", + Url = @"https://osu.ppy.sh/u/1234" + } + }, + new APIKudosuHistory + { + Amount = 2, + CreatedAt = new DateTimeOffset(new DateTime(2012, 6, 11)), + Source = KudosuSource.Recalculate, + Action = KudosuAction.Give, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 10", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username10", + Url = @"https://osu.ppy.sh/u/1234" + } + }, + new APIKudosuHistory + { + Amount = 32, + CreatedAt = new DateTimeOffset(new DateTime(2019, 8, 11)), + Source = KudosuSource.Recalculate, + Action = KudosuAction.Reset, + Post = new APIKudosuHistory.ModdingPost + { + Title = @"Random post 11", + Url = @"https://osu.ppy.sh/b/1234", + }, + Giver = new APIKudosuHistory.KudosuGiver + { + Username = @"Username11", + Url = @"https://osu.ppy.sh/u/1234" + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.cs new file mode 100644 index 0000000000..54e655d4ec --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardModSelector.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 osu.Game.Overlays.BeatmapSet; +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Taiko; +using osu.Game.Rulesets.Catch; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Bindables; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneLeaderboardModSelector : OsuTestScene + { + public TestSceneLeaderboardModSelector() + { + LeaderboardModSelector modSelector; + FillFlowContainer selectedMods; + var ruleset = new Bindable(); + + Add(selectedMods = new FillFlowContainer + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + }); + + Add(modSelector = new LeaderboardModSelector + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Ruleset = { BindTarget = ruleset } + }); + + modSelector.SelectedMods.CollectionChanged += (_, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + args.NewItems.Cast().ForEach(mod => selectedMods.Add(new OsuSpriteText + { + Text = mod.Acronym, + })); + break; + + case NotifyCollectionChangedAction.Remove: + args.OldItems.Cast().ForEach(mod => + { + foreach (var selected in selectedMods) + { + if (selected.Text == mod.Acronym) + { + selectedMods.Remove(selected); + break; + } + } + }); + break; + } + }; + + AddStep("osu ruleset", () => ruleset.Value = new OsuRuleset().RulesetInfo); + AddStep("mania ruleset", () => ruleset.Value = new ManiaRuleset().RulesetInfo); + AddStep("taiko ruleset", () => ruleset.Value = new TaikoRuleset().RulesetInfo); + AddStep("catch ruleset", () => ruleset.Value = new CatchRuleset().RulesetInfo); + AddStep("Deselect all", () => modSelector.DeselectAll()); + AddStep("null ruleset", () => ruleset.Value = null); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.cs new file mode 100644 index 0000000000..afa559280c --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneLeaderboardScopeSelector.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.Overlays.BeatmapSet; +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Game.Screens.Select.Leaderboards; +using osu.Framework.Allocation; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneLeaderboardScopeSelector : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + public TestSceneLeaderboardScopeSelector() + { + Bindable scope = new Bindable(); + + Add(new LeaderboardScopeSelector + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = { BindTarget = scope } + }); + + AddStep(@"Select global", () => scope.Value = BeatmapLeaderboardScope.Global); + AddStep(@"Select country", () => scope.Value = BeatmapLeaderboardScope.Country); + AddStep(@"Select friend", () => scope.Value = BeatmapLeaderboardScope.Friend); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsCard.cs new file mode 100644 index 0000000000..17675bfbc0 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsCard.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 osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Overlays.News; +using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Allocation; +using osu.Game.Overlays; +using osuTK; +using System; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneNewsCard : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Purple); + + public TestSceneNewsCard() + { + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Width = 500, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 20), + Children = new[] + { + new NewsCard(new APINewsPost + { + Title = "This post has an image which starts with \"/\" and has many authors! (clickable)", + 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, + Slug = "2020-07-16-summer-theme-park-2020-voting-open" + }), + new NewsCard(new APINewsPost + { + Title = "This post has a full-url image! (HTML entity: &) (non-clickable)", + Preview = "boom (HTML entity: &)", + Author = "user (HTML entity: &)", + FirstImage = "https://assets.ppy.sh/artists/88/header.jpg", + PublishedAt = DateTimeOffset.Now + }) + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs new file mode 100644 index 0000000000..78288bf6e4 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.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.Overlays.News; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Framework.Allocation; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneNewsHeader : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private TestHeader header; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = header = new TestHeader + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + }); + + [Test] + public void TestControl() + { + AddAssert("Front page selected", () => header.Current.Value == "frontpage"); + AddAssert("1 tab total", () => header.TabCount == 1); + + AddStep("Set article 1", () => header.SetArticle("1")); + AddAssert("Article 1 selected", () => header.Current.Value == "1"); + AddAssert("2 tabs total", () => header.TabCount == 2); + + AddStep("Set article 2", () => header.SetArticle("2")); + AddAssert("Article 2 selected", () => header.Current.Value == "2"); + AddAssert("2 tabs total", () => header.TabCount == 2); + + AddStep("Set front page", () => header.SetFrontPage()); + AddAssert("Front page selected", () => header.Current.Value == "frontpage"); + AddAssert("1 tab total", () => header.TabCount == 1); + } + + private class TestHeader : NewsHeader + { + public int TabCount => TabControl.Items.Count; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.cs new file mode 100644 index 0000000000..37d51c16d2 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsOverlay.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 System; +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 TestSceneNewsOverlay : OsuTestScene + { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + private NewsOverlay news; + + [SetUp] + public void SetUp() => Schedule(() => Child = news = new NewsOverlay()); + + [Test] + public void TestRequest() + { + setUpNewsResponse(responseExample); + AddStep("Show", () => news.Show()); + AddStep("Show article", () => news.ShowArticle("article")); + } + + private void setUpNewsResponse(GetNewsResponse r) + => AddStep("set up response", () => + { + dummyAPI.HandleRequest = request => + { + if (!(request is GetNewsRequest getNewsRequest)) + return; + + getNewsRequest.TriggerSuccess(r); + }; + }); + + private GetNewsResponse responseExample => 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 + }, + 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 + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs new file mode 100644 index 0000000000..8e151a987a --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.Chat; +using osu.Game.Rulesets; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Online +{ + [HeadlessTest] + public class TestSceneNowPlayingCommand : OsuTestScene + { + [Cached(typeof(IChannelPostTarget))] + private PostTarget postTarget { get; set; } + + public TestSceneNowPlayingCommand() + { + Add(postTarget = new PostTarget()); + } + + [Test] + public void TestGenericActivity() + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.InLobby(null)); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is listening")); + } + + [Test] + public void TestEditActivity() + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.Editing(new BeatmapInfo())); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is editing")); + } + + [Test] + public void TestPlayActivity() + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.SoloGame(new BeatmapInfo(), new RulesetInfo())); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is playing")); + } + + [TestCase(true)] + [TestCase(false)] + public void TestLinkPresence(bool hasOnlineId) + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.InLobby(null)); + + AddStep("Set beatmap", () => Beatmap.Value = new DummyWorkingBeatmap(Audio, null) + { + BeatmapInfo = { OnlineBeatmapID = hasOnlineId ? 1234 : (int?)null } + }); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + if (hasOnlineId) + AddAssert("Check link presence", () => postTarget.LastMessage.Contains("https://osu.ppy.sh/b/1234")); + else + AddAssert("Check link not present", () => !postTarget.LastMessage.Contains("https://")); + } + + public class PostTarget : Component, IChannelPostTarget + { + public void PostMessage(string text, bool isAction = false, Channel target = null) + { + LastMessage = text; + } + + public string LastMessage { get; private set; } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneOnlineViewContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneOnlineViewContainer.cs new file mode 100644 index 0000000000..ec183adbbc --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneOnlineViewContainer.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 NUnit.Framework; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; +using osu.Game.Online.API; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneOnlineViewContainer : OsuTestScene + { + private readonly TestOnlineViewContainer onlineView; + + public TestSceneOnlineViewContainer() + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Child = onlineView = new TestOnlineViewContainer() + }; + } + + [Test] + public void TestOnlineStateVisibility() + { + AddStep("set status to online", () => ((DummyAPIAccess)API).SetState(APIState.Online)); + + AddUntilStep("children are visible", () => onlineView.ViewTarget.IsPresent); + AddUntilStep("loading animation is not visible", () => !onlineView.LoadingSpinner.IsPresent); + } + + [Test] + public void TestOfflineStateVisibility() + { + AddStep("set status to offline", () => ((DummyAPIAccess)API).SetState(APIState.Offline)); + + AddUntilStep("children are not visible", () => !onlineView.ViewTarget.IsPresent); + AddUntilStep("loading animation is not visible", () => !onlineView.LoadingSpinner.IsPresent); + } + + [Test] + public void TestConnectingStateVisibility() + { + AddStep("set status to connecting", () => ((DummyAPIAccess)API).SetState(APIState.Connecting)); + + AddUntilStep("children are not visible", () => !onlineView.ViewTarget.IsPresent); + AddUntilStep("loading animation is visible", () => onlineView.LoadingSpinner.IsPresent); + } + + [Test] + public void TestFailingStateVisibility() + { + AddStep("set status to failing", () => ((DummyAPIAccess)API).SetState(APIState.Failing)); + + AddUntilStep("children are not visible", () => !onlineView.ViewTarget.IsPresent); + AddUntilStep("loading animation is visible", () => onlineView.LoadingSpinner.IsPresent); + } + + private class TestOnlineViewContainer : OnlineViewContainer + { + public new LoadingSpinner LoadingSpinner => base.LoadingSpinner; + + public CompositeDrawable ViewTarget => base.Content; + + public TestOnlineViewContainer() + : base(@"Please sign in to view dummy test content") + { + RelativeSizeAxes = Axes.Both; + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Blue.Opacity(0.8f), + }, + new OsuSpriteText + { + Text = "dummy online content", + Font = OsuFont.Default.With(size: 40), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs index c344cb9598..6a847e4269 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneProfileRulesetSelector.cs @@ -3,38 +3,44 @@ using osu.Framework.Graphics; using osu.Game.Overlays.Profile.Header.Components; -using System; -using System.Collections.Generic; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; +using osu.Game.Users; +using osu.Framework.Bindables; +using osu.Game.Overlays; +using osu.Framework.Allocation; namespace osu.Game.Tests.Visual.Online { public class TestSceneProfileRulesetSelector : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ProfileRulesetSelector), - typeof(ProfileRulesetTabItem), - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); public TestSceneProfileRulesetSelector() { ProfileRulesetSelector selector; + var user = new Bindable(); Child = selector = new ProfileRulesetSelector { Anchor = Anchor.Centre, Origin = Anchor.Centre, + User = { BindTarget = user } }; AddStep("set osu! as default", () => selector.SetDefaultRuleset(new OsuRuleset().RulesetInfo)); AddStep("set mania as default", () => selector.SetDefaultRuleset(new ManiaRuleset().RulesetInfo)); AddStep("set taiko as default", () => selector.SetDefaultRuleset(new TaikoRuleset().RulesetInfo)); AddStep("set catch as default", () => selector.SetDefaultRuleset(new CatchRuleset().RulesetInfo)); - AddStep("select default ruleset", selector.SelectDefaultRuleset); + + AddStep("User with osu as default", () => user.Value = new User { PlayMode = "osu" }); + AddStep("User with mania as default", () => user.Value = new User { PlayMode = "mania" }); + AddStep("User with taiko as default", () => user.Value = new User { PlayMode = "taiko" }); + AddStep("User with catch as default", () => user.Value = new User { PlayMode = "fruits" }); + AddStep("null user", () => user.Value = null); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs b/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs index 709e75ab13..3b31192259 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneRankGraph.cs @@ -1,14 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; 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; -using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; using osuTK; @@ -18,11 +17,8 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneRankGraph : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(RankGraph), - typeof(LineGraph) - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); public TestSceneRankGraph() { @@ -69,28 +65,22 @@ namespace osu.Game.Tests.Visual.Online } }); - AddStep("null user", () => graph.User.Value = null); + AddStep("null user", () => graph.Statistics.Value = null); AddStep("rank only", () => { - graph.User.Value = new User + graph.Statistics.Value = new UserStatistics { - Statistics = new UserStatistics - { - Ranks = new UserStatistics.UserRanks { Global = 123456 }, - PP = 12345, - } + Ranks = new UserStatistics.UserRanks { Global = 123456 }, + PP = 12345, }; }); AddStep("with rank history", () => { - graph.User.Value = new User + graph.Statistics.Value = new UserStatistics { - Statistics = new UserStatistics - { - Ranks = new UserStatistics.UserRanks { Global = 89000 }, - PP = 12345, - }, + Ranks = new UserStatistics.UserRanks { Global = 89000 }, + PP = 12345, RankHistory = new User.RankHistoryData { Data = data, @@ -100,13 +90,10 @@ namespace osu.Game.Tests.Visual.Online AddStep("with zero values", () => { - graph.User.Value = new User + graph.Statistics.Value = new UserStatistics { - Statistics = new UserStatistics - { - Ranks = new UserStatistics.UserRanks { Global = 89000 }, - PP = 12345, - }, + Ranks = new UserStatistics.UserRanks { Global = 89000 }, + PP = 12345, RankHistory = new User.RankHistoryData { Data = dataWithZeros, @@ -116,13 +103,10 @@ namespace osu.Game.Tests.Visual.Online AddStep("small amount of data", () => { - graph.User.Value = new User + graph.Statistics.Value = new UserStatistics { - Statistics = new UserStatistics - { - Ranks = new UserStatistics.UserRanks { Global = 12000 }, - PP = 12345, - }, + Ranks = new UserStatistics.UserRanks { Global = 12000 }, + PP = 12345, RankHistory = new User.RankHistoryData { Data = smallData, @@ -132,13 +116,10 @@ namespace osu.Game.Tests.Visual.Online AddStep("graph with edges", () => { - graph.User.Value = new User + graph.Statistics.Value = new UserStatistics { - Statistics = new UserStatistics - { - Ranks = new UserStatistics.UserRanks { Global = 12000 }, - PP = 12345, - }, + Ranks = new UserStatistics.UserRanks { Global = 12000 }, + PP = 12345, RankHistory = new User.RankHistoryData { Data = edgyData, diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.cs new file mode 100644 index 0000000000..458ba80712 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsCountryFilter.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 osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays.Rankings; +using osu.Game.Users; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Framework.Allocation; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneRankingsCountryFilter : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + public TestSceneRankingsCountryFilter() + { + var countryBindable = new Bindable(); + + AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Gray, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new CountryFilter + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Current = { BindTarget = countryBindable } + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "Some content", + Margin = new MarginPadding { Vertical = 20 } + } + } + } + }); + + var country = new Country + { + FlagName = "BY", + FullName = "Belarus" + }; + var unknownCountry = new Country + { + FlagName = "CK", + FullName = "Cook Islands" + }; + + AddStep("Set country", () => countryBindable.Value = country); + AddStep("Set null country", () => countryBindable.Value = null); + AddStep("Set country with no flag", () => countryBindable.Value = unknownCountry); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.cs new file mode 100644 index 0000000000..677952681c --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsHeader.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.Allocation; +using osu.Framework.Bindables; +using osu.Game.Overlays; +using osu.Game.Overlays.Rankings; +using osu.Game.Rulesets; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneRankingsHeader : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Green); + + public TestSceneRankingsHeader() + { + var countryBindable = new Bindable(); + var ruleset = new Bindable(); + var scope = new Bindable(); + + Add(new RankingsOverlayHeader + { + Current = { BindTarget = scope }, + Country = { BindTarget = countryBindable }, + Ruleset = { BindTarget = ruleset } + }); + + var country = new Country + { + FlagName = "BY", + FullName = "Belarus" + }; + + var unknownCountry = new Country + { + FlagName = "CK", + FullName = "Cook Islands" + }; + + AddStep("Set country", () => countryBindable.Value = country); + AddStep("Set scope to Score", () => scope.Value = RankingsScope.Score); + AddStep("Set country with no flag", () => countryBindable.Value = unknownCountry); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.cs new file mode 100644 index 0000000000..626f545b91 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsOverlay.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 osu.Framework.Allocation; +using osu.Game.Overlays; +using NUnit.Framework; +using osu.Game.Users; +using osu.Framework.Bindables; +using osu.Game.Overlays.Rankings; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneRankingsOverlay : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + [Cached(typeof(RankingsOverlay))] + private readonly RankingsOverlay rankingsOverlay; + + private readonly Bindable countryBindable = new Bindable(); + private readonly Bindable scope = new Bindable(); + + public TestSceneRankingsOverlay() + { + Add(rankingsOverlay = new TestRankingsOverlay + { + Country = { BindTarget = countryBindable }, + Scope = { BindTarget = scope }, + }); + } + + [Test] + public void TestShow() + { + AddStep("Show", rankingsOverlay.Show); + } + + [Test] + public void TestFlagScopeDependency() + { + AddStep("Set scope to Score", () => scope.Value = RankingsScope.Score); + AddAssert("Check country is Null", () => countryBindable.Value == null); + AddStep("Set country", () => countryBindable.Value = us_country); + AddAssert("Check scope is Performance", () => scope.Value == RankingsScope.Performance); + } + + [Test] + public void TestShowCountry() + { + AddStep("Show US", () => rankingsOverlay.ShowCountry(us_country)); + } + + [Test] + public void TestHide() + { + AddStep("Hide", rankingsOverlay.Hide); + } + + private static readonly Country us_country = new Country + { + FlagName = "US", + FullName = "United States" + }; + + private class TestRankingsOverlay : RankingsOverlay + { + public new Bindable Country => base.Country; + + public new Bindable Scope => base.Scope; + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.cs new file mode 100644 index 0000000000..d60222fa0b --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsSpotlightSelector.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.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Rankings; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneRankingsSpotlightSelector : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [Resolved] + private IAPIProvider api { get; set; } + + private readonly SpotlightSelector selector; + + public TestSceneRankingsSpotlightSelector() + { + Add(selector = new SpotlightSelector()); + } + + [Test] + public void TestLocalSpotlights() + { + var spotlights = new[] + { + new APISpotlight + { + Name = "Spotlight 1", + StartDate = DateTimeOffset.Now, + EndDate = DateTimeOffset.Now, + }, + new APISpotlight + { + Name = "Spotlight 2", + StartDate = DateTimeOffset.Now, + EndDate = DateTimeOffset.Now, + }, + new APISpotlight + { + Name = "Spotlight 3", + StartDate = DateTimeOffset.Now, + EndDate = DateTimeOffset.Now, + }, + }; + + AddStep("load spotlights", () => selector.Spotlights = spotlights); + AddStep("change to spotlight 3", () => selector.Current.Value = spotlights[2]); + } + + [Test] + public void TestOnlineSpotlights() + { + List spotlights = null; + + AddStep("retrieve spotlights", () => + { + var req = new GetSpotlightsRequest(); + req.Success += res => spotlights = res.Spotlights; + + api.Perform(req); + }); + + AddStep("set spotlights", () => + { + if (spotlights != null) + selector.Spotlights = spotlights; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs b/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs new file mode 100644 index 0000000000..ee109189c7 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneRankingsTables.cs @@ -0,0 +1,137 @@ +// 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.Overlays.Rankings.Tables; +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; +using System.Threading; +using osu.Game.Online.API; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Taiko; +using osu.Game.Rulesets.Catch; +using osu.Framework.Allocation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Overlays.Rankings; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneRankingsTables : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + [Resolved] + private IAPIProvider api { get; set; } + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + private readonly BasicScrollContainer scrollFlow; + private readonly LoadingLayer loading; + private CancellationTokenSource cancellationToken; + private APIRequest request; + + public TestSceneRankingsTables() + { + Children = new Drawable[] + { + scrollFlow = new BasicScrollContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.8f, + }, + loading = new LoadingLayer(), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddStep("Osu performance", () => createPerformanceTable(new OsuRuleset().RulesetInfo, null)); + AddStep("Mania scores", () => createScoreTable(new ManiaRuleset().RulesetInfo)); + AddStep("Taiko country scores", () => createCountryTable(new TaikoRuleset().RulesetInfo)); + AddStep("Catch US performance page 10", () => createPerformanceTable(new CatchRuleset().RulesetInfo, "US", 10)); + AddStep("Osu spotlight table (chart 271)", () => createSpotlightTable(new OsuRuleset().RulesetInfo, 271)); + } + + private void createCountryTable(RulesetInfo ruleset, int page = 1) + { + onLoadStarted(); + + request = new GetCountryRankingsRequest(ruleset, page); + ((GetCountryRankingsRequest)request).Success += rankings => Schedule(() => + { + var table = new CountriesTable(page, rankings.Countries); + loadTable(table); + }); + + api.Queue(request); + } + + private void createPerformanceTable(RulesetInfo ruleset, string country, int page = 1) + { + onLoadStarted(); + + request = new GetUserRankingsRequest(ruleset, country: country, page: page); + ((GetUserRankingsRequest)request).Success += rankings => Schedule(() => + { + var table = new PerformanceTable(page, rankings.Users); + loadTable(table); + }); + + api.Queue(request); + } + + private void createScoreTable(RulesetInfo ruleset, int page = 1) + { + onLoadStarted(); + + request = new GetUserRankingsRequest(ruleset, UserRankingsType.Score, page); + ((GetUserRankingsRequest)request).Success += rankings => Schedule(() => + { + var table = new ScoresTable(page, rankings.Users); + loadTable(table); + }); + + api.Queue(request); + } + + private void createSpotlightTable(RulesetInfo ruleset, int spotlight) + { + onLoadStarted(); + + request = new GetSpotlightRankingsRequest(ruleset, spotlight, RankingsSortCriteria.All); + ((GetSpotlightRankingsRequest)request).Success += rankings => Schedule(() => + { + var table = new ScoresTable(1, rankings.Users); + loadTable(table); + }); + + api.Queue(request); + } + + private void onLoadStarted() + { + loading.Show(); + request?.Cancel(); + cancellationToken?.Cancel(); + cancellationToken = new CancellationTokenSource(); + } + + private void loadTable(Drawable table) + { + LoadComponentAsync(table, t => + { + scrollFlow.Clear(); + scrollFlow.Add(t); + loading.Hide(); + }, cancellationToken.Token); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs index b26de1984a..0cb8cc22ec 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs @@ -1,17 +1,16 @@ -// 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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet.Scores; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Users; using osuTK.Graphics; @@ -20,14 +19,8 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneScoresContainer : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(DrawableTopScore), - typeof(TopScoreUserSection), - typeof(TopScoreStatisticsSection), - typeof(ScoreTable), - typeof(ScoreTableRowBackground), - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); public TestSceneScoresContainer() { @@ -66,12 +59,12 @@ namespace osu.Game.Tests.Visual.Online FlagName = @"ES", }, }, - Mods = new Mod[] + Mods = new[] { - new OsuModDoubleTime(), - new OsuModHidden(), - new OsuModFlashlight(), - new OsuModHardRock(), + new OsuModDoubleTime().Acronym, + new OsuModHidden().Acronym, + new OsuModFlashlight().Acronym, + new OsuModHardRock().Acronym, }, Rank = ScoreRank.XH, PP = 200, @@ -91,11 +84,11 @@ namespace osu.Game.Tests.Visual.Online FlagName = @"BR", }, }, - Mods = new Mod[] + Mods = new[] { - new OsuModDoubleTime(), - new OsuModHidden(), - new OsuModFlashlight(), + new OsuModDoubleTime().Acronym, + new OsuModHidden().Acronym, + new OsuModFlashlight().Acronym, }, Rank = ScoreRank.S, PP = 190, @@ -115,10 +108,10 @@ namespace osu.Game.Tests.Visual.Online FlagName = @"JP", }, }, - Mods = new Mod[] + Mods = new[] { - new OsuModDoubleTime(), - new OsuModHidden(), + new OsuModDoubleTime().Acronym, + new OsuModHidden().Acronym, }, Rank = ScoreRank.B, PP = 180, @@ -138,9 +131,9 @@ namespace osu.Game.Tests.Visual.Online FlagName = @"CA", }, }, - Mods = new Mod[] + Mods = new[] { - new OsuModDoubleTime(), + new OsuModDoubleTime().Acronym, }, Rank = ScoreRank.C, PP = 170, @@ -192,6 +185,29 @@ namespace osu.Game.Tests.Visual.Online Position = 1337, }; + var myBestScoreWithNullPosition = new APILegacyUserTopScoreInfo + { + Score = new APILegacyScoreInfo + { + User = new User + { + Id = 7151382, + Username = @"Mayuri Hana", + Country = new Country + { + FullName = @"Thailand", + FlagName = @"TH", + }, + }, + Rank = ScoreRank.D, + PP = 160, + MaxCombo = 1234, + TotalScore = 123456, + Accuracy = 0.6543, + }, + Position = null, + }; + var oneScore = new APILegacyScores { Scores = new List @@ -208,12 +224,12 @@ namespace osu.Game.Tests.Visual.Online FlagName = @"ES", }, }, - Mods = new Mod[] + Mods = new[] { - new OsuModDoubleTime(), - new OsuModHidden(), - new OsuModFlashlight(), - new OsuModHardRock(), + new OsuModDoubleTime().Acronym, + new OsuModHidden().Acronym, + new OsuModFlashlight().Acronym, + new OsuModHardRock().Acronym, }, Rank = ScoreRank.XH, PP = 200, @@ -226,10 +242,13 @@ namespace osu.Game.Tests.Visual.Online foreach (var s in allScores.Scores) { - s.Statistics.Add(HitResult.Great, RNG.Next(2000)); - s.Statistics.Add(HitResult.Good, RNG.Next(2000)); - s.Statistics.Add(HitResult.Meh, RNG.Next(2000)); - s.Statistics.Add(HitResult.Miss, RNG.Next(2000)); + s.Statistics = new Dictionary + { + { "count_300", RNG.Next(2000) }, + { "count_100", RNG.Next(2000) }, + { "count_50", RNG.Next(2000) }, + { "count_miss", RNG.Next(2000) } + }; } AddStep("Load all scores", () => @@ -244,6 +263,12 @@ namespace osu.Game.Tests.Visual.Online allScores.UserScore = myBestScore; scoresContainer.Scores = allScores; }); + + AddStep("Load scores with null my best position", () => + { + allScores.UserScore = myBestScoreWithNullPosition; + scoresContainer.Scores = allScores; + }); } private class TestScoresContainer : ScoresContainer diff --git a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs index bccb263600..18ac415126 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneShowMoreButton.cs @@ -1,19 +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.Game.Overlays.Profile.Sections; -using System; -using System.Collections.Generic; using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Allocation; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.Online { public class TestSceneShowMoreButton : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ShowMoreButton), - }; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); public TestSceneShowMoreButton() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneSocialOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneSocialOverlay.cs deleted file mode 100644 index 5cb96c7ed2..0000000000 --- a/osu.Game.Tests/Visual/Online/TestSceneSocialOverlay.cs +++ /dev/null @@ -1,94 +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.Overlays; -using osu.Game.Overlays.Social; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Online -{ - [TestFixture] - public class TestSceneSocialOverlay : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(UserPanel), - typeof(SocialPanel), - typeof(FilterControl), - typeof(SocialGridPanel), - typeof(SocialListPanel) - }; - - public TestSceneSocialOverlay() - { - SocialOverlay s = new SocialOverlay - { - Users = new[] - { - new User - { - Username = @"flyte", - Id = 3103765, - Country = new Country { FlagName = @"JP" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", - }, - new User - { - Username = @"Cookiezi", - Id = 124493, - Country = new Country { FlagName = @"KR" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", - }, - new User - { - Username = @"Angelsim", - Id = 1777162, - Country = new Country { FlagName = @"KR" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", - }, - new User - { - Username = @"Rafis", - Id = 2558286, - Country = new Country { FlagName = @"PL" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c4.jpg", - }, - new User - { - Username = @"hvick225", - Id = 50265, - Country = new Country { FlagName = @"TW" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c5.jpg", - }, - new User - { - Username = @"peppy", - Id = 2, - Country = new Country { FlagName = @"AU" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" - }, - new User - { - Username = @"filsdelama", - Id = 2831793, - Country = new Country { FlagName = @"FR" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c7.jpg" - }, - new User - { - Username = @"_index", - Id = 652457, - Country = new Country { FlagName = @"RU" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c8.jpg" - }, - }, - }; - Add(s); - - AddStep(@"toggle", s.ToggleVisibility); - } - } -} diff --git a/osu.Game.Tests/Visual/Online/TestSceneSpotlightsLayout.cs b/osu.Game.Tests/Visual/Online/TestSceneSpotlightsLayout.cs new file mode 100644 index 0000000000..266dcb013b --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneSpotlightsLayout.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Overlays.Rankings; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneSpotlightsLayout : OsuTestScene + { + protected override bool UseOnlineAPI => true; + + [Cached] + private readonly OverlayColourProvider overlayColour = new OverlayColourProvider(OverlayColourScheme.Green); + + public TestSceneSpotlightsLayout() + { + var ruleset = new Bindable(new OsuRuleset().RulesetInfo); + + Add(new BasicScrollContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.8f, + Child = new SpotlightsLayout + { + Ruleset = { BindTarget = ruleset } + } + }); + + AddStep("Osu ruleset", () => ruleset.Value = new OsuRuleset().RulesetInfo); + AddStep("Mania ruleset", () => ruleset.Value = new ManiaRuleset().RulesetInfo); + AddStep("Taiko ruleset", () => ruleset.Value = new TaikoRuleset().RulesetInfo); + AddStep("Catch ruleset", () => ruleset.Value = new CatchRuleset().RulesetInfo); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs index 91006bc0d9..492abdd88d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs @@ -6,6 +6,11 @@ using osu.Framework.Graphics; using osu.Game.Online.Chat; using osu.Game.Users; using osuTK; +using System; +using System.Linq; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays.Chat; namespace osu.Game.Tests.Visual.Online { @@ -32,17 +37,23 @@ namespace osu.Game.Tests.Visual.Online Id = 4, }; + private readonly User longUsernameUser = new User + { + Username = "Very Long Long Username", + Id = 5, + }; + [Cached] private ChannelManager channelManager = new ChannelManager(); - private readonly StandAloneChatDisplay chatDisplay; - private readonly StandAloneChatDisplay chatDisplay2; + private readonly TestStandAloneChatDisplay chatDisplay; + private readonly TestStandAloneChatDisplay chatDisplay2; public TestSceneStandAloneChatDisplay() { Add(channelManager); - Add(chatDisplay = new StandAloneChatDisplay + Add(chatDisplay = new TestStandAloneChatDisplay { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -50,7 +61,7 @@ namespace osu.Game.Tests.Visual.Online Size = new Vector2(400, 80) }); - Add(chatDisplay2 = new StandAloneChatDisplay(true) + Add(chatDisplay2 = new TestStandAloneChatDisplay(true) { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -99,6 +110,66 @@ namespace osu.Game.Tests.Visual.Online Sender = admin, Content = "Okay okay, calm down guys. Let's do this!" })); + + AddStep("message from long username", () => testChannel.AddNewMessages(new Message(sequence++) + { + Sender = longUsernameUser, + Content = "Hi guys, my new username is lit!" + })); + + AddStep("message with new date", () => testChannel.AddNewMessages(new Message(sequence++) + { + Sender = longUsernameUser, + Content = "Message from the future!", + Timestamp = DateTimeOffset.Now + })); + + AddUntilStep("ensure still scrolled to bottom", () => chatDisplay.ScrolledToBottom); + + const int messages_per_call = 10; + AddRepeatStep("add many messages", () => + { + for (int i = 0; i < messages_per_call; i++) + { + testChannel.AddNewMessages(new Message(sequence++) + { + Sender = longUsernameUser, + Content = "Many messages! " + Guid.NewGuid(), + Timestamp = DateTimeOffset.Now + }); + } + }, Channel.MAX_HISTORY / messages_per_call + 5); + + AddAssert("Ensure no adjacent day separators", () => + { + var indices = chatDisplay.FillFlow.OfType().Select(ds => chatDisplay.FillFlow.IndexOf(ds)); + + foreach (var i in indices) + { + if (i < chatDisplay.FillFlow.Count && chatDisplay.FillFlow[i + 1] is DrawableChannel.DaySeparator) + return false; + } + + return true; + }); + + AddUntilStep("ensure still scrolled to bottom", () => chatDisplay.ScrolledToBottom); + } + + private class TestStandAloneChatDisplay : StandAloneChatDisplay + { + public TestStandAloneChatDisplay(bool textbox = false) + : base(textbox) + { + } + + protected DrawableChannel DrawableChannel => InternalChildren.OfType().First(); + + protected OsuScrollContainer ScrollContainer => (OsuScrollContainer)((Container)DrawableChannel.Child).Child; + + public FillFlowContainer FillFlow => (FillFlowContainer)ScrollContainer.Child; + + public bool ScrolledToBottom => ScrollContainer.IsScrolledToEnd(1); } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.cs b/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.cs new file mode 100644 index 0000000000..f168ae5035 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneTotalCommentsCounter.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.Graphics; +using osu.Framework.Bindables; +using osu.Game.Overlays.Comments; +using osu.Framework.Utils; +using osu.Framework.Allocation; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneTotalCommentsCounter : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + public TestSceneTotalCommentsCounter() + { + var count = new BindableInt(); + + Add(new TotalCommentsCounter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = { BindTarget = count } + }); + + AddStep(@"Set 100", () => count.Value = 100); + AddStep(@"Set 0", () => count.Value = 0); + AddStep(@"Set random", () => count.Value = RNG.Next(0, int.MaxValue)); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index 54f06d6ad2..c2e9945c99 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.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.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets; using osu.Game.Users; using osuTK; @@ -13,28 +16,53 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneUserPanel : OsuTestScene { - private readonly UserPanel peppy; + private readonly Bindable activity = new Bindable(); + private readonly Bindable status = new Bindable(); - public TestSceneUserPanel() + private UserGridPanel peppy; + private TestUserListPanel evast; + + [Resolved] + private RulesetStore rulesetStore { get; set; } + + [SetUp] + public void SetUp() => Schedule(() => { - UserPanel flyte; + UserGridPanel flyte; - Add(new FillFlowContainer + activity.Value = null; + status.Value = null; + + Child = new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, Spacing = new Vector2(10f), - Children = new[] + Children = new Drawable[] { - flyte = new UserPanel(new User + new UserBrickPanel(new User + { + Username = @"flyte", + Id = 3103765, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" + }), + new UserBrickPanel(new User + { + Username = @"peppy", + Id = 2, + Colour = "99EB47", + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }), + flyte = new UserGridPanel(new User { Username = @"flyte", Id = 3103765, Country = new Country { FlagName = @"JP" }, CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" }) { Width = 300 }, - peppy = new UserPanel(new User + peppy = new UserGridPanel(new User { Username = @"peppy", Id = 2, @@ -43,35 +71,75 @@ namespace osu.Game.Tests.Visual.Online IsSupporter = true, SupportLevel = 3, }) { Width = 300 }, + evast = new TestUserListPanel(new User + { + Username = @"Evast", + Id = 8195163, + Country = new Country { FlagName = @"BY" }, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg", + IsOnline = false, + LastVisit = DateTimeOffset.Now + }) }, - }); + }; flyte.Status.Value = new UserStatusOnline(); - peppy.Status.Value = null; - } - - [Test] - public void UserStatusesTests() - { - AddStep("online", () => { peppy.Status.Value = new UserStatusOnline(); }); - AddStep(@"do not disturb", () => { peppy.Status.Value = new UserStatusDoNotDisturb(); }); - AddStep(@"offline", () => { peppy.Status.Value = new UserStatusOffline(); }); - AddStep(@"null status", () => { peppy.Status.Value = null; }); - } - - [Test] - public void UserActivitiesTests() - { - Bindable activity = new Bindable(); + peppy.Status.BindTo(status); peppy.Activity.BindTo(activity); - AddStep("idle", () => { activity.Value = null; }); - AddStep("spectating", () => { activity.Value = new UserActivity.Spectating(); }); - AddStep("solo", () => { activity.Value = new UserActivity.SoloGame(null, null); }); - AddStep("choosing", () => { activity.Value = new UserActivity.ChoosingBeatmap(); }); - AddStep("editing", () => { activity.Value = new UserActivity.Editing(null); }); - AddStep("modding", () => { activity.Value = new UserActivity.Modding(); }); + evast.Status.BindTo(status); + evast.Activity.BindTo(activity); + }); + + [Test] + public void TestUserStatus() + { + AddStep("online", () => status.Value = new UserStatusOnline()); + AddStep("do not disturb", () => status.Value = new UserStatusDoNotDisturb()); + AddStep("offline", () => status.Value = new UserStatusOffline()); + AddStep("null status", () => status.Value = null); + } + + [Test] + public void TestUserActivity() + { + AddStep("set online status", () => status.Value = new UserStatusOnline()); + + AddStep("idle", () => activity.Value = null); + AddStep("spectating", () => activity.Value = new UserActivity.Spectating()); + AddStep("solo (osu!)", () => activity.Value = soloGameStatusForRuleset(0)); + AddStep("solo (osu!taiko)", () => activity.Value = soloGameStatusForRuleset(1)); + AddStep("solo (osu!catch)", () => activity.Value = soloGameStatusForRuleset(2)); + AddStep("solo (osu!mania)", () => activity.Value = soloGameStatusForRuleset(3)); + AddStep("choosing", () => activity.Value = new UserActivity.ChoosingBeatmap()); + AddStep("editing", () => activity.Value = new UserActivity.Editing(null)); + AddStep("modding", () => activity.Value = new UserActivity.Modding()); + } + + [Test] + public void TestUserActivityChange() + { + AddAssert("visit message is visible", () => evast.LastVisitMessage.IsPresent); + AddStep("set online status", () => status.Value = new UserStatusOnline()); + AddAssert("visit message is not visible", () => !evast.LastVisitMessage.IsPresent); + AddStep("set choosing activity", () => activity.Value = new UserActivity.ChoosingBeatmap()); + AddStep("set offline status", () => status.Value = new UserStatusOffline()); + AddAssert("visit message is visible", () => evast.LastVisitMessage.IsPresent); + AddStep("set online status", () => status.Value = new UserStatusOnline()); + AddAssert("visit message is not visible", () => !evast.LastVisitMessage.IsPresent); + } + + private UserActivity soloGameStatusForRuleset(int rulesetId) => new UserActivity.SoloGame(null, rulesetStore.GetRuleset(rulesetId)); + + private class TestUserListPanel : UserListPanel + { + public TestUserListPanel(User user) + : base(user) + { + } + + public new TextFlowContainer LastVisitMessage => base.LastVisitMessage; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs index 2285c9b799..04b741b2bb 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs @@ -2,32 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Allocation; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays; using osu.Game.Overlays.Profile; -using osu.Game.Overlays.Profile.Header; -using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { public class TestSceneUserProfileHeader : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ProfileHeader), - typeof(RankGraph), - typeof(LineGraph), - typeof(OverlayHeaderTabControl), - typeof(CentreHeaderContainer), - typeof(BottomHeaderContainer), - typeof(DetailHeaderContainer), - typeof(ProfileHeaderButton) - }; + protected override bool UseOnlineAPI => true; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); [Resolved] private IAPIProvider api { get; set; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index c2376aa153..7ade24f4de 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -2,16 +2,12 @@ // 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.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Overlays; using osu.Game.Overlays.Profile; -using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -19,20 +15,13 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneUserProfileOverlay : OsuTestScene { + protected override bool UseOnlineAPI => true; + private readonly TestUserProfileOverlay profile; [Resolved] private IAPIProvider api { get; set; } - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ProfileHeader), - typeof(RankGraph), - typeof(LineGraph), - typeof(SectionsContainer<>), - typeof(SupporterIcon) - }; - public static readonly User TEST_USER = new User { Username = @"Somebody", @@ -50,12 +39,12 @@ namespace osu.Game.Tests.Visual.Online { Current = 727, Progress = 69, - } - }, - RankHistory = new User.RankHistoryData - { - Mode = @"osu", - Data = Enumerable.Range(2345, 45).Concat(Enumerable.Range(2109, 40)).ToArray() + }, + RankHistory = new User.RankHistoryData + { + Mode = @"osu", + Data = Enumerable.Range(2345, 45).Concat(Enumerable.Range(2109, 40)).ToArray() + }, }, Badges = new[] { @@ -68,7 +57,7 @@ namespace osu.Game.Tests.Visual.Online }, Title = "osu!volunteer", Colour = "ff0000", - Achievements = new User.UserAchievement[0], + Achievements = Array.Empty(), }; public TestSceneUserProfileOverlay() @@ -105,6 +94,15 @@ namespace osu.Game.Tests.Visual.Online CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" }, api.IsLoggedIn)); + AddStep("Show bancho", () => profile.ShowUser(new User + { + Username = @"BanchoBot", + Id = 3, + IsBot = true, + Country = new Country { FullName = @"Saint Helena", FlagName = @"SH" }, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c4.jpg" + }, api.IsLoggedIn)); + AddStep("Hide", profile.Hide); AddStep("Show without reload", profile.Show); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs new file mode 100644 index 0000000000..1e9d62f379 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.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; +using NUnit.Framework; +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.Overlays.Profile.Header.Components; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneUserProfilePreviousUsernames : OsuTestScene + { + [Resolved] + private IAPIProvider api { get; set; } + + private readonly Bindable user = new Bindable(); + + public TestSceneUserProfilePreviousUsernames() + { + Child = new PreviousUsernames + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + User = { BindTarget = user }, + }; + + User[] users = + { + new User { PreviousUsernames = new[] { "username1" } }, + new User { PreviousUsernames = new[] { "longusername", "longerusername" } }, + new User { PreviousUsernames = new[] { "test", "angelsim", "verylongusername" } }, + new User { PreviousUsernames = new[] { "ihavenoidea", "howcani", "makethistext", "anylonger" } }, + new User { PreviousUsernames = Array.Empty() }, + null + }; + + AddStep("single username", () => user.Value = users[0]); + AddStep("two usernames", () => user.Value = users[1]); + AddStep("three usernames", () => user.Value = users[2]); + AddStep("four usernames", () => user.Value = users[3]); + AddStep("no username", () => user.Value = users[4]); + AddStep("null user", () => user.Value = users[5]); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddStep("online user (Angelsim)", () => + { + var request = new GetUserRequest(1777162); + request.Success += user => this.user.Value = user; + api.Queue(request); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs index f022425bf6..0973076c40 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileRecentSection.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.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -12,7 +12,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Overlays.Profile.Sections; +using osu.Game.Overlays; using osu.Game.Overlays.Profile.Sections.Recent; namespace osu.Game.Tests.Visual.Online @@ -20,13 +20,8 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneUserProfileRecentSection : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(RecentSection), - typeof(DrawableRecentActivity), - typeof(PaginatedRecentActivityContainer), - typeof(MedalIcon) - }; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); public TestSceneUserProfileRecentSection() { @@ -131,6 +126,22 @@ namespace osu.Game.Tests.Visual.Online Beatmap = dummyBeatmap, }, new APIRecentActivity + { + User = dummyUser, + Type = RecentActivityType.Rank, + Rank = 1, + Mode = "vitaru", + Beatmap = dummyBeatmap, + }, + new APIRecentActivity + { + User = dummyUser, + Type = RecentActivityType.Rank, + Rank = 1, + Mode = "fruits", + Beatmap = dummyBeatmap, + }, + new APIRecentActivity { User = dummyUser, Type = RecentActivityType.RankLost, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs new file mode 100644 index 0000000000..5dca218531 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.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 osu.Game.Overlays.Profile.Sections.Ranks; +using osu.Framework.Graphics; +using osu.Game.Scoring; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Overlays; +using osu.Framework.Allocation; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneUserProfileScores : OsuTestScene + { + public TestSceneUserProfileScores() + { + var firstScore = new ScoreInfo + { + PP = 1047.21, + Rank = ScoreRank.SH, + Beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "JUSTadICE (TV Size)", + Artist = "Oomori Seiko" + }, + Version = "Extreme" + }, + Date = DateTimeOffset.Now, + Mods = new Mod[] + { + new OsuModHidden(), + new OsuModHardRock(), + new OsuModDoubleTime() + }, + Accuracy = 0.9813 + }; + + var secondScore = new ScoreInfo + { + PP = 134.32, + Rank = ScoreRank.A, + Beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "Triumph & Regret", + Artist = "typeMARS" + }, + Version = "[4K] Regret" + }, + Date = DateTimeOffset.Now, + Mods = new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime(), + }, + Accuracy = 0.998546 + }; + + var thirdScore = new ScoreInfo + { + PP = 96.83, + Rank = ScoreRank.S, + Beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "Idolize", + Artist = "Creo" + }, + Version = "Insane" + }, + Date = DateTimeOffset.Now, + Accuracy = 0.9726 + }; + + var noPPScore = new ScoreInfo + { + Rank = ScoreRank.B, + Beatmap = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "C18H27NO3(extend)", + Artist = "Team Grimoire" + }, + Version = "[4K] Cataclysmic Hypernova" + }, + Date = DateTimeOffset.Now, + Accuracy = 0.55879 + }; + + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new[] + { + new ColourProvidedContainer(OverlayColourScheme.Green, new DrawableProfileScore(firstScore)), + new ColourProvidedContainer(OverlayColourScheme.Green, new DrawableProfileScore(secondScore)), + new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileScore(noPPScore)), + new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(firstScore, 0.97)), + new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(secondScore, 0.85)), + new ColourProvidedContainer(OverlayColourScheme.Pink, new DrawableProfileWeightedScore(thirdScore, 0.66)), + } + }); + } + + private class ColourProvidedContainer : Container + { + [Cached] + private readonly OverlayColourProvider colourProvider; + + public ColourProvidedContainer(OverlayColourScheme colourScheme, DrawableProfileScore score) + { + colourProvider = new OverlayColourProvider(colourScheme); + + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + Add(score); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs b/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs index 9f0a8c769a..c22cff4af6 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserRanks.cs @@ -1,16 +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 NUnit.Framework; +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; using osu.Game.Overlays.Profile.Sections; -using osu.Game.Overlays.Profile.Sections.Ranks; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -18,7 +17,10 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneUserRanks : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(DrawableProfileScore), typeof(RanksSection) }; + protected override bool UseOnlineAPI => true; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); public TestSceneUserRanks() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserRequest.cs b/osu.Game.Tests/Visual/Online/TestSceneUserRequest.cs new file mode 100644 index 0000000000..15cfd3ee54 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneUserRequest.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; +using osu.Game.Users; +using osu.Framework.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Taiko; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneUserRequest : OsuTestScene + { + [Resolved] + private IAPIProvider api { get; set; } + + private readonly Bindable user = new Bindable(); + private GetUserRequest request; + private readonly LoadingLayer loading; + + public TestSceneUserRequest() + { + Add(new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new UserTestContainer + { + User = { BindTarget = user } + }, + loading = new LoadingLayer() + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddStep(@"local user", () => getUser()); + AddStep(@"local user with taiko ruleset", () => getUser(ruleset: new TaikoRuleset().RulesetInfo)); + AddStep(@"cookiezi", () => getUser(124493)); + AddStep(@"cookiezi with mania ruleset", () => getUser(124493, new ManiaRuleset().RulesetInfo)); + } + + private void getUser(long? userId = null, RulesetInfo ruleset = null) + { + loading.Show(); + + request?.Cancel(); + request = new GetUserRequest(userId, ruleset); + request.Success += user => + { + this.user.Value = user; + loading.Hide(); + }; + api.Queue(request); + } + + private class UserTestContainer : FillFlowContainer + { + public readonly Bindable User = new Bindable(); + + public UserTestContainer() + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Vertical; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + User.BindValueChanged(onUserUpdate, true); + } + + private void onUserUpdate(ValueChangedEvent user) + { + Clear(); + + AddRange(new Drawable[] + { + new OsuSpriteText + { + Text = $@"Username: {user.NewValue?.Username}" + }, + new OsuSpriteText + { + Text = $@"RankedScore: {user.NewValue?.Statistics.RankedScore}" + }, + }); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs new file mode 100644 index 0000000000..9bb29541ec --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneVotePill.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Overlays.Comments; +using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Allocation; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneVotePill : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private VotePill votePill; + + [Test] + public void TestUserCommentPill() + { + AddStep("Log in", logIn); + AddStep("User comment", () => addVotePill(getUserComment())); + AddStep("Click", () => votePill.Click()); + AddAssert("Not loading", () => !votePill.IsLoading); + } + + [Test] + public void TestRandomCommentPill() + { + AddStep("Log in", logIn); + AddStep("Random comment", () => addVotePill(getRandomComment())); + AddStep("Click", () => votePill.Click()); + AddAssert("Loading", () => votePill.IsLoading); + } + + [Test] + public void TestOfflineRandomCommentPill() + { + AddStep("Log out", API.Logout); + AddStep("Random comment", () => addVotePill(getRandomComment())); + AddStep("Click", () => votePill.Click()); + AddAssert("Not loading", () => !votePill.IsLoading); + } + + private void logIn() => API.Login("localUser", "password"); + + private Comment getUserComment() => new Comment + { + IsVoted = false, + UserId = API.LocalUser.Value.Id, + VotesCount = 10, + }; + + private Comment getRandomComment() => new Comment + { + IsVoted = false, + UserId = 4444, + VotesCount = 2, + }; + + private void addVotePill(Comment comment) + { + Clear(); + Add(votePill = new VotePill(comment) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs new file mode 100644 index 0000000000..2af15923a0 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs @@ -0,0 +1,155 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneAccuracyCircle : OsuTestScene + { + [Test] + public void TestLowDRank() + { + 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; + + addCircleStep(score); + } + + private void addCircleStep(ScoreInfo score) => AddStep("add panel", () => + { + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 700), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#555"), Color4Extensions.FromHex("#333")) + } + } + }, + new AccuracyCircle(score, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(230) + } + }; + }); + + private ScoreInfo createScore() => new ScoreInfo + { + User = new User + { + Id = 2, + Username = "peppy", + }, + Beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, + Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, + TotalScore = 2845370, + Accuracy = 0.95, + MaxCombo = 999, + Rank = ScoreRank.S, + Date = DateTimeOffset.Now, + Statistics = + { + { HitResult.Miss, 1 }, + { HitResult.Meh, 50 }, + { HitResult.Good, 100 }, + { HitResult.Great, 300 }, + } + }; + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs new file mode 100644 index 0000000000..76cfe75b59 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.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 NUnit.Framework; +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.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Ranking.Contracted; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneContractedPanelMiddleContent : OsuTestScene + { + [Resolved] + private RulesetStore rulesetStore { get; set; } + + [Test] + public void TestShowPanel() + { + AddStep("show example score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), new TestScoreInfo(new OsuRuleset().RulesetInfo))); + } + + private void showPanel(WorkingBeatmap workingBeatmap, ScoreInfo score) + { + Child = new ContractedPanelMiddleContentContainer(workingBeatmap, score); + } + + private class ContractedPanelMiddleContentContainer : Container + { + [Cached] + private Bindable workingBeatmap { get; set; } + + public ContractedPanelMiddleContentContainer(WorkingBeatmap beatmap, ScoreInfo score) + { + workingBeatmap = new Bindable(beatmap); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Size = new Vector2(ScorePanel.CONTRACTED_WIDTH, 460); + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#353535"), + }, + new ContractedPanelMiddleContent(score), + }; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs new file mode 100644 index 0000000000..7be44a62de --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.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.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Ranking.Expanded; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneExpandedPanelMiddleContent : OsuTestScene + { + [Resolved] + private RulesetStore rulesetStore { get; set; } + + [Test] + public void TestMapWithKnownMapper() + { + var author = new User { Username = "mapper_name" }; + + AddStep("show example score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo) + { + Beatmap = createTestBeatmap(author) + })); + + AddAssert("mapper name present", () => this.ChildrenOfType().Any(spriteText => spriteText.Text == "mapper_name")); + } + + [Test] + public void TestMapWithUnknownMapper() + { + AddStep("show example score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo) + { + Beatmap = createTestBeatmap(null) + })); + + AddAssert("mapped by text not present", () => + this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text, "mapped", "by"))); + } + + private void showPanel(ScoreInfo score) => Child = new ExpandedPanelMiddleContentContainer(score); + + private BeatmapInfo createTestBeatmap(User author) + { + var beatmap = new TestBeatmap(rulesetStore.GetRuleset(0)).BeatmapInfo; + + beatmap.Metadata.Author = author; + beatmap.Metadata.Title = "Verrrrrrrrrrrrrrrrrrry looooooooooooooooooooooooong beatmap title"; + beatmap.Metadata.Artist = "Verrrrrrrrrrrrrrrrrrry looooooooooooooooooooooooong beatmap artist"; + + return beatmap; + } + + private bool containsAny(string text, params string[] stringsToMatch) => stringsToMatch.Any(text.Contains); + + private class ExpandedPanelMiddleContentContainer : Container + { + public ExpandedPanelMiddleContentContainer(ScoreInfo score) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Size = new Vector2(ScorePanel.EXPANDED_WIDTH, 700); + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#444"), + }, + new ExpandedPanelMiddleContent(score) + }; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs new file mode 100644 index 0000000000..a32bcbe7f0 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Ranking.Expanded; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneExpandedPanelTopContent : OsuTestScene + { + public TestSceneExpandedPanelTopContent() + { + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 200), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#444"), + }, + new ExpandedPanelTopContent(new TestScoreInfo(new OsuRuleset().RulesetInfo).User), + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs new file mode 100644 index 0000000000..4bc843096f --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.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; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneHitEventTimingDistributionGraph : OsuTestScene + { + [Test] + public void TestManyDistributedEvents() + { + createTest(CreateDistributedHitEvents()); + } + + [Test] + public void TestAroundCentre() + { + createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList()); + } + + [Test] + public void TestZeroTimeOffset() + { + createTest(Enumerable.Range(0, 100).Select(_ => new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList()); + } + + [Test] + public void TestNoEvents() + { + createTest(new List()); + } + + [Test] + public void TestMissesDontShow() + { + createTest(Enumerable.Range(0, 100).Select(i => + { + if (i % 2 == 0) + return new HitEvent(0, HitResult.Perfect, new HitCircle(), new HitCircle(), null); + + return new HitEvent(30, HitResult.Miss, new HitCircle(), new HitCircle(), null); + }).ToList()); + } + + private void createTest(List events) => AddStep("create test", () => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333") + }, + new HitEventTimingDistributionGraph(events) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(600, 130) + } + }; + }); + + public static List CreateDistributedHitEvents() + { + var hitEvents = new List(); + + for (int i = 0; i < 50; i++) + { + int count = (int)(Math.Pow(25 - Math.Abs(i - 25), 2)); + + for (int j = 0; j < count; j++) + hitEvents.Add(new HitEvent(i - 25, HitResult.Perfect, new HitCircle(), new HitCircle(), null)); + } + + return hitEvents; + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs new file mode 100644 index 0000000000..ff96a999ec --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -0,0 +1,343 @@ +// 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.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Ranking +{ + [TestFixture] + public class TestSceneResultsScreen : OsuManualInputManagerTestScene + { + private BeatmapManager beatmaps; + + [BackgroundDependencyLoader] + private void load(BeatmapManager beatmaps) + { + this.beatmaps = beatmaps; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var beatmapInfo = beatmaps.QueryBeatmap(b => b.RulesetID == 0); + if (beatmapInfo != null) + 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() + { + TestResultsScreen screen = null; + OsuScreenStack stack; + + AddStep("load results", () => + { + Child = stack = new OsuScreenStack + { + RelativeSizeAxes = Axes.Both + }; + + stack.Push(screen = createResultsScreen()); + }); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddAssert("retry overlay not present", () => screen.RetryOverlay == null); + } + + [Test] + public void TestResultsWithPlayer() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddAssert("retry overlay present", () => screen.RetryOverlay != null); + } + + [Test] + public void TestResultsForUnranked() + { + UnrankedSoloResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createUnrankedSoloResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddAssert("retry overlay present", () => screen.RetryOverlay != null); + } + + [Test] + public void TestShowHideStatisticsViaOutsideClick() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + + AddUntilStep("expanded panel at the left of the screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return expandedPanel.ScreenSpaceDrawQuad.TopLeft.X - screen.ScreenSpaceDrawQuad.TopLeft.X < 150; + }); + + AddStep("click to right of panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel.ScreenSpaceDrawQuad.TopRight + new Vector2(100, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + + AddUntilStep("expanded panel in centre of screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, screen.ScreenSpaceDrawQuad.Centre.X, 1); + }); + } + + [Test] + public void TestShowHideStatistics() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + + AddUntilStep("expanded panel at the left of the screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return expandedPanel.ScreenSpaceDrawQuad.TopLeft.X - screen.ScreenSpaceDrawQuad.TopLeft.X < 150; + }); + + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + + AddUntilStep("expanded panel in centre of screen", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, screen.ScreenSpaceDrawQuad.Centre.X, 1); + }); + } + + [Test] + public void TestShowStatisticsAndClickOtherPanel() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + + ScorePanel expandedPanel = null; + ScorePanel contractedPanel = null; + + AddStep("click expanded panel then contracted panel", () => + { + expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + + contractedPanel = this.ChildrenOfType().First(p => p.State == PanelState.Contracted && p.ScreenSpaceDrawQuad.TopLeft.X > screen.ScreenSpaceDrawQuad.TopLeft.X); + InputManager.MoveMouseTo(contractedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("statistics shown", () => this.ChildrenOfType().Single().State.Value == Visibility.Visible); + + AddAssert("contracted panel still contracted", () => contractedPanel.State == PanelState.Contracted); + AddAssert("expanded panel still expanded", () => expandedPanel.State == PanelState.Expanded); + } + + [Test] + public void TestFetchScoresAfterShowingStatistics() + { + DelayedFetchResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = new DelayedFetchResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo), 3000))); + AddUntilStep("wait for loaded", () => screen.IsLoaded); + AddStep("click expanded panel", () => + { + var expandedPanel = this.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + InputManager.MoveMouseTo(expandedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for fetch", () => screen.FetchCompleted); + AddAssert("expanded panel still on screen", () => this.ChildrenOfType().Single(p => p.State == PanelState.Expanded).ScreenSpaceDrawQuad.TopLeft.X > 0); + } + + [Test] + public void TestDownloadButtonInitiallyDisabled() + { + TestResultsScreen screen = null; + + AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); + + AddAssert("download button is disabled", () => !screen.ChildrenOfType().Last().Enabled.Value); + + AddStep("click contracted panel", () => + { + var contractedPanel = this.ChildrenOfType().First(p => p.State == PanelState.Contracted && p.ScreenSpaceDrawQuad.TopLeft.X > screen.ScreenSpaceDrawQuad.TopLeft.X); + InputManager.MoveMouseTo(contractedPanel); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("download button is enabled", () => screen.ChildrenOfType().Last().Enabled.Value); + } + + private class TestResultsContainer : Container + { + [Cached(typeof(Player))] + private readonly Player player = new TestPlayer(); + + public TestResultsContainer(IScreen screen) + { + RelativeSizeAxes = Axes.Both; + OsuScreenStack stack; + + InternalChild = stack = new OsuScreenStack + { + RelativeSizeAxes = Axes.Both, + }; + + stack.Push(screen); + } + } + + private class TestResultsScreen : ResultsScreen + { + public HotkeyRetryOverlay RetryOverlay; + + public TestResultsScreen(ScoreInfo score) + : base(score) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RetryOverlay = InternalChildren.OfType().SingleOrDefault(); + } + + protected override APIRequest FetchScores(Action> scoresCallback) + { + var scores = new List(); + + for (int i = 0; i < 20; i++) + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + score.TotalScore += 10 - i; + score.Hash = $"test{i}"; + scores.Add(score); + } + + scoresCallback?.Invoke(scores); + + return null; + } + } + + private class DelayedFetchResultsScreen : TestResultsScreen + { + public bool FetchCompleted { get; private set; } + + private readonly double delay; + + public DelayedFetchResultsScreen(ScoreInfo score, double delay) + : base(score) + { + this.delay = delay; + } + + protected override APIRequest FetchScores(Action> scoresCallback) + { + Task.Run(async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(delay)); + + var scores = new List(); + + for (int i = 0; i < 20; i++) + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + score.TotalScore += 10 - i; + scores.Add(score); + } + + scoresCallback?.Invoke(scores); + + Schedule(() => FetchCompleted = true); + }); + + return null; + } + } + + private class UnrankedSoloResultsScreen : SoloResultsScreen + { + public HotkeyRetryOverlay RetryOverlay; + + public UnrankedSoloResultsScreen(ScoreInfo score) + : base(score) + { + Score.Beatmap.OnlineBeatmapID = 0; + Score.Beatmap.Status = BeatmapSetOnlineStatus.Pending; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RetryOverlay = InternalChildren.OfType().SingleOrDefault(); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs new file mode 100644 index 0000000000..5af55e99f8 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs @@ -0,0 +1,114 @@ +// 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.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneScorePanel : OsuTestScene + { + private ScorePanel panel; + + [Test] + public void TestDRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.5, Rank = ScoreRank.D }; + + addPanelStep(score); + } + + [Test] + public void TestCRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.75, Rank = ScoreRank.C }; + + addPanelStep(score); + } + + [Test] + public void TestBRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.85, Rank = ScoreRank.B }; + + addPanelStep(score); + } + + [Test] + public void TestARank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.925, Rank = ScoreRank.A }; + + addPanelStep(score); + } + + [Test] + public void TestSRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.975, Rank = ScoreRank.S }; + + addPanelStep(score); + } + + [Test] + public void TestAlmostSSRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.9999, Rank = ScoreRank.S }; + + addPanelStep(score); + } + + [Test] + public void TestSSRank() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 1, Rank = ScoreRank.X }; + + addPanelStep(score); + } + + [Test] + public void TestAllHitResults() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Statistics = { [HitResult.Perfect] = 350, [HitResult.Ok] = 200 } }; + + addPanelStep(score); + } + + [Test] + public void TestContractedPanel() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.925, Rank = ScoreRank.A }; + + addPanelStep(score, PanelState.Contracted); + } + + [Test] + public void TestExpandAndContract() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.925, Rank = ScoreRank.A }; + + addPanelStep(score, PanelState.Contracted); + AddWaitStep("wait for transition", 10); + + AddStep("expand panel", () => panel.State = PanelState.Expanded); + AddWaitStep("wait for transition", 10); + + AddStep("contract panel", () => panel.State = PanelState.Contracted); + AddWaitStep("wait for transition", 10); + } + + private void addPanelStep(ScoreInfo score, PanelState state = PanelState.Expanded) => AddStep("add panel", () => + { + Child = panel = new ScorePanel(score, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = state + }; + }); + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs new file mode 100644 index 0000000000..e65dcb19b1 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs @@ -0,0 +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.Graphics; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneScorePanelList : OsuManualInputManagerTestScene + { + private ScorePanelList list; + + [Test] + public void TestEmptyList() + { + createListStep(() => new ScorePanelList()); + } + + [Test] + public void TestEmptyListWithSelectedScore() + { + createListStep(() => new ScorePanelList + { + SelectedScore = { Value = new TestScoreInfo(new OsuRuleset().RulesetInfo) } + }); + } + + [Test] + public void TestAddPanelAfterSelectingScore() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList + { + SelectedScore = { Value = score } + }); + + AddStep("add panel", () => list.AddScore(score)); + + assertScoreState(score, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestAddPanelBeforeSelectingScore() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList()); + + AddStep("add panel", () => list.AddScore(score)); + + assertScoreState(score, false); + assertFirstPanelCentred(); + + AddStep("select score", () => list.SelectedScore.Value = score); + + assertScoreState(score, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestAddManyNonExpandedPanels() + { + createListStep(() => new ScorePanelList()); + + AddStep("add many scores", () => + { + for (int i = 0; i < 20; i++) + list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + }); + + assertFirstPanelCentred(); + } + + [Test] + public void TestAddManyScoresAfterExpandedPanel() + { + var initialScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList()); + + AddStep("add initial panel and select", () => + { + list.AddScore(initialScore); + list.SelectedScore.Value = initialScore; + }); + + AddStep("add many scores", () => + { + for (int i = 0; i < 20; i++) + list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore - i - 1 }); + }); + + assertScoreState(initialScore, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestAddManyScoresBeforeExpandedPanel() + { + var initialScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList()); + + AddStep("add initial panel and select", () => + { + list.AddScore(initialScore); + list.SelectedScore.Value = initialScore; + }); + + AddStep("add scores", () => + { + for (int i = 0; i < 20; i++) + list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore + i + 1 }); + }); + + assertScoreState(initialScore, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestAddManyPanelsOnBothSidesOfExpandedPanel() + { + var initialScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList()); + + AddStep("add initial panel and select", () => + { + list.AddScore(initialScore); + list.SelectedScore.Value = initialScore; + }); + + AddStep("add scores after", () => + { + for (int i = 0; i < 20; i++) + list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore - i - 1 }); + + for (int i = 0; i < 20; i++) + list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore + i + 1 }); + }); + + assertScoreState(initialScore, true); + assertExpandedPanelCentred(); + } + + [Test] + public void TestSelectMultipleScores() + { + var firstScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var secondScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + + createListStep(() => new ScorePanelList()); + + AddStep("add scores and select first", () => + { + list.AddScore(firstScore); + list.AddScore(secondScore); + list.SelectedScore.Value = firstScore; + }); + + assertScoreState(firstScore, true); + assertScoreState(secondScore, false); + + AddStep("select second score", () => + { + InputManager.MoveMouseTo(list.ChildrenOfType().Single(p => p.Score == secondScore)); + InputManager.Click(MouseButton.Left); + }); + + assertScoreState(firstScore, false); + assertScoreState(secondScore, true); + assertExpandedPanelCentred(); + } + + private void createListStep(Func creationFunc) + { + AddStep("create list", () => Child = list = creationFunc().With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + })); + + AddUntilStep("wait for load", () => list.IsLoaded); + } + + private void assertExpandedPanelCentred() => AddUntilStep("expanded panel centred", () => + { + var expandedPanel = list.ChildrenOfType().Single(p => p.State == PanelState.Expanded); + return Precision.AlmostEquals(expandedPanel.ScreenSpaceDrawQuad.Centre.X, list.ScreenSpaceDrawQuad.Centre.X, 1); + }); + + private void assertFirstPanelCentred() + => AddUntilStep("first panel centred", () => Precision.AlmostEquals(list.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre.X, list.ScreenSpaceDrawQuad.Centre.X, 1)); + + private void assertScoreState(ScoreInfo score, bool expanded) + => AddUntilStep($"score expanded = {expanded}", () => (list.ChildrenOfType().Single(p => p.Score == score).State == PanelState.Expanded) == expanded); + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneSimpleStatisticTable.cs b/osu.Game.Tests/Visual/Ranking/TestSceneSimpleStatisticTable.cs new file mode 100644 index 0000000000..07a0bcc8d8 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneSimpleStatisticTable.cs @@ -0,0 +1,68 @@ +// 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 Humanizer; +using NUnit.Framework; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Screens.Ranking.Statistics; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneSimpleStatisticTable : OsuTestScene + { + private Container container; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = new Container + { + AutoSizeAxes = Axes.Y, + Width = 700, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333"), + }, + container = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(20) + } + } + }; + }); + + [Test] + public void TestEmpty() + { + AddStep("create with no items", + () => container.Add(new SimpleStatisticTable(2, Enumerable.Empty()))); + } + + [Test] + public void TestManyItems( + [Values(1, 2, 3, 4, 12)] int itemCount, + [Values(1, 3, 5)] int columnCount) + { + AddStep($"create with {"item".ToQuantity(itemCount)}", () => + { + var items = Enumerable.Range(1, itemCount) + .Select(i => new SimpleStatisticItem($"Statistic #{i}") + { + Value = RNG.Next(100) + }); + + container.Add(new SimpleStatisticTable(columnCount, items)); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.cs new file mode 100644 index 0000000000..d0067c3396 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStarRatingDisplay.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.Beatmaps; +using osu.Game.Screens.Ranking.Expanded; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneStarRatingDisplay : OsuTestScene + { + public TestSceneStarRatingDisplay() + { + Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + 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)), + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs new file mode 100644 index 0000000000..8700fbeb42 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics; + +namespace osu.Game.Tests.Visual.Ranking +{ + public class TestSceneStatisticsPanel : OsuTestScene + { + [Test] + public void TestScoreWithStatistics() + { + var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents() + }; + + loadPanel(score); + } + + [Test] + public void TestScoreWithoutStatistics() + { + loadPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + } + + [Test] + public void TestNullScore() + { + loadPanel(null); + } + + private void loadPanel(ScoreInfo score) => AddStep("load panel", () => + { + Child = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + Score = { Value = score } + }; + }); + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs new file mode 100644 index 0000000000..082d85603e --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.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.Framework.Graphics; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Tests.Visual.Settings +{ + public class TestSceneDirectorySelector : OsuTestScene + { + [BackgroundDependencyLoader] + private void load() + { + Add(new DirectorySelector { RelativeSizeAxes = Axes.Both }); + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs new file mode 100644 index 0000000000..311e4c3362 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Tests.Visual.Settings +{ + public class TestSceneFileSelector : OsuTestScene + { + [Test] + public void TestAllFiles() + { + AddStep("create", () => Child = new FileSelector { RelativeSizeAxes = Axes.Both }); + } + + [Test] + public void TestJpgFilesOnly() + { + AddStep("create", () => Child = new FileSelector(validFileExtensions: new[] { ".jpg" }) { RelativeSizeAxes = Axes.Both }); + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs index 426ff988c4..8330b9b360 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs @@ -1,29 +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; -using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Threading; using osu.Game.Overlays; using osu.Game.Overlays.KeyBinding; +using osuTK.Input; namespace osu.Game.Tests.Visual.Settings { [TestFixture] - public class TestSceneKeyBindingPanel : OsuTestScene + public class TestSceneKeyBindingPanel : OsuManualInputManagerTestScene { private readonly KeyBindingPanel panel; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(KeyBindingRow), - typeof(GlobalKeyBindingsSection), - typeof(KeyBindingRow), - typeof(KeyBindingsSubsection), - typeof(RulesetBindingsSection), - typeof(VariantBindingsSubsection), - }; - public TestSceneKeyBindingPanel() { Child = panel = new KeyBindingPanel(); @@ -34,5 +27,113 @@ namespace osu.Game.Tests.Visual.Settings base.LoadComplete(); panel.Show(); } + + [Test] + public void TestClickTwiceOnClearButton() + { + KeyBindingRow firstRow = null; + + AddStep("click first row", () => + { + firstRow = panel.ChildrenOfType().First(); + InputManager.MoveMouseTo(firstRow); + InputManager.Click(MouseButton.Left); + }); + + AddStep("schedule button clicks", () => + { + var clearButton = firstRow.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(clearButton); + + int buttonClicks = 0; + ScheduledDelegate clickDelegate = null; + + clickDelegate = Scheduler.AddDelayed(() => + { + InputManager.Click(MouseButton.Left); + + if (++buttonClicks == 2) + { + // ReSharper disable once AccessToModifiedClosure + Debug.Assert(clickDelegate != null); + // ReSharper disable once AccessToModifiedClosure + clickDelegate.Cancel(); + } + }, 0, true); + }); + } + + [Test] + public void TestClearButtonOnBindings() + { + KeyBindingRow multiBindingRow = null; + + AddStep("click first row with two bindings", () => + { + multiBindingRow = panel.ChildrenOfType().First(row => row.Defaults.Count() > 1); + InputManager.MoveMouseTo(multiBindingRow); + InputManager.Click(MouseButton.Left); + }); + + clickClearButton(); + + AddAssert("first binding cleared", () => string.IsNullOrEmpty(multiBindingRow.ChildrenOfType().First().Text.Text)); + + AddStep("click second binding", () => + { + var target = multiBindingRow.ChildrenOfType().ElementAt(1); + + InputManager.MoveMouseTo(target); + InputManager.Click(MouseButton.Left); + }); + + clickClearButton(); + + AddAssert("second binding cleared", () => string.IsNullOrEmpty(multiBindingRow.ChildrenOfType().ElementAt(1).Text.Text)); + + void clickClearButton() + { + AddStep("click clear button", () => + { + var clearButton = multiBindingRow.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(clearButton); + InputManager.Click(MouseButton.Left); + }); + } + } + + [Test] + public void TestClickRowSelectsFirstBinding() + { + KeyBindingRow multiBindingRow = null; + + AddStep("click first row with two bindings", () => + { + multiBindingRow = panel.ChildrenOfType().First(row => row.Defaults.Count() > 1); + InputManager.MoveMouseTo(multiBindingRow); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("first binding selected", () => multiBindingRow.ChildrenOfType().First().IsBinding); + + AddStep("click second binding", () => + { + var target = multiBindingRow.ChildrenOfType().ElementAt(1); + + InputManager.MoveMouseTo(target); + InputManager.Click(MouseButton.Left); + }); + + AddStep("click back binding row", () => + { + multiBindingRow = panel.ChildrenOfType().ElementAt(10); + InputManager.MoveMouseTo(multiBindingRow); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("first binding selected", () => multiBindingRow.ChildrenOfType().First().IsBinding); + } } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs b/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.cs new file mode 100644 index 0000000000..2883e54385 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneMigrationScreens.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.IO; +using System.Threading; +using osu.Framework.Screens; +using osu.Game.Overlays.Settings.Sections.Maintenance; + +namespace osu.Game.Tests.Visual.Settings +{ + public class TestSceneMigrationScreens : ScreenTestScene + { + public TestSceneMigrationScreens() + { + AddStep("Push screen", () => Stack.Push(new TestMigrationSelectScreen())); + } + + private class TestMigrationSelectScreen : MigrationSelectScreen + { + protected override void BeginMigration(DirectoryInfo target) => this.Push(new TestMigrationRunScreen()); + + private class TestMigrationRunScreen : MigrationRunScreen + { + protected override void PerformMigration() + { + Thread.Sleep(3000); + } + + public TestMigrationRunScreen() + : base(null) + { + } + } + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs index 668fdf2c20..115d2fec7d 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsPanel.cs @@ -1,13 +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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Game.Overlays; -using osu.Game.Overlays.Settings; namespace osu.Game.Tests.Visual.Settings { @@ -17,12 +14,6 @@ namespace osu.Game.Tests.Visual.Settings private readonly SettingsPanel settings; private readonly DialogOverlay dialogOverlay; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(SettingsFooter), - typeof(SettingsOverlay), - }; - public TestSceneSettingsPanel() { settings = new SettingsOverlay diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs new file mode 100644 index 0000000000..e3dae9c27e --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.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 NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; +using osuTK; + +namespace osu.Game.Tests.Visual.Settings +{ + [TestFixture] + public class TestSceneSettingsSource : OsuTestScene + { + public TestSceneSettingsSource() + { + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Width = 0.5f, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(50), + ChildrenEnumerable = new TestTargetClass().CreateSettingsControls() + }, + }; + } + + private class TestTargetClass + { + [SettingSource("Sample bool", "Clicking this changes a setting")] + public BindableBool TickBindable { get; } = new BindableBool(); + + [SettingSource("Sample float", "Change something for a mod")] + public BindableFloat SliderBindable { get; } = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Default = 5, + Value = 7 + }; + + [SettingSource("Sample enum", "Change something for a mod")] + public Bindable EnumBindable { get; } = new Bindable + { + Default = TestEnum.Value1, + Value = TestEnum.Value2 + }; + + [SettingSource("Sample string", "Change something for a mod")] + public Bindable StringBindable { get; } = new Bindable(); + } + + private enum TestEnum + { + Value1, + Value2 + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.cs new file mode 100644 index 0000000000..3d3517ada4 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneAdvancedStats.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 System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Select.Details; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [System.ComponentModel.Description("Advanced beatmap statistics display")] + public class TestSceneAdvancedStats : OsuTestScene + { + private TestAdvancedStats advancedStats; + + [Resolved] + private RulesetStore rulesets { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + [SetUp] + public void Setup() => Schedule(() => Child = advancedStats = new TestAdvancedStats + { + Width = 500 + }); + + private BeatmapInfo exampleBeatmapInfo => new BeatmapInfo + { + RulesetID = 0, + Ruleset = rulesets.AvailableRulesets.First(), + BaseDifficulty = new BeatmapDifficulty + { + CircleSize = 7.2f, + DrainRate = 3, + OverallDifficulty = 5.7f, + ApproachRate = 3.5f + }, + StarDifficulty = 4.5f + }; + + [Test] + public void TestNoMod() + { + AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + + AddStep("no mods selected", () => SelectedMods.Value = Array.Empty()); + + AddAssert("first bar text is Circle Size", () => advancedStats.ChildrenOfType().First().Text == "Circle Size"); + AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue)); + AddAssert("HP drain bar is white", () => barIsWhite(advancedStats.HpDrain)); + AddAssert("accuracy bar is white", () => barIsWhite(advancedStats.Accuracy)); + AddAssert("approach rate bar is white", () => barIsWhite(advancedStats.ApproachRate)); + } + + [Test] + public void TestManiaFirstBarText() + { + AddStep("set beatmap", () => advancedStats.Beatmap = new BeatmapInfo + { + Ruleset = rulesets.GetRuleset(3), + BaseDifficulty = new BeatmapDifficulty + { + CircleSize = 5, + DrainRate = 4.3f, + OverallDifficulty = 4.5f, + ApproachRate = 3.1f + }, + StarDifficulty = 8 + }); + + AddAssert("first bar text is Key Count", () => advancedStats.ChildrenOfType().First().Text == "Key Count"); + } + + [Test] + public void TestEasyMod() + { + AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + + AddStep("select EZ mod", () => + { + var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); + SelectedMods.Value = new[] { ruleset.GetAllMods().OfType().Single() }; + }); + + AddAssert("circle size bar is blue", () => barIsBlue(advancedStats.FirstValue)); + AddAssert("HP drain bar is blue", () => barIsBlue(advancedStats.HpDrain)); + AddAssert("accuracy bar is blue", () => barIsBlue(advancedStats.Accuracy)); + AddAssert("approach rate bar is blue", () => barIsBlue(advancedStats.ApproachRate)); + } + + [Test] + public void TestHardRockMod() + { + AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + + AddStep("select HR mod", () => + { + var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); + SelectedMods.Value = new[] { ruleset.GetAllMods().OfType().Single() }; + }); + + AddAssert("circle size bar is red", () => barIsRed(advancedStats.FirstValue)); + AddAssert("HP drain bar is red", () => barIsRed(advancedStats.HpDrain)); + AddAssert("accuracy bar is red", () => barIsRed(advancedStats.Accuracy)); + AddAssert("approach rate bar is red", () => barIsRed(advancedStats.ApproachRate)); + } + + [Test] + public void TestUnchangedDifficultyAdjustMod() + { + AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + + AddStep("select unchanged Difficulty Adjust mod", () => + { + var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); + var difficultyAdjustMod = ruleset.GetAllMods().OfType().Single(); + difficultyAdjustMod.ReadFromDifficulty(advancedStats.Beatmap.BaseDifficulty); + SelectedMods.Value = new[] { difficultyAdjustMod }; + }); + + AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue)); + AddAssert("HP drain bar is white", () => barIsWhite(advancedStats.HpDrain)); + AddAssert("accuracy bar is white", () => barIsWhite(advancedStats.Accuracy)); + AddAssert("approach rate bar is white", () => barIsWhite(advancedStats.ApproachRate)); + } + + [Test] + public void TestChangedDifficultyAdjustMod() + { + AddStep("set beatmap", () => advancedStats.Beatmap = exampleBeatmapInfo); + + AddStep("select changed Difficulty Adjust mod", () => + { + var ruleset = advancedStats.Beatmap.Ruleset.CreateInstance(); + var difficultyAdjustMod = ruleset.GetAllMods().OfType().Single(); + var originalDifficulty = advancedStats.Beatmap.BaseDifficulty; + var adjustedDifficulty = new BeatmapDifficulty + { + CircleSize = originalDifficulty.CircleSize, + DrainRate = originalDifficulty.DrainRate - 0.5f, + OverallDifficulty = originalDifficulty.OverallDifficulty, + ApproachRate = originalDifficulty.ApproachRate + 2.2f, + }; + difficultyAdjustMod.ReadFromDifficulty(adjustedDifficulty); + SelectedMods.Value = new[] { difficultyAdjustMod }; + }); + + AddAssert("circle size bar is white", () => barIsWhite(advancedStats.FirstValue)); + AddAssert("drain rate bar is blue", () => barIsBlue(advancedStats.HpDrain)); + AddAssert("accuracy bar is white", () => barIsWhite(advancedStats.Accuracy)); + AddAssert("approach rate bar is red", () => barIsRed(advancedStats.ApproachRate)); + } + + private bool barIsWhite(AdvancedStats.StatisticRow row) => row.ModBar.AccentColour == Color4.White; + private bool barIsBlue(AdvancedStats.StatisticRow row) => row.ModBar.AccentColour == colours.BlueDark; + private bool barIsRed(AdvancedStats.StatisticRow row) => row.ModBar.AccentColour == colours.Red; + + private class TestAdvancedStats : AdvancedStats + { + public new StatisticRow FirstValue => base.FirstValue; + public new StatisticRow HpDrain => base.HpDrain; + public new StatisticRow Accuracy => base.Accuracy; + public new StatisticRow ApproachRate => base.ApproachRate; + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 7c9b7c7815..4699784327 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -10,40 +10,28 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; +using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { [TestFixture] - public class TestSceneBeatmapCarousel : OsuTestScene + public class TestSceneBeatmapCarousel : OsuManualInputManagerTestScene { private TestBeatmapCarousel carousel; private RulesetStore rulesets; - public override IReadOnlyList RequiredTypes => new[] - { - typeof(CarouselItem), - typeof(CarouselGroup), - typeof(CarouselGroupEagerSelect), - typeof(CarouselBeatmap), - typeof(CarouselBeatmapSet), - - typeof(DrawableCarouselItem), - typeof(CarouselItemState), - - typeof(DrawableCarouselBeatmap), - typeof(DrawableCarouselBeatmapSet), - }; - private readonly Stack selectedSets = new Stack(); private readonly HashSet eagerSelectedIDs = new HashSet(); - private BeatmapInfo currentSelection; + private BeatmapInfo currentSelection => carousel.SelectedBeatmap; private const int set_count = 5; @@ -51,50 +39,728 @@ namespace osu.Game.Tests.Visual.SongSelect private void load(RulesetStore rulesets) { this.rulesets = rulesets; - - Add(carousel = new TestBeatmapCarousel - { - RelativeSizeAxes = Axes.Both, - }); - - List beatmapSets = new List(); - - for (int i = 1; i <= set_count; i++) - beatmapSets.Add(createTestBeatmapSet(i)); - - carousel.SelectionChanged = s => currentSelection = s; - - loadBeatmaps(beatmapSets); - - testTraversal(); - testFiltering(); - testRandom(); - testAddRemove(); - testSorting(); - - testRemoveAll(); - testEmptyTraversal(); - testHiding(); - testSelectingFilteredRuleset(); - testCarouselRootIsRandom(); } - private void loadBeatmaps(List beatmapSets) + [Test] + public void TestManyPanels() + { + loadBeatmaps(count: 5000, randomDifficulties: true); + } + + [Test] + public void TestKeyRepeat() + { + loadBeatmaps(); + advanceSelection(false); + + AddStep("press down arrow", () => InputManager.PressKey(Key.Down)); + + BeatmapInfo selection = null; + + checkSelectionIterating(true); + + AddStep("press up arrow", () => InputManager.PressKey(Key.Up)); + + checkSelectionIterating(true); + + AddStep("release down arrow", () => InputManager.ReleaseKey(Key.Down)); + + checkSelectionIterating(true); + + AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up)); + + checkSelectionIterating(false); + + void checkSelectionIterating(bool isIterating) + { + for (int i = 0; i < 3; i++) + { + AddStep("store selection", () => selection = carousel.SelectedBeatmap); + if (isIterating) + AddUntilStep("selection changed", () => carousel.SelectedBeatmap != selection); + else + AddUntilStep("selection not changed", () => carousel.SelectedBeatmap == selection); + } + } + } + + [Test] + public void TestRecommendedSelection() + { + loadBeatmaps(carouselAdjust: carousel => carousel.GetRecommendedBeatmap = beatmaps => beatmaps.LastOrDefault()); + + AddStep("select last", () => carousel.SelectBeatmap(carousel.BeatmapSets.Last().Beatmaps.Last())); + + // check recommended was selected + advanceSelection(direction: 1, diff: false); + waitForSelection(1, 3); + + // change away from recommended + advanceSelection(direction: -1, diff: true); + waitForSelection(1, 2); + + // next set, check recommended + advanceSelection(direction: 1, diff: false); + waitForSelection(2, 3); + + // next set, check recommended + advanceSelection(direction: 1, diff: false); + waitForSelection(3, 3); + + // go back to first set and ensure user selection was retained + advanceSelection(direction: -1, diff: false); + advanceSelection(direction: -1, diff: false); + waitForSelection(1, 2); + } + + /// + /// Test keyboard traversal + /// + [Test] + public void TestTraversal() + { + loadBeatmaps(); + + AddStep("select first", () => carousel.SelectBeatmap(carousel.BeatmapSets.First().Beatmaps.First())); + waitForSelection(1, 1); + + advanceSelection(direction: 1, diff: true); + waitForSelection(1, 2); + + advanceSelection(direction: -1, diff: false); + waitForSelection(set_count, 1); + + advanceSelection(direction: -1, diff: true); + waitForSelection(set_count - 1, 3); + + advanceSelection(diff: false); + advanceSelection(diff: false); + waitForSelection(1, 2); + + advanceSelection(direction: -1, diff: true); + advanceSelection(direction: -1, diff: true); + waitForSelection(set_count, 3); + } + + [TestCase(true)] + [TestCase(false)] + public void TestTraversalBeyondVisible(bool forwards) + { + var sets = new List(); + + const int total_set_count = 200; + + for (int i = 0; i < total_set_count; i++) + sets.Add(createTestBeatmapSet(i + 1)); + + loadBeatmaps(sets); + + for (int i = 1; i < total_set_count; i += i) + selectNextAndAssert(i); + + void selectNextAndAssert(int amount) + { + setSelected(forwards ? 1 : total_set_count, 1); + + AddStep($"{(forwards ? "Next" : "Previous")} beatmap {amount} times", () => + { + for (int i = 0; i < amount; i++) + { + carousel.SelectNext(forwards ? 1 : -1); + } + }); + + waitForSelection(forwards ? amount + 1 : total_set_count - amount); + } + } + + [Test] + public void TestTraversalBeyondVisibleDifficulties() + { + var sets = new List(); + + const int total_set_count = 20; + + for (int i = 0; i < total_set_count; i++) + sets.Add(createTestBeatmapSet(i + 1)); + + loadBeatmaps(sets); + + // Selects next set once, difficulty index doesn't change + selectNextAndAssert(3, true, 2, 1); + + // Selects next set 16 times (50 \ 3 == 16), difficulty index changes twice (50 % 3 == 2) + selectNextAndAssert(50, true, 17, 3); + + // Travels around the carousel thrice (200 \ 60 == 3) + // continues to select 20 times (200 \ 60 == 20) + // selects next set 6 times (20 \ 3 == 6) + // difficulty index changes twice (20 % 3 == 2) + selectNextAndAssert(200, true, 7, 3); + + // All same but in reverse + selectNextAndAssert(3, false, 19, 3); + selectNextAndAssert(50, false, 4, 1); + selectNextAndAssert(200, false, 14, 1); + + void selectNextAndAssert(int amount, bool forwards, int expectedSet, int expectedDiff) + { + // Select very first or very last difficulty + setSelected(forwards ? 1 : 20, forwards ? 1 : 3); + + AddStep($"{(forwards ? "Next" : "Previous")} difficulty {amount} times", () => + { + for (int i = 0; i < amount; i++) + carousel.SelectNext(forwards ? 1 : -1, false); + }); + + waitForSelection(expectedSet, expectedDiff); + } + } + + /// + /// Test filtering + /// + [Test] + public void TestFiltering() + { + loadBeatmaps(); + + // basic filtering + + setSelected(1, 1); + + AddStep("Filter", () => carousel.Filter(new FilterCriteria { SearchText = "set #3!" }, false)); + checkVisibleItemCount(diff: false, count: 1); + checkVisibleItemCount(diff: true, count: 3); + waitForSelection(3, 1); + + advanceSelection(diff: true, count: 4); + waitForSelection(3, 2); + + AddStep("Un-filter (debounce)", () => carousel.Filter(new FilterCriteria())); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(diff: false, count: set_count); + checkVisibleItemCount(diff: true, count: 3); + + // test filtering some difficulties (and keeping current beatmap set selected). + + setSelected(1, 2); + AddStep("Filter some difficulties", () => carousel.Filter(new FilterCriteria { SearchText = "Normal" }, false)); + waitForSelection(1, 1); + + AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); + waitForSelection(1, 1); + + AddStep("Filter all", () => carousel.Filter(new FilterCriteria { SearchText = "Dingo" }, false)); + + checkVisibleItemCount(false, 0); + checkVisibleItemCount(true, 0); + AddAssert("Selection is null", () => currentSelection == null); + + advanceSelection(true); + AddAssert("Selection is null", () => currentSelection == null); + + advanceSelection(false); + AddAssert("Selection is null", () => currentSelection == null); + + AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); + + AddAssert("Selection is non-null", () => currentSelection != null); + + setSelected(1, 3); + } + + [Test] + public void TestFilterRange() + { + loadBeatmaps(); + + // buffer the selection + setSelected(3, 2); + + setSelected(1, 3); + + AddStep("Apply a range filter", () => carousel.Filter(new FilterCriteria + { + SearchText = "#3", + StarDifficulty = new FilterCriteria.OptionalRange + { + Min = 2, + Max = 5.5, + IsLowerInclusive = true + } + }, false)); + + // should reselect the buffered selection. + waitForSelection(3, 2); + } + + /// + /// Test random non-repeating algorithm + /// + [Test] + public void TestRandom() + { + loadBeatmaps(); + + setSelected(1, 1); + + nextRandom(); + ensureRandomDidntRepeat(); + nextRandom(); + ensureRandomDidntRepeat(); + nextRandom(); + ensureRandomDidntRepeat(); + + prevRandom(); + ensureRandomFetchSuccess(); + prevRandom(); + ensureRandomFetchSuccess(); + + nextRandom(); + ensureRandomDidntRepeat(); + nextRandom(); + ensureRandomDidntRepeat(); + + nextRandom(); + AddAssert("ensure repeat", () => selectedSets.Contains(carousel.SelectedBeatmapSet)); + + AddStep("Add set with 100 difficulties", () => carousel.UpdateBeatmapSet(createTestBeatmapSetWithManyDifficulties(set_count + 1))); + AddStep("Filter Extra", () => carousel.Filter(new FilterCriteria { SearchText = "Extra 10" }, false)); + checkInvisibleDifficultiesUnselectable(); + checkInvisibleDifficultiesUnselectable(); + checkInvisibleDifficultiesUnselectable(); + checkInvisibleDifficultiesUnselectable(); + checkInvisibleDifficultiesUnselectable(); + AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); + } + + /// + /// Test adding and removing beatmap sets + /// + [Test] + public void TestAddRemove() + { + loadBeatmaps(); + + AddStep("Add new set", () => carousel.UpdateBeatmapSet(createTestBeatmapSet(set_count + 1))); + AddStep("Add new set", () => carousel.UpdateBeatmapSet(createTestBeatmapSet(set_count + 2))); + + checkVisibleItemCount(false, set_count + 2); + + AddStep("Remove set", () => carousel.RemoveBeatmapSet(createTestBeatmapSet(set_count + 2))); + + checkVisibleItemCount(false, set_count + 1); + + setSelected(set_count + 1, 1); + + AddStep("Remove set", () => carousel.RemoveBeatmapSet(createTestBeatmapSet(set_count + 1))); + + checkVisibleItemCount(false, set_count); + + waitForSelection(set_count); + } + + [Test] + public void TestSelectionEnteringFromEmptyRuleset() + { + var sets = new List(); + + AddStep("Create beatmaps for taiko only", () => + { + sets.Clear(); + + var rulesetBeatmapSet = createTestBeatmapSet(1); + var taikoRuleset = rulesets.AvailableRulesets.ElementAt(1); + rulesetBeatmapSet.Beatmaps.ForEach(b => + { + b.Ruleset = taikoRuleset; + b.RulesetID = 1; + }); + + sets.Add(rulesetBeatmapSet); + }); + + loadBeatmaps(sets, () => new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }); + + AddStep("Set non-empty mode filter", () => + carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(1) }, false)); + + AddAssert("Something is selected", () => carousel.SelectedBeatmap != null); + } + + /// + /// Test sorting + /// + [Test] + public void TestSorting() + { + loadBeatmaps(); + + AddStep("Sort by author", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Author }, false)); + AddAssert("Check zzzzz is at bottom", () => carousel.BeatmapSets.Last().Metadata.AuthorString == "zzzzz"); + AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + AddAssert($"Check #{set_count} is at bottom", () => carousel.BeatmapSets.Last().Metadata.Title.EndsWith($"#{set_count}!", StringComparison.Ordinal)); + } + + [Test] + public void TestSortingStability() + { + var sets = new List(); + + for (int i = 0; i < 20; i++) + { + var set = createTestBeatmapSet(i); + set.Metadata.Artist = "same artist"; + set.Metadata.Title = "same title"; + sets.Add(set); + } + + loadBeatmaps(sets); + + AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); + AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.ID == index).All(b => b)); + + AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false)); + AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.ID == index).All(b => b)); + } + + [Test] + public void TestSortingWithFiltered() + { + List sets = new List(); + + for (int i = 0; i < 3; i++) + { + var set = createTestBeatmapSet(i); + set.Beatmaps[0].StarDifficulty = 3 - i; + set.Beatmaps[2].StarDifficulty = 6 + i; + sets.Add(set); + } + + loadBeatmaps(sets); + + AddStep("Filter to normal", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Normal" }, false)); + AddAssert("Check first set at end", () => carousel.BeatmapSets.First() == sets.Last()); + AddAssert("Check last set at start", () => carousel.BeatmapSets.Last() == sets.First()); + + AddStep("Filter to insane", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Insane" }, false)); + AddAssert("Check first set at start", () => carousel.BeatmapSets.First() == sets.First()); + AddAssert("Check last set at end", () => carousel.BeatmapSets.Last() == sets.Last()); + } + + [Test] + public void TestRemoveAll() + { + loadBeatmaps(); + + setSelected(2, 1); + AddAssert("Selection is non-null", () => currentSelection != null); + + AddStep("Remove selected", () => carousel.RemoveBeatmapSet(carousel.SelectedBeatmapSet)); + waitForSelection(2); + + AddStep("Remove first", () => carousel.RemoveBeatmapSet(carousel.BeatmapSets.First())); + AddStep("Remove first", () => carousel.RemoveBeatmapSet(carousel.BeatmapSets.First())); + waitForSelection(1); + + AddUntilStep("Remove all", () => + { + if (!carousel.BeatmapSets.Any()) return true; + + carousel.RemoveBeatmapSet(carousel.BeatmapSets.Last()); + return false; + }); + + checkNoSelection(); + } + + [Test] + public void TestEmptyTraversal() + { + loadBeatmaps(new List()); + + advanceSelection(direction: 1, diff: false); + checkNoSelection(); + + advanceSelection(direction: 1, diff: true); + checkNoSelection(); + + advanceSelection(direction: -1, diff: false); + checkNoSelection(); + + advanceSelection(direction: -1, diff: true); + checkNoSelection(); + } + + [Test] + public void TestHiding() + { + BeatmapSetInfo hidingSet = null; + List hiddenList = new List(); + + AddStep("create hidden set", () => + { + hidingSet = createTestBeatmapSet(1); + hidingSet.Beatmaps[1].Hidden = true; + + hiddenList.Clear(); + hiddenList.Add(hidingSet); + }); + + loadBeatmaps(hiddenList); + + setSelected(1, 1); + + checkVisibleItemCount(true, 2); + advanceSelection(true); + waitForSelection(1, 3); + + setHidden(3); + waitForSelection(1, 1); + + setHidden(2, false); + advanceSelection(true); + waitForSelection(1, 2); + + setHidden(1); + waitForSelection(1, 2); + + setHidden(2); + checkNoSelection(); + + void setHidden(int diff, bool hidden = true) + { + AddStep((hidden ? "" : "un") + $"hide diff {diff}", () => + { + hidingSet.Beatmaps[diff - 1].Hidden = hidden; + carousel.UpdateBeatmapSet(hidingSet); + }); + } + } + + [Test] + public void TestSelectingFilteredRuleset() + { + BeatmapSetInfo testMixed = null; + + createCarousel(); + + AddStep("add mixed ruleset beatmapset", () => + { + testMixed = createTestBeatmapSet(set_count + 1); + + for (int i = 0; i <= 2; i++) + { + testMixed.Beatmaps[i].Ruleset = rulesets.AvailableRulesets.ElementAt(i); + testMixed.Beatmaps[i].RulesetID = i; + } + + carousel.UpdateBeatmapSet(testMixed); + }); + AddStep("filter to ruleset 0", () => + carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }, false)); + AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testMixed.Beatmaps[1], false)); + AddAssert("unfiltered beatmap not selected", () => carousel.SelectedBeatmap.RulesetID == 0); + + AddStep("remove mixed set", () => + { + carousel.RemoveBeatmapSet(testMixed); + testMixed = null; + }); + BeatmapSetInfo testSingle = null; + AddStep("add single ruleset beatmapset", () => + { + testSingle = createTestBeatmapSet(set_count + 2); + testSingle.Beatmaps.ForEach(b => + { + b.Ruleset = rulesets.AvailableRulesets.ElementAt(1); + b.RulesetID = b.Ruleset.ID ?? 1; + }); + + carousel.UpdateBeatmapSet(testSingle); + }); + AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testSingle.Beatmaps[0], false)); + checkNoSelection(); + AddStep("remove single ruleset set", () => carousel.RemoveBeatmapSet(testSingle)); + } + + [Test] + public void TestCarouselRemembersSelection() + { + List manySets = new List(); + + for (int i = 1; i <= 50; i++) + manySets.Add(createTestBeatmapSet(i)); + + loadBeatmaps(manySets); + + advanceSelection(direction: 1, diff: false); + + for (int i = 0; i < 5; i++) + { + AddStep("Toggle non-matching filter", () => + { + carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); + }); + + AddStep("Restore no filter", () => + { + carousel.Filter(new FilterCriteria(), false); + eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); + }); + } + + // always returns to same selection as long as it's available. + AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); + } + + [Test] + public void TestRandomFallbackOnNonMatchingPrevious() + { + List manySets = new List(); + + AddStep("populate maps", () => + { + for (int i = 0; i < 10; i++) + { + var set = createTestBeatmapSet(i); + + foreach (var b in set.Beatmaps) + { + // all taiko except for first + int ruleset = i > 0 ? 1 : 0; + + b.Ruleset = rulesets.GetRuleset(ruleset); + b.RulesetID = ruleset; + } + + manySets.Add(set); + } + }); + + loadBeatmaps(manySets); + + for (int i = 0; i < 10; i++) + { + AddStep("Reset filter", () => carousel.Filter(new FilterCriteria(), false)); + + AddStep("select first beatmap", () => carousel.SelectBeatmap(manySets.First().Beatmaps.First())); + + AddStep("Toggle non-matching filter", () => + { + carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); + }); + + AddAssert("selection lost", () => carousel.SelectedBeatmap == null); + + AddStep("Restore different ruleset filter", () => + { + carousel.Filter(new FilterCriteria { Ruleset = rulesets.GetRuleset(1) }, false); + eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); + }); + + AddAssert("selection changed", () => carousel.SelectedBeatmap != manySets.First().Beatmaps.First()); + } + + AddAssert("Selection was random", () => eagerSelectedIDs.Count > 2); + } + + [Test] + public void TestFilteringByUserStarDifficulty() + { + BeatmapSetInfo set = null; + + loadBeatmaps(new List()); + + AddStep("add mixed difficulty set", () => + { + set = createTestBeatmapSet(1); + set.Beatmaps.Clear(); + + for (int i = 1; i <= 15; i++) + { + set.Beatmaps.Add(new BeatmapInfo + { + Version = $"Stars: {i}", + StarDifficulty = i, + }); + } + + carousel.UpdateBeatmapSet(set); + }); + + AddStep("select added set", () => carousel.SelectBeatmap(set.Beatmaps[0], false)); + + AddStep("filter [5..]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 5 } })); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(true, 11); + + AddStep("filter to [0..7]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Max = 7 } })); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(true, 7); + + AddStep("filter to [5..7]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 5, Max = 7 } })); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(true, 3); + + AddStep("filter [2..2]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 2, Max = 2 } })); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(true, 1); + + AddStep("filter to [0..]", () => carousel.Filter(new FilterCriteria { UserStarDifficulty = { Min = 0 } })); + AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); + checkVisibleItemCount(true, 15); + } + + private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null, int? count = null, bool randomDifficulties = false) { bool changed = false; - AddStep($"Load {beatmapSets.Count} Beatmaps", () => + + createCarousel(c => { + carouselAdjust?.Invoke(c); + + if (beatmapSets == null) + { + beatmapSets = new List(); + + for (int i = 1; i <= (count ?? set_count); i++) + beatmapSets.Add(createTestBeatmapSet(i, randomDifficulties)); + } + + carousel.Filter(initialCriteria?.Invoke() ?? new FilterCriteria()); carousel.BeatmapSetsChanged = () => changed = true; carousel.BeatmapSets = beatmapSets; }); + AddUntilStep("Wait for load", () => changed); } + private void createCarousel(Action carouselAdjust = null, Container target = null) + { + AddStep("Create carousel", () => + { + selectedSets.Clear(); + eagerSelectedIDs.Clear(); + + carousel = new TestBeatmapCarousel + { + RelativeSizeAxes = Axes.Both, + }; + + carouselAdjust?.Invoke(carousel); + + (target ?? this).Child = carousel; + }); + } + private void ensureRandomFetchSuccess() => AddAssert("ensure prev random fetch worked", () => selectedSets.Peek() == carousel.SelectedBeatmapSet); - private void checkSelected(int set, int? diff = null) => - AddAssert($"selected is set{set}{(diff.HasValue ? $" diff{diff.Value}" : "")}", () => + private void waitForSelection(int set, int? diff = null) => + AddUntilStep($"selected is set{set}{(diff.HasValue ? $" diff{diff.Value}" : "")}", () => { if (diff != null) return carousel.SelectedBeatmap == carousel.BeatmapSets.Skip(set - 1).First().Beatmaps.Skip(diff.Value - 1).First(); @@ -109,8 +775,10 @@ namespace osu.Game.Tests.Visual.SongSelect private void advanceSelection(bool diff, int direction = 1, int count = 1) { if (count == 1) + { AddStep($"select {(direction > 0 ? "next" : "prev")} {(diff ? "diff" : "set")}", () => carousel.SelectNext(direction, !diff)); + } else { AddRepeatStep($"select {(direction > 0 ? "next" : "prev")} {(diff ? "diff" : "set")}", () => @@ -147,7 +815,7 @@ namespace osu.Game.Tests.Visual.SongSelect private bool selectedBeatmapVisible() { - var currentlySelected = carousel.Items.Find(s => s.Item is CarouselBeatmap && s.Item.State.Value == CarouselItemState.Selected); + var currentlySelected = carousel.Items.FirstOrDefault(s => s.Item is CarouselBeatmap && s.Item.State.Value == CarouselItemState.Selected); if (currentlySelected == null) return true; @@ -160,286 +828,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selection is visible", selectedBeatmapVisible); } - private void checkNonmatchingFilter() - { - AddStep("Toggle non-matching filter", () => - { - carousel.Filter(new FilterCriteria { SearchText = "Dingo" }, false); - carousel.Filter(new FilterCriteria(), false); - eagerSelectedIDs.Add(carousel.SelectedBeatmapSet.ID); - }); - } - - /// - /// Test keyboard traversal - /// - private void testTraversal() - { - advanceSelection(direction: 1, diff: false); - checkSelected(1, 1); - - advanceSelection(direction: 1, diff: true); - checkSelected(1, 2); - - advanceSelection(direction: -1, diff: false); - checkSelected(set_count, 1); - - advanceSelection(direction: -1, diff: true); - checkSelected(set_count - 1, 3); - - advanceSelection(diff: false); - advanceSelection(diff: false); - checkSelected(1, 2); - - advanceSelection(direction: -1, diff: true); - advanceSelection(direction: -1, diff: true); - checkSelected(set_count, 3); - } - - /// - /// Test filtering - /// - private void testFiltering() - { - // basic filtering - - setSelected(1, 1); - - AddStep("Filter", () => carousel.Filter(new FilterCriteria { SearchText = "set #3!" }, false)); - checkVisibleItemCount(diff: false, count: 1); - checkVisibleItemCount(diff: true, count: 3); - checkSelected(3, 1); - - advanceSelection(diff: true, count: 4); - checkSelected(3, 2); - - AddStep("Un-filter (debounce)", () => carousel.Filter(new FilterCriteria())); - AddUntilStep("Wait for debounce", () => !carousel.PendingFilterTask); - checkVisibleItemCount(diff: false, count: set_count); - checkVisibleItemCount(diff: true, count: 3); - - // test filtering some difficulties (and keeping current beatmap set selected). - - setSelected(1, 2); - AddStep("Filter some difficulties", () => carousel.Filter(new FilterCriteria { SearchText = "Normal" }, false)); - checkSelected(1, 1); - - AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); - checkSelected(1, 1); - - AddStep("Filter all", () => carousel.Filter(new FilterCriteria { SearchText = "Dingo" }, false)); - - checkVisibleItemCount(false, 0); - checkVisibleItemCount(true, 0); - AddAssert("Selection is null", () => currentSelection == null); - - advanceSelection(true); - AddAssert("Selection is null", () => currentSelection == null); - - advanceSelection(false); - AddAssert("Selection is null", () => currentSelection == null); - - AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); - - AddAssert("Selection is non-null", () => currentSelection != null); - } - - /// - /// Test random non-repeating algorithm - /// - private void testRandom() - { - setSelected(1, 1); - - nextRandom(); - ensureRandomDidntRepeat(); - nextRandom(); - ensureRandomDidntRepeat(); - nextRandom(); - ensureRandomDidntRepeat(); - - prevRandom(); - ensureRandomFetchSuccess(); - prevRandom(); - ensureRandomFetchSuccess(); - - nextRandom(); - ensureRandomDidntRepeat(); - nextRandom(); - ensureRandomDidntRepeat(); - - nextRandom(); - AddAssert("ensure repeat", () => selectedSets.Contains(carousel.SelectedBeatmapSet)); - - AddStep("Add set with 100 difficulties", () => carousel.UpdateBeatmapSet(createTestBeatmapSetWithManyDifficulties(set_count + 1))); - AddStep("Filter Extra", () => carousel.Filter(new FilterCriteria { SearchText = "Extra 10" }, false)); - checkInvisibleDifficultiesUnselectable(); - checkInvisibleDifficultiesUnselectable(); - checkInvisibleDifficultiesUnselectable(); - checkInvisibleDifficultiesUnselectable(); - checkInvisibleDifficultiesUnselectable(); - AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); - } - - /// - /// Test adding and removing beatmap sets - /// - private void testAddRemove() - { - AddStep("Add new set", () => carousel.UpdateBeatmapSet(createTestBeatmapSet(set_count + 1))); - AddStep("Add new set", () => carousel.UpdateBeatmapSet(createTestBeatmapSet(set_count + 2))); - - checkVisibleItemCount(false, set_count + 2); - - AddStep("Remove set", () => carousel.RemoveBeatmapSet(createTestBeatmapSet(set_count + 2))); - - checkVisibleItemCount(false, set_count + 1); - - setSelected(set_count + 1, 1); - - AddStep("Remove set", () => carousel.RemoveBeatmapSet(createTestBeatmapSet(set_count + 1))); - - checkVisibleItemCount(false, set_count); - - checkSelected(set_count); - } - - /// - /// Test sorting - /// - private void testSorting() - { - AddStep("Sort by author", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Author }, false)); - AddAssert("Check zzzzz is at bottom", () => carousel.BeatmapSets.Last().Metadata.AuthorString == "zzzzz"); - AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); - AddAssert($"Check #{set_count} is at bottom", () => carousel.BeatmapSets.Last().Metadata.Title.EndsWith($"#{set_count}!")); - } - - private void testRemoveAll() - { - setSelected(2, 1); - AddAssert("Selection is non-null", () => currentSelection != null); - - AddStep("Remove selected", () => carousel.RemoveBeatmapSet(carousel.SelectedBeatmapSet)); - checkSelected(2); - - AddStep("Remove first", () => carousel.RemoveBeatmapSet(carousel.BeatmapSets.First())); - AddStep("Remove first", () => carousel.RemoveBeatmapSet(carousel.BeatmapSets.First())); - checkSelected(1); - - AddUntilStep("Remove all", () => - { - if (!carousel.BeatmapSets.Any()) return true; - - carousel.RemoveBeatmapSet(carousel.BeatmapSets.Last()); - return false; - }); - - checkNoSelection(); - } - - private void testEmptyTraversal() - { - advanceSelection(direction: 1, diff: false); - checkNoSelection(); - - advanceSelection(direction: 1, diff: true); - checkNoSelection(); - - advanceSelection(direction: -1, diff: false); - checkNoSelection(); - - advanceSelection(direction: -1, diff: true); - checkNoSelection(); - } - - private void testHiding() - { - var hidingSet = createTestBeatmapSet(1); - hidingSet.Beatmaps[1].Hidden = true; - AddStep("Add set with diff 2 hidden", () => carousel.UpdateBeatmapSet(hidingSet)); - setSelected(1, 1); - - checkVisibleItemCount(true, 2); - advanceSelection(true); - checkSelected(1, 3); - - setHidden(3); - checkSelected(1, 1); - - setHidden(2, false); - advanceSelection(true); - checkSelected(1, 2); - - setHidden(1); - checkSelected(1, 2); - - setHidden(2); - checkNoSelection(); - - void setHidden(int diff, bool hidden = true) - { - AddStep((hidden ? "" : "un") + $"hide diff {diff}", () => - { - hidingSet.Beatmaps[diff - 1].Hidden = hidden; - carousel.UpdateBeatmapSet(hidingSet); - }); - } - } - - private void testSelectingFilteredRuleset() - { - var testMixed = createTestBeatmapSet(set_count + 1); - AddStep("add mixed ruleset beatmapset", () => - { - for (int i = 0; i <= 2; i++) - { - testMixed.Beatmaps[i].Ruleset = rulesets.AvailableRulesets.ElementAt(i); - testMixed.Beatmaps[i].RulesetID = i; - } - - carousel.UpdateBeatmapSet(testMixed); - }); - AddStep("filter to ruleset 0", () => - carousel.Filter(new FilterCriteria { Ruleset = rulesets.AvailableRulesets.ElementAt(0) }, false)); - AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testMixed.Beatmaps[1], false)); - AddAssert("unfiltered beatmap selected", () => carousel.SelectedBeatmap.Equals(testMixed.Beatmaps[0])); - - AddStep("remove mixed set", () => - { - carousel.RemoveBeatmapSet(testMixed); - testMixed = null; - }); - var testSingle = createTestBeatmapSet(set_count + 2); - testSingle.Beatmaps.ForEach(b => - { - b.Ruleset = rulesets.AvailableRulesets.ElementAt(1); - b.RulesetID = b.Ruleset.ID ?? 1; - }); - AddStep("add single ruleset beatmapset", () => carousel.UpdateBeatmapSet(testSingle)); - AddStep("select filtered map skipping filtered", () => carousel.SelectBeatmap(testSingle.Beatmaps[0], false)); - checkNoSelection(); - AddStep("remove single ruleset set", () => carousel.RemoveBeatmapSet(testSingle)); - } - - private void testCarouselRootIsRandom() - { - List beatmapSets = new List(); - - for (int i = 1; i <= 50; i++) - beatmapSets.Add(createTestBeatmapSet(i)); - - loadBeatmaps(beatmapSets); - advanceSelection(direction: 1, diff: false); - checkNonmatchingFilter(); - checkNonmatchingFilter(); - checkNonmatchingFilter(); - checkNonmatchingFilter(); - checkNonmatchingFilter(); - AddAssert("Selection was random", () => eagerSelectedIDs.Count > 1); - } - - private BeatmapSetInfo createTestBeatmapSet(int id) + private BeatmapSetInfo createTestBeatmapSet(int id, bool randomDifficultyCount = false) { return new BeatmapSetInfo { @@ -453,45 +842,37 @@ namespace osu.Game.Tests.Visual.SongSelect Title = $"test set #{id}!", AuthorString = string.Concat(Enumerable.Repeat((char)('z' - Math.Min(25, id - 1)), 5)) }, - Beatmaps = new List(new[] - { - new BeatmapInfo - { - OnlineBeatmapID = id * 10, - Path = "normal.osu", - Version = "Normal", - StarDifficulty = 2, - BaseDifficulty = new BeatmapDifficulty - { - OverallDifficulty = 3.5f, - } - }, - new BeatmapInfo - { - OnlineBeatmapID = id * 10 + 1, - Path = "hard.osu", - Version = "Hard", - StarDifficulty = 5, - BaseDifficulty = new BeatmapDifficulty - { - OverallDifficulty = 5, - } - }, - new BeatmapInfo - { - OnlineBeatmapID = id * 10 + 2, - Path = "insane.osu", - Version = "Insane", - StarDifficulty = 6, - BaseDifficulty = new BeatmapDifficulty - { - OverallDifficulty = 7, - } - }, - }), + Beatmaps = getBeatmaps(randomDifficultyCount ? RNG.Next(1, 20) : 3).ToList() }; } + private IEnumerable getBeatmaps(int count) + { + int id = 0; + + for (int i = 0; i < count; i++) + { + float diff = (float)i / count * 10; + + string version = "Normal"; + if (diff > 6.6) + version = "Insane"; + else if (diff > 3.3) + version = "Hard"; + + yield return new BeatmapInfo + { + OnlineBeatmapID = id++ * 10, + Version = version, + StarDifficulty = diff, + BaseDifficulty = new BeatmapDifficulty + { + OverallDifficulty = diff, + } + }; + } + } + private BeatmapSetInfo createTestBeatmapSetWithManyDifficulties(int id) { var toReturn = new BeatmapSetInfo @@ -516,6 +897,7 @@ namespace osu.Game.Tests.Visual.SongSelect OnlineBeatmapID = b * 10, Path = $"extra{b}.osu", Version = $"Extra {b}", + Ruleset = rulesets.GetRuleset((b - 1) % 4), StarDifficulty = 2, BaseDifficulty = new BeatmapDifficulty { @@ -529,9 +911,26 @@ namespace osu.Game.Tests.Visual.SongSelect private class TestBeatmapCarousel : BeatmapCarousel { - public new List Items => base.Items; - public bool PendingFilterTask => PendingFilter != null; + + public IEnumerable Items + { + get + { + foreach (var item in ScrollableContent) + { + yield return item; + + if (item is DrawableCarouselBeatmapSet set) + { + foreach (var difficulty in set.DrawableBeatmaps) + yield return difficulty; + } + } + } + } + + protected override IEnumerable GetLoadableBeatmaps() => Enumerable.Empty(); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetailArea.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetailArea.cs deleted file mode 100644 index 7b97a27732..0000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetailArea.cs +++ /dev/null @@ -1,168 +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.Linq; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Screens.Select; -using osuTK; - -namespace osu.Game.Tests.Visual.SongSelect -{ - [TestFixture] - [System.ComponentModel.Description("PlaySongSelect leaderboard/details area")] - public class TestSceneBeatmapDetailArea : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] { typeof(BeatmapDetails) }; - - [BackgroundDependencyLoader] - private void load(OsuGameBase game) - { - BeatmapDetailArea detailsArea; - Add(detailsArea = new BeatmapDetailArea - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(550f, 450f), - }); - - AddStep("all metrics", () => detailsArea.Beatmap = new DummyWorkingBeatmap(null, null) - { - BeatmapSetInfo = - { - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() } - }, - BeatmapInfo = - { - Version = "All Metrics", - Metadata = new BeatmapMetadata - { - Source = "osu!lazer", - Tags = "this beatmap has all the metrics", - }, - BaseDifficulty = new BeatmapDifficulty - { - CircleSize = 7, - DrainRate = 1, - OverallDifficulty = 5.7f, - ApproachRate = 3.5f, - }, - StarDifficulty = 5.3f, - Metrics = new BeatmapMetrics - { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), - }, - } - } - ); - - AddStep("all except source", () => detailsArea.Beatmap = new DummyWorkingBeatmap(null, null) - { - BeatmapSetInfo = - { - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() } - }, - BeatmapInfo = - { - Version = "All Metrics", - Metadata = new BeatmapMetadata - { - Tags = "this beatmap has all the metrics", - }, - BaseDifficulty = new BeatmapDifficulty - { - CircleSize = 7, - DrainRate = 1, - OverallDifficulty = 5.7f, - ApproachRate = 3.5f, - }, - StarDifficulty = 5.3f, - Metrics = new BeatmapMetrics - { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), - }, - } - }); - - AddStep("ratings", () => detailsArea.Beatmap = new DummyWorkingBeatmap(null, null) - { - BeatmapSetInfo = - { - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() } - }, - BeatmapInfo = - { - Version = "Only Ratings", - Metadata = new BeatmapMetadata - { - Source = "osu!lazer", - Tags = "this beatmap has ratings metrics but not retries or fails", - }, - BaseDifficulty = new BeatmapDifficulty - { - CircleSize = 6, - DrainRate = 9, - OverallDifficulty = 6, - ApproachRate = 6, - }, - StarDifficulty = 4.8f - } - }); - - AddStep("fails+retries", () => detailsArea.Beatmap = new DummyWorkingBeatmap(null, null) - { - BeatmapInfo = - { - Version = "Only Retries and Fails", - Metadata = new BeatmapMetadata - { - Source = "osu!lazer", - Tags = "this beatmap has retries and fails but no ratings", - }, - BaseDifficulty = new BeatmapDifficulty - { - CircleSize = 3.7f, - DrainRate = 6, - OverallDifficulty = 6, - ApproachRate = 7, - }, - StarDifficulty = 2.91f, - Metrics = new BeatmapMetrics - { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), - }, - } - }); - - AddStep("null metrics", () => detailsArea.Beatmap = new DummyWorkingBeatmap(null, null) - { - BeatmapInfo = - { - Version = "No Metrics", - Metadata = new BeatmapMetadata - { - Source = "osu!lazer", - Tags = "this beatmap has no metrics", - }, - BaseDifficulty = new BeatmapDifficulty - { - CircleSize = 5, - DrainRate = 5, - OverallDifficulty = 5.5f, - ApproachRate = 6.5f, - }, - StarDifficulty = 1.97f, - } - }); - - AddStep("null beatmap", () => detailsArea.Beatmap = null); - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index 932e114580..0b2c0ce63b 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -13,6 +13,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Taiko; @@ -75,7 +76,6 @@ namespace osu.Game.Tests.Visual.SongSelect testBeatmapLabels(instance); - // TODO: adjust cases once more info is shown for other gamemodes switch (instance) { case OsuRuleset _: @@ -99,8 +99,6 @@ namespace osu.Game.Tests.Visual.SongSelect break; } } - - testNullBeatmap(); } private void testBeatmapLabels(Ruleset ruleset) @@ -117,7 +115,8 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("check info labels count", () => infoWedge.Info.InfoLabelContainer.Children.Count == expectedCount); } - private void testNullBeatmap() + [Test] + public void TestNullBeatmap() { selectBeatmap(null); AddAssert("check empty version", () => string.IsNullOrEmpty(infoWedge.Info.VersionLabel.Text)); @@ -127,6 +126,12 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("check no info labels", () => !infoWedge.Info.InfoLabelContainer.Children.Any()); } + [Test] + public void TestTruncation() + { + selectBeatmap(createLongMetadata()); + } + private void selectBeatmap([CanBeNull] IBeatmap b) { BeatmapInfoWedge.BufferedWedgeInfo infoBefore = null; @@ -166,15 +171,34 @@ namespace osu.Game.Tests.Visual.SongSelect }; } + private IBeatmap createLongMetadata() + { + return new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + AuthorString = "WWWWWWWWWWWWWWW", + Artist = "Verrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrry long Artist", + Source = "Verrrrry long Source", + Title = "Verrrrry long Title" + }, + Version = "Verrrrrrrrrrrrrrrrrrrrrrrrrrrrry long Version", + Status = BeatmapSetOnlineStatus.Graveyard, + }, + }; + } + private class TestBeatmapInfoWedge : BeatmapInfoWedge { public new BufferedWedgeInfo Info => base.Info; } - private class TestHitObject : HitObject, IHasPosition + private class TestHitObject : ConvertHitObject, IHasPosition { - public float X { get; } = 0; - public float Y { get; } = 0; + public float X => 0; + public float Y => 0; public Vector2 Position { get; } = Vector2.Zero; } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs similarity index 81% rename from osu.Game.Tests/Visual/SongSelect/TestSceneLeaderboard.cs rename to osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs index 8e358a77db..67cd720260 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneLeaderboard.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; -using System.ComponentModel; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Online.Leaderboards; +using osu.Game.Overlays; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; @@ -14,20 +16,20 @@ using osuTK; namespace osu.Game.Tests.Visual.SongSelect { - [Description("PlaySongSelect leaderboard")] - public class TestSceneLeaderboard : OsuTestScene + public class TestSceneBeatmapLeaderboard : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Placeholder), - typeof(MessagePlaceholder), - typeof(RetrievalFailurePlaceholder), - }; - private readonly FailableLeaderboard leaderboard; - public TestSceneLeaderboard() + [Cached] + private readonly DialogOverlay dialogOverlay; + + public TestSceneBeatmapLeaderboard() { + Add(dialogOverlay = new DialogOverlay + { + Depth = -1 + }); + Add(leaderboard = new FailableLeaderboard { Origin = Anchor.Centre, @@ -37,13 +39,61 @@ namespace osu.Game.Tests.Visual.SongSelect }); AddStep(@"New Scores", newScores); + AddStep(@"Show personal best", showPersonalBest); AddStep(@"Empty Scores", () => leaderboard.SetRetrievalState(PlaceholderState.NoScores)); AddStep(@"Network failure", () => leaderboard.SetRetrievalState(PlaceholderState.NetworkFailure)); AddStep(@"No supporter", () => leaderboard.SetRetrievalState(PlaceholderState.NotSupporter)); AddStep(@"Not logged in", () => leaderboard.SetRetrievalState(PlaceholderState.NotLoggedIn)); AddStep(@"Unavailable", () => leaderboard.SetRetrievalState(PlaceholderState.Unavailable)); + AddStep(@"None selected", () => leaderboard.SetRetrievalState(PlaceholderState.NoneSelected)); foreach (BeatmapSetOnlineStatus status in Enum.GetValues(typeof(BeatmapSetOnlineStatus))) AddStep($"{status} beatmap", () => showBeatmapWithStatus(status)); + AddStep("null personal best position", showPersonalBestWithNullPosition); + } + + private void showPersonalBestWithNullPosition() + { + leaderboard.TopScore = new ScoreInfo + { + Rank = ScoreRank.XH, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock() }, + User = new User + { + Id = 6602580, + Username = @"waaiiru", + Country = new Country + { + FullName = @"Spain", + FlagName = @"ES", + }, + }, + }; + } + + private void showPersonalBest() + { + leaderboard.TopScore = new ScoreInfo + { + Position = 999, + Rank = ScoreRank.XH, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + User = new User + { + Id = 6602580, + Username = @"waaiiru", + Country = new Country + { + FullName = @"Spain", + FlagName = @"ES", + }, + }, + }; } private void newScores() diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs index ecdc484887..e9742acdde 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapOptionsOverlay.cs @@ -3,9 +3,8 @@ using System.ComponentModel; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; using osu.Game.Screens.Select.Options; -using osuTK.Graphics; -using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { @@ -16,10 +15,13 @@ namespace osu.Game.Tests.Visual.SongSelect { var overlay = new BeatmapOptionsOverlay(); - overlay.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, Color4.Purple, null, Key.Number1); - overlay.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, Color4.Purple, null, Key.Number2); - overlay.AddButton(@"Edit", @"Beatmap", FontAwesome.Solid.PencilAlt, Color4.Yellow, null, Key.Number3); - overlay.AddButton(@"Delete", @"Beatmap", FontAwesome.Solid.Trash, Color4.Pink, null, Key.Number4, float.MaxValue); + var colours = new OsuColour(); + + overlay.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, null); + overlay.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, null); + overlay.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, colours.Purple, null); + overlay.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, null); + overlay.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, null); Add(overlay); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs new file mode 100644 index 0000000000..5d0fb248df --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs @@ -0,0 +1,228 @@ +// 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.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Screens.Select; +using osu.Game.Tests.Resources; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.SongSelect +{ + public class TestSceneFilterControl : OsuManualInputManagerTestScene + { + protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private CollectionManager collectionManager; + + private RulesetStore rulesets; + private BeatmapManager beatmapManager; + + private FilterControl control; + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default)); + + beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + + base.Content.AddRange(new Drawable[] + { + collectionManager = new CollectionManager(LocalStorage), + Content + }); + + Dependencies.Cache(collectionManager); + } + + [SetUp] + public void SetUp() => Schedule(() => + { + collectionManager.Collections.Clear(); + + Child = control = new FilterControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = FilterControl.HEIGHT, + }; + }); + + [Test] + public void TestEmptyCollectionFilterContainsAllBeatmaps() + { + assertCollectionDropdownContains("All beatmaps"); + assertCollectionHeaderDisplays("All beatmaps"); + } + + [Test] + public void TestCollectionAddedToDropdown() + { + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } })); + assertCollectionDropdownContains("1"); + assertCollectionDropdownContains("2"); + } + + [Test] + public void TestCollectionRemovedFromDropdown() + { + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } })); + AddStep("remove collection", () => collectionManager.Collections.RemoveAt(0)); + + assertCollectionDropdownContains("1", false); + assertCollectionDropdownContains("2"); + } + + [Test] + public void TestCollectionRenamed() + { + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("select collection", () => + { + var dropdown = control.ChildrenOfType().Single(); + dropdown.Current.Value = dropdown.ItemSource.ElementAt(1); + }); + + addExpandHeaderStep(); + + AddStep("change name", () => collectionManager.Collections[0].Name.Value = "First"); + + assertCollectionDropdownContains("First"); + assertCollectionHeaderDisplays("First"); + } + + [Test] + public void TestAllBeatmapFilterDoesNotHaveAddButton() + { + addExpandHeaderStep(); + AddStep("hover all beatmaps", () => InputManager.MoveMouseTo(getAddOrRemoveButton(0))); + AddAssert("'All beatmaps' filter does not have add button", () => !getAddOrRemoveButton(0).IsPresent); + } + + [Test] + public void TestCollectionFilterHasAddButton() + { + addExpandHeaderStep(); + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1))); + AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent); + } + + [Test] + public void TestButtonDisabledAndEnabledWithBeatmapChanges() + { + addExpandHeaderStep(); + + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + + AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); + AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value); + + AddStep("set dummy beatmap", () => Beatmap.SetDefault()); + AddAssert("button disabled", () => !getAddOrRemoveButton(1).Enabled.Value); + } + + [Test] + public void TestButtonChangesWhenAddedAndRemovedFromCollection() + { + addExpandHeaderStep(); + + AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); + + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + + AddStep("add beatmap to collection", () => collectionManager.Collections[0].Beatmaps.Add(Beatmap.Value.BeatmapInfo)); + AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare)); + + AddStep("remove beatmap from collection", () => collectionManager.Collections[0].Beatmaps.Clear()); + AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + } + + [Test] + public void TestButtonAddsAndRemovesBeatmap() + { + addExpandHeaderStep(); + + AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); + + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + + addClickAddOrRemoveButtonStep(1); + AddAssert("collection contains beatmap", () => collectionManager.Collections[0].Beatmaps.Contains(Beatmap.Value.BeatmapInfo)); + AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare)); + + addClickAddOrRemoveButtonStep(1); + AddAssert("collection does not contain beatmap", () => !collectionManager.Collections[0].Beatmaps.Contains(Beatmap.Value.BeatmapInfo)); + AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + } + + [Test] + public void TestManageCollectionsFilterIsNotSelected() + { + addExpandHeaderStep(); + + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("select collection", () => + { + InputManager.MoveMouseTo(getCollectionDropdownItems().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + + addExpandHeaderStep(); + + AddStep("click manage collections filter", () => + { + InputManager.MoveMouseTo(getCollectionDropdownItems().Last()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("collection filter still selected", () => control.CreateCriteria().Collection?.Name.Value == "1"); + } + + private void assertCollectionHeaderDisplays(string collectionName, bool shouldDisplay = true) + => AddAssert($"collection dropdown header displays '{collectionName}'", + () => shouldDisplay == (control.ChildrenOfType().Single().ChildrenOfType().First().Text == collectionName)); + + private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) => + AddAssert($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'", + // A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872 + () => shouldContain == (getCollectionDropdownItems().Any(i => i.ChildrenOfType().OfType().First().Text == collectionName))); + + private IconButton getAddOrRemoveButton(int index) + => getCollectionDropdownItems().ElementAt(index).ChildrenOfType().Single(); + + private void addExpandHeaderStep() => AddStep("expand header", () => + { + InputManager.MoveMouseTo(control.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + private void addClickAddOrRemoveButtonStep(int index) => AddStep("click add or remove button", () => + { + InputManager.MoveMouseTo(getAddOrRemoveButton(index)); + InputManager.Click(MouseButton.Left); + }); + + private IEnumerable.DropdownMenu.DrawableDropdownMenuItem> getCollectionDropdownItems() + => control.ChildrenOfType().Single().ChildrenOfType.DropdownMenu.DrawableDropdownMenuItem>(); + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index f3255814f2..aa531ba106 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -11,19 +11,26 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Database; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko; +using osu.Game.Scoring; +using osu.Game.Screens.Play; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Filter; +using osu.Game.Users; +using osuTK.Input; namespace osu.Game.Tests.Visual.SongSelect { @@ -31,81 +38,241 @@ namespace osu.Game.Tests.Visual.SongSelect public class TestScenePlaySongSelect : ScreenTestScene { private BeatmapManager manager; - private RulesetStore rulesets; - + private MusicController music; private WorkingBeatmap defaultBeatmap; - private DatabaseContextFactory factory; - - public override IReadOnlyList RequiredTypes => new[] - { - typeof(Screens.Select.SongSelect), - typeof(BeatmapCarousel), - - typeof(CarouselItem), - typeof(CarouselGroup), - typeof(CarouselGroupEagerSelect), - typeof(CarouselBeatmap), - typeof(CarouselBeatmapSet), - - typeof(DrawableCarouselItem), - typeof(CarouselItemState), - - typeof(DrawableCarouselBeatmap), - typeof(DrawableCarouselBeatmapSet), - }; - - private class TestSongSelect : PlaySongSelect - { - public Action StartRequested; - - public new Bindable Ruleset => base.Ruleset; - - public WorkingBeatmap CurrentBeatmap => Beatmap.Value; - public WorkingBeatmap CurrentBeatmapDetailsBeatmap => BeatmapDetails.Beatmap; - public new BeatmapCarousel Carousel => base.Carousel; - - protected override bool OnStart() - { - StartRequested?.Invoke(); - return base.OnStart(); - } - } - private TestSongSelect songSelect; - protected override void Dispose(bool isDisposing) - { - factory.ResetDatabase(); - base.Dispose(isDisposing); - } - [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { - factory = new DatabaseContextFactory(LocalStorage); - factory.ResetDatabase(); + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, defaultBeatmap = Beatmap.Default)); - using (var usage = factory.Get()) - usage.Migrate(); + Dependencies.Cache(music = new MusicController()); - factory.ResetDatabase(); + // required to get bindables attached + Add(music); - using (var usage = factory.Get()) - usage.Migrate(); - - Dependencies.Cache(rulesets = new RulesetStore(factory)); - Dependencies.Cache(manager = new BeatmapManager(LocalStorage, factory, rulesets, null, audio, host, defaultBeatmap = Beatmap.Default)); - - Beatmap.SetDefault(); + Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); } - [SetUp] - public virtual void SetUp() => Schedule(() => + private OsuConfigManager config; + + public override void SetUpSteps() { - Ruleset.Value = new OsuRuleset().RulesetInfo; - manager?.Delete(manager.GetAllUsableBeatmapSets()); - }); + base.SetUpSteps(); + + AddStep("delete all beatmaps", () => + { + Ruleset.Value = new OsuRuleset().RulesetInfo; + manager?.Delete(manager.GetAllUsableBeatmapSets()); + + Beatmap.SetDefault(); + }); + } + + [Test] + public void TestSingleFilterOnEnter() + { + addRulesetImportStep(0); + addRulesetImportStep(0); + + createSongSelect(); + + AddAssert("filter count is 1", () => songSelect.FilterCount == 1); + } + + [Test] + public void TestChangeBeatmapBeforeEnter() + { + addRulesetImportStep(0); + + createSongSelect(); + + waitForInitialSelection(); + + WorkingBeatmap selected = null; + + AddStep("store selected beatmap", () => selected = Beatmap.Value); + + AddStep("select next and enter", () => + { + InputManager.Key(Key.Down); + InputManager.Key(Key.Enter); + }); + + AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddAssert("ensure selection changed", () => selected != Beatmap.Value); + } + + [Test] + public void TestChangeBeatmapAfterEnter() + { + addRulesetImportStep(0); + + createSongSelect(); + + waitForInitialSelection(); + + WorkingBeatmap selected = null; + + AddStep("store selected beatmap", () => selected = Beatmap.Value); + + AddStep("select next and enter", () => + { + InputManager.Key(Key.Enter); + InputManager.Key(Key.Down); + }); + + AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); + } + + [Test] + public void TestChangeBeatmapViaMouseBeforeEnter() + { + addRulesetImportStep(0); + + createSongSelect(); + + AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); + + WorkingBeatmap selected = null; + + AddStep("store selected beatmap", () => selected = Beatmap.Value); + + AddStep("select next and enter", () => + { + InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() + .First(b => ((CarouselBeatmap)b.Item).Beatmap != songSelect.Carousel.SelectedBeatmap)); + + InputManager.Click(MouseButton.Left); + + InputManager.Key(Key.Enter); + }); + + AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddAssert("ensure selection changed", () => selected != Beatmap.Value); + } + + [Test] + public void TestChangeBeatmapViaMouseAfterEnter() + { + addRulesetImportStep(0); + + createSongSelect(); + + waitForInitialSelection(); + + WorkingBeatmap selected = null; + + AddStep("store selected beatmap", () => selected = Beatmap.Value); + + AddStep("select next and enter", () => + { + InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() + .First(b => ((CarouselBeatmap)b.Item).Beatmap != songSelect.Carousel.SelectedBeatmap)); + + InputManager.PressButton(MouseButton.Left); + + InputManager.Key(Key.Enter); + + InputManager.ReleaseButton(MouseButton.Left); + }); + + AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); + } + + [Test] + public void TestNoFilterOnSimpleResume() + { + addRulesetImportStep(0); + addRulesetImportStep(0); + + createSongSelect(); + + AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); + AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + + AddStep("return", () => songSelect.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); + AddAssert("filter count is 1", () => songSelect.FilterCount == 1); + } + + [Test] + public void TestFilterOnResumeAfterChange() + { + addRulesetImportStep(0); + addRulesetImportStep(0); + + AddStep("change convert setting", () => config.Set(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("return", () => songSelect.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); + AddAssert("filter count is 2", () => songSelect.FilterCount == 2); + } + + [Test] + public void TestAudioResuming() + { + createSongSelect(); + + addRulesetImportStep(0); + addRulesetImportStep(0); + + checkMusicPlaying(true); + AddStep("select first", () => songSelect.Carousel.SelectBeatmap(songSelect.Carousel.BeatmapSets.First().Beatmaps.First())); + checkMusicPlaying(true); + + AddStep("manual pause", () => music.TogglePause()); + checkMusicPlaying(false); + AddStep("select next difficulty", () => songSelect.Carousel.SelectNext(skipDifficulties: false)); + checkMusicPlaying(false); + + AddStep("select next set", () => songSelect.Carousel.SelectNext()); + checkMusicPlaying(true); + } + + [TestCase(false)] + [TestCase(true)] + public void TestAudioRemainsCorrectOnRulesetChange(bool rulesetsInSameBeatmap) + { + createSongSelect(); + + // start with non-osu! to avoid convert confusion + changeRuleset(1); + + if (rulesetsInSameBeatmap) + { + AddStep("import multi-ruleset map", () => + { + var usableRulesets = rulesets.AvailableRulesets.Where(r => r.ID != 2).ToArray(); + manager.Import(createTestBeatmapSet(usableRulesets)).Wait(); + }); + } + else + { + addRulesetImportStep(1); + addRulesetImportStep(0); + } + + checkMusicPlaying(true); + + AddStep("manual pause", () => music.TogglePause()); + checkMusicPlaying(false); + + changeRuleset(0); + checkMusicPlaying(!rulesetsInSameBeatmap); + } [Test] public void TestDummy() @@ -130,22 +297,20 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); - AddStep(@"Sort by Artist", delegate { songSelect.FilterControl.Sort = SortMode.Artist; }); - AddStep(@"Sort by Title", delegate { songSelect.FilterControl.Sort = SortMode.Title; }); - AddStep(@"Sort by Author", delegate { songSelect.FilterControl.Sort = SortMode.Author; }); - AddStep(@"Sort by DateAdded", delegate { songSelect.FilterControl.Sort = SortMode.DateAdded; }); - AddStep(@"Sort by BPM", delegate { songSelect.FilterControl.Sort = SortMode.BPM; }); - AddStep(@"Sort by Length", delegate { songSelect.FilterControl.Sort = SortMode.Length; }); - AddStep(@"Sort by Difficulty", delegate { songSelect.FilterControl.Sort = SortMode.Difficulty; }); + 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)); } [Test] - [Ignore("needs fixing")] public void TestImportUnderDifferentRuleset() { createSongSelect(); - changeRuleset(2); - addRulesetImportStep(0); + addRulesetImportStep(2); AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmap == null); } @@ -165,6 +330,68 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmap == null); } + [Test] + public void TestPresentNewRulesetNewBeatmap() + { + createSongSelect(); + changeRuleset(2); + + addRulesetImportStep(2); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.RulesetID == 2); + + addRulesetImportStep(0); + addRulesetImportStep(0); + addRulesetImportStep(0); + + BeatmapInfo target = null; + + AddStep("select beatmap/ruleset externally", () => + { + target = manager.GetAllUsableBeatmapSets() + .Last(b => b.Beatmaps.Any(bi => bi.RulesetID == 0)).Beatmaps.Last(); + + Ruleset.Value = rulesets.AvailableRulesets.First(r => r.ID == 0); + Beatmap.Value = manager.GetWorkingBeatmap(target); + }); + + 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); + } + + [Test] + public void TestPresentNewBeatmapNewRuleset() + { + createSongSelect(); + changeRuleset(2); + + addRulesetImportStep(2); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.RulesetID == 2); + + addRulesetImportStep(0); + addRulesetImportStep(0); + addRulesetImportStep(0); + + BeatmapInfo target = null; + + AddStep("select beatmap/ruleset externally", () => + { + target = manager.GetAllUsableBeatmapSets() + .Last(b => b.Beatmaps.Any(bi => bi.RulesetID == 0)).Beatmaps.Last(); + + Beatmap.Value = manager.GetWorkingBeatmap(target); + Ruleset.Value = rulesets.AvailableRulesets.First(r => r.ID == 0); + }); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.Equals(target)); + + 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); + } + [Test] public void TestRulesetChangeResetsMods() { @@ -179,22 +406,38 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("change ruleset", () => { - Mods.ValueChanged += onModChange; + SelectedMods.ValueChanged += onModChange; songSelect.Ruleset.ValueChanged += onRulesetChange; Ruleset.Value = new TaikoRuleset().RulesetInfo; - Mods.ValueChanged -= onModChange; + SelectedMods.ValueChanged -= onModChange; songSelect.Ruleset.ValueChanged -= onRulesetChange; }); AddAssert("mods changed before ruleset", () => modChangeIndex < rulesetChangeIndex); - AddAssert("empty mods", () => !Mods.Value.Any()); + AddAssert("empty mods", () => !SelectedMods.Value.Any()); void onModChange(ValueChangedEvent> e) => modChangeIndex = actionIndex++; void onRulesetChange(ValueChangedEvent e) => rulesetChangeIndex = actionIndex++; } + [Test] + public void TestModsRetainedBetweenSongSelect() + { + AddAssert("empty mods", () => !SelectedMods.Value.Any()); + + createSongSelect(); + + addRulesetImportStep(0); + + changeMods(new OsuModHardRock()); + + createSongSelect(); + + AddAssert("mods retained", () => SelectedMods.Value.Any()); + } + [Test] public void TestStartAfterUnMatchingFilterDoesNotStart() { @@ -217,6 +460,119 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("start not requested", () => !startRequested); } + [TestCase(false)] + [TestCase(true)] + public void TestExternalBeatmapChangeWhileFiltered(bool differentRuleset) + { + createSongSelect(); + addManyTestMaps(); + + changeRuleset(0); + + // used for filter check below + AddStep("allow convert display", () => config.Set(OsuSetting.ShowConvertedBeatmaps, true)); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + + AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); + + AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); + + AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmap == null); + + BeatmapInfo target = null; + + int targetRuleset = differentRuleset ? 1 : 0; + + AddStep("select beatmap externally", () => + { + target = manager.GetAllUsableBeatmapSets() + .Where(b => b.Beatmaps.Any(bi => bi.RulesetID == targetRuleset)) + .ElementAt(5).Beatmaps.First(bi => bi.RulesetID == targetRuleset); + + Beatmap.Value = manager.GetWorkingBeatmap(target); + }); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + + AddAssert("selected only shows expected ruleset (plus converts)", () => + { + var selectedPanel = songSelect.Carousel.ChildrenOfType().First(s => s.Item.State.Value == CarouselItemState.Selected); + + // special case for converts checked here. + return selectedPanel.ChildrenOfType().All(i => + i.IsFiltered || i.Item.Beatmap.Ruleset.ID == targetRuleset || i.Item.Beatmap.Ruleset.ID == 0); + }); + + AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmap?.OnlineBeatmapID == target.OnlineBeatmapID); + AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.OnlineBeatmapID == target.OnlineBeatmapID); + + AddStep("reset filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = string.Empty); + + AddAssert("game still correct", () => Beatmap.Value?.BeatmapInfo.OnlineBeatmapID == target.OnlineBeatmapID); + AddAssert("carousel still correct", () => songSelect.Carousel.SelectedBeatmap.OnlineBeatmapID == target.OnlineBeatmapID); + } + + [Test] + public void TestExternalBeatmapChangeWhileFilteredThenRefilter() + { + createSongSelect(); + addManyTestMaps(); + + changeRuleset(0); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + + AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); + + AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); + + AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmap == null); + + BeatmapInfo target = null; + + AddStep("select beatmap externally", () => + { + target = manager.GetAllUsableBeatmapSets().Where(b => b.Beatmaps.Any(bi => bi.RulesetID == 1)) + .ElementAt(5).Beatmaps.First(); + + Beatmap.Value = manager.GetWorkingBeatmap(target); + }); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap != null); + + AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmap?.OnlineBeatmapID == target.OnlineBeatmapID); + AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.OnlineBeatmapID == target.OnlineBeatmapID); + + AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nononoo"); + + AddUntilStep("game lost selection", () => Beatmap.Value is DummyWorkingBeatmap); + AddAssert("carousel lost selection", () => songSelect.Carousel.SelectedBeatmap == null); + } + + [Test] + public void TestAutoplayViaCtrlEnter() + { + addRulesetImportStep(0); + + createSongSelect(); + + AddStep("press ctrl+enter", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.Enter); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); + + AddAssert("autoplay enabled", () => songSelect.Mods.Value.FirstOrDefault() is ModAutoplay); + + AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + + AddAssert("mod disabled", () => songSelect.Mods.Value.Count == 0); + } + [Test] public void TestHideSetSelectsCorrectBeatmap() { @@ -229,14 +585,246 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selected beatmap has not changed", () => songSelect.Carousel.SelectedBeatmap.ID == previousID); } + [Test] + public void TestDifficultyIconSelecting() + { + addRulesetImportStep(0); + createSongSelect(); + + DrawableCarouselBeatmapSet set = null; + AddStep("Find the DrawableCarouselBeatmapSet", () => + { + set = songSelect.Carousel.ChildrenOfType().First(); + }); + + FilterableDifficultyIcon difficultyIcon = null; + AddStep("Find an icon", () => + { + difficultyIcon = set.ChildrenOfType() + .First(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex()); + }); + + AddStep("Click on a difficulty", () => + { + InputManager.MoveMouseTo(difficultyIcon); + + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Selected beatmap correct", () => getCurrentBeatmapIndex() == getDifficultyIconIndex(set, difficultyIcon)); + + double? maxBPM = null; + AddStep("Filter some difficulties", () => songSelect.Carousel.Filter(new FilterCriteria + { + BPM = new FilterCriteria.OptionalRange + { + Min = maxBPM = songSelect.Carousel.SelectedBeatmapSet.MaxBPM, + IsLowerInclusive = true + } + })); + + BeatmapInfo filteredBeatmap = null; + FilterableDifficultyIcon filteredIcon = null; + + AddStep("Get filtered icon", () => + { + filteredBeatmap = songSelect.Carousel.SelectedBeatmapSet.Beatmaps.First(b => b.BPM < maxBPM); + int filteredBeatmapIndex = getBeatmapIndex(filteredBeatmap.BeatmapSet, filteredBeatmap); + filteredIcon = set.ChildrenOfType().ElementAt(filteredBeatmapIndex); + }); + + AddStep("Click on a filtered difficulty", () => + { + InputManager.MoveMouseTo(filteredIcon); + + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Selected beatmap correct", () => songSelect.Carousel.SelectedBeatmap == filteredBeatmap); + } + + [Test] + public void TestDifficultyIconSelectingForDifferentRuleset() + { + changeRuleset(0); + + createSongSelect(); + + AddStep("import multi-ruleset map", () => + { + var usableRulesets = rulesets.AvailableRulesets.Where(r => r.ID != 2).ToArray(); + manager.Import(createTestBeatmapSet(usableRulesets)).Wait(); + }); + + DrawableCarouselBeatmapSet set = null; + AddUntilStep("Find the DrawableCarouselBeatmapSet", () => + { + set = songSelect.Carousel.ChildrenOfType().FirstOrDefault(); + return set != null; + }); + + FilterableDifficultyIcon difficultyIcon = null; + AddStep("Find an icon for different ruleset", () => + { + difficultyIcon = set.ChildrenOfType() + .First(icon => icon.Item.Beatmap.Ruleset.ID == 3); + }); + + AddAssert("Check ruleset is osu!", () => Ruleset.Value.ID == 0); + + int previousSetID = 0; + + AddStep("record set ID", () => previousSetID = Beatmap.Value.BeatmapSetInfo.ID); + + AddStep("Click on a difficulty", () => + { + InputManager.MoveMouseTo(difficultyIcon); + + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.ID == 3); + + AddAssert("Selected beatmap still same set", () => songSelect.Carousel.SelectedBeatmap.BeatmapSet.ID == previousSetID); + AddAssert("Selected beatmap is mania", () => Beatmap.Value.BeatmapInfo.Ruleset.ID == 3); + } + + [Test] + public void TestGroupedDifficultyIconSelecting() + { + changeRuleset(0); + + createSongSelect(); + + BeatmapSetInfo imported = null; + + AddStep("import huge difficulty count map", () => + { + var usableRulesets = rulesets.AvailableRulesets.Where(r => r.ID != 2).ToArray(); + imported = manager.Import(createTestBeatmapSet(usableRulesets, 50)).Result; + }); + + AddStep("select the first beatmap of import", () => Beatmap.Value = manager.GetWorkingBeatmap(imported.Beatmaps.First())); + + DrawableCarouselBeatmapSet set = null; + AddUntilStep("Find the DrawableCarouselBeatmapSet", () => + { + set = songSelect.Carousel.ChildrenOfType().FirstOrDefault(); + return set != null; + }); + + FilterableGroupedDifficultyIcon groupIcon = null; + AddStep("Find group icon for different ruleset", () => + { + groupIcon = set.ChildrenOfType() + .First(icon => icon.Items.First().Beatmap.Ruleset.ID == 3); + }); + + AddAssert("Check ruleset is osu!", () => Ruleset.Value.ID == 0); + + AddStep("Click on group", () => + { + InputManager.MoveMouseTo(groupIcon); + + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.ID == 3); + + AddAssert("Check first item in group selected", () => Beatmap.Value.BeatmapInfo == groupIcon.Items.First().Beatmap); + } + + [Test] + public void TestChangeRulesetWhilePresentingScore() + { + BeatmapInfo getPresentBeatmap() => manager.QueryBeatmap(b => !b.BeatmapSet.DeletePending && b.RulesetID == 0); + BeatmapInfo getSwitchBeatmap() => manager.QueryBeatmap(b => !b.BeatmapSet.DeletePending && b.RulesetID == 1); + + changeRuleset(0); + + createSongSelect(); + + addRulesetImportStep(0); + addRulesetImportStep(1); + + AddStep("present score", () => + { + // this ruleset change should be overridden by the present. + Ruleset.Value = getSwitchBeatmap().Ruleset; + + songSelect.PresentScore(new ScoreInfo + { + User = new User { Username = "woo" }, + Beatmap = getPresentBeatmap(), + Ruleset = getPresentBeatmap().Ruleset + }); + }); + + AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); + + AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.Equals(getPresentBeatmap())); + AddAssert("check ruleset is correct for score", () => Ruleset.Value.ID == 0); + } + + [Test] + public void TestChangeBeatmapWhilePresentingScore() + { + BeatmapInfo getPresentBeatmap() => manager.QueryBeatmap(b => !b.BeatmapSet.DeletePending && b.RulesetID == 0); + BeatmapInfo getSwitchBeatmap() => manager.QueryBeatmap(b => !b.BeatmapSet.DeletePending && b.RulesetID == 1); + + changeRuleset(0); + + addRulesetImportStep(0); + addRulesetImportStep(1); + + createSongSelect(); + + AddStep("present score", () => + { + // this beatmap change should be overridden by the present. + Beatmap.Value = manager.GetWorkingBeatmap(getSwitchBeatmap()); + + songSelect.PresentScore(new ScoreInfo + { + User = new User { Username = "woo" }, + Beatmap = getPresentBeatmap(), + Ruleset = getPresentBeatmap().Ruleset + }); + }); + + AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); + + AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.Equals(getPresentBeatmap())); + AddAssert("check ruleset is correct for score", () => Ruleset.Value.ID == 0); + } + + private void waitForInitialSelection() + { + AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); + AddUntilStep("wait for difficulty panels visible", () => songSelect.Carousel.ChildrenOfType().Any()); + } + + private int getBeatmapIndex(BeatmapSetInfo set, BeatmapInfo info) => set.Beatmaps.FindIndex(b => b == info); + + private int getCurrentBeatmapIndex() => getBeatmapIndex(songSelect.Carousel.SelectedBeatmapSet, songSelect.Carousel.SelectedBeatmap); + + private int getDifficultyIconIndex(DrawableCarouselBeatmapSet set, FilterableDifficultyIcon icon) + { + return set.ChildrenOfType().ToList().FindIndex(i => i == icon); + } + private void addRulesetImportStep(int id) => AddStep($"import test map for ruleset {id}", () => importForRuleset(id)); - private void importForRuleset(int id) => manager.Import(createTestBeatmapSet(getImportId(), rulesets.AvailableRulesets.Where(r => r.ID == id).ToArray())).Wait(); + private void importForRuleset(int id) => manager.Import(createTestBeatmapSet(rulesets.AvailableRulesets.Where(r => r.ID == id).ToArray())).Wait(); private static int importId; + private int getImportId() => ++importId; - private void changeMods(params Mod[] mods) => AddStep($"change mods to {string.Join(", ", mods.Select(m => m.Acronym))}", () => Mods.Value = mods); + private void checkMusicPlaying(bool playing) => + AddUntilStep($"music {(playing ? "" : "not ")}playing", () => music.IsPlaying == playing); + + private void changeMods(params Mod[] mods) => AddStep($"change mods to {string.Join(", ", mods.Select(m => m.Acronym))}", () => SelectedMods.Value = mods); private void changeRuleset(int id) => AddStep($"change ruleset to {id}", () => Ruleset.Value = rulesets.AvailableRulesets.First(r => r.ID == id)); @@ -244,6 +832,7 @@ namespace osu.Game.Tests.Visual.SongSelect { AddStep("create song select", () => LoadScreen(songSelect = new TestSongSelect())); AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for carousel loaded", () => songSelect.Carousel.IsAlive); } private void addManyTestMaps() @@ -253,20 +842,22 @@ namespace osu.Game.Tests.Visual.SongSelect var usableRulesets = rulesets.AvailableRulesets.Where(r => r.ID != 2).ToArray(); for (int i = 0; i < 100; i += 10) - manager.Import(createTestBeatmapSet(i, usableRulesets)).Wait(); + manager.Import(createTestBeatmapSet(usableRulesets)).Wait(); }); } - private BeatmapSetInfo createTestBeatmapSet(int setId, RulesetInfo[] rulesets) + private BeatmapSetInfo createTestBeatmapSet(RulesetInfo[] rulesets, int countPerRuleset = 6) { int j = 0; RulesetInfo getRuleset() => rulesets[j++ % rulesets.Length]; + int setId = getImportId(); + var beatmaps = new List(); - for (int i = 0; i < 6; i++) + for (int i = 0; i < countPerRuleset; i++) { - int beatmapId = setId * 10 + i; + int beatmapId = setId * 1000 + i; int length = RNG.Next(30000, 200000); double bpm = RNG.NextSingle(80, 200); @@ -275,7 +866,6 @@ namespace osu.Game.Tests.Visual.SongSelect { Ruleset = getRuleset(), OnlineBeatmapID = beatmapId, - Path = "normal.osu", Version = $"{beatmapId} (length {TimeSpan.FromMilliseconds(length):m\\:ss}, bpm {bpm:0.#})", Length = length, BPM = bpm, @@ -301,5 +891,40 @@ namespace osu.Game.Tests.Visual.SongSelect DateAdded = DateTimeOffset.UtcNow, }; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + rulesets?.Dispose(); + } + + private class TestSongSelect : PlaySongSelect + { + public Action StartRequested; + + public new Bindable Ruleset => base.Ruleset; + + public new FilterControl FilterControl => base.FilterControl; + + public WorkingBeatmap CurrentBeatmap => Beatmap.Value; + public WorkingBeatmap CurrentBeatmapDetailsBeatmap => BeatmapDetails.Beatmap; + public new BeatmapCarousel Carousel => base.Carousel; + + public new void PresentScore(ScoreInfo score) => base.PresentScore(score); + + protected override bool OnStart() + { + StartRequested?.Invoke(); + return base.OnStart(); + } + + public int FilterCount; + + protected override void ApplyFilterToCarousel(FilterCriteria criteria) + { + FilterCount++; + base.ApplyFilterToCarousel(criteria); + } + } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.cs new file mode 100644 index 0000000000..b8b8792b9b --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneUserTopScoreContainer.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; +using osu.Game.Online.Leaderboards; +using osu.Game.Overlays; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.SongSelect +{ + public class TestSceneUserTopScoreContainer : OsuTestScene + { + [Cached] + private readonly DialogOverlay dialogOverlay; + + public TestSceneUserTopScoreContainer() + { + UserTopScoreContainer topScoreContainer; + + Add(dialogOverlay = new DialogOverlay + { + Depth = -1 + }); + + Add(new Container + { + Origin = Anchor.BottomCentre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Y, + Width = 500, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.DarkGreen, + }, + topScoreContainer = new UserTopScoreContainer(s => new LeaderboardScore(s, s.Position, false)) + { + Origin = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + } + } + }); + + var scores = new[] + { + new ScoreInfo + { + Position = 999, + Rank = ScoreRank.XH, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + User = new User + { + Id = 6602580, + Username = @"waaiiru", + Country = new Country + { + FullName = @"Spain", + FlagName = @"ES", + }, + }, + }, + new ScoreInfo + { + Position = 110000, + Rank = ScoreRank.X, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + User = new User + { + Id = 4608074, + Username = @"Skycries", + Country = new Country + { + FullName = @"Brazil", + FlagName = @"BR", + }, + }, + }, + new ScoreInfo + { + Position = 22333, + Rank = ScoreRank.S, + Accuracy = 1, + MaxCombo = 244, + TotalScore = 1707827, + User = new User + { + Id = 1541390, + Username = @"Toukai", + Country = new Country + { + FullName = @"Canada", + FlagName = @"CA", + }, + }, + } + }; + + AddStep(@"Trigger visibility", topScoreContainer.ToggleVisibility); + AddStep(@"Add score(rank 999)", () => topScoreContainer.Score.Value = scores[0]); + AddStep(@"Add score(rank 110000)", () => topScoreContainer.Score.Value = scores[1]); + AddStep(@"Add score(rank 22333)", () => topScoreContainer.Score.Value = scores[2]); + AddStep(@"Add null score", () => topScoreContainer.Score.Value = null); + } + } +} diff --git a/osu.Game.Tests/Visual/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/TestSceneOsuGame.cs index fcc3a3596f..b347c39c1e 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/TestSceneOsuGame.cs @@ -34,21 +34,17 @@ namespace osu.Game.Tests.Visual [TestFixture] public class TestSceneOsuGame : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(OsuLogo), - }; - private IReadOnlyList requiredGameDependencies => new[] { typeof(OsuGame), - typeof(RavenLogger), + typeof(SentryLogger), typeof(OsuLogo), typeof(IdleTracker), typeof(OnScreenDisplay), typeof(NotificationOverlay), - typeof(DirectOverlay), - typeof(SocialOverlay), + typeof(BeatmapListingOverlay), + typeof(DashboardOverlay), + typeof(NewsOverlay), typeof(ChannelManager), typeof(ChatOverlay), typeof(SettingsOverlay), @@ -109,16 +105,20 @@ namespace osu.Game.Tests.Visual AddAssert("check OsuGame DI members", () => { foreach (var type in requiredGameDependencies) + { if (game.Dependencies.Get(type) == null) - throw new Exception($"{type} has not been cached"); + throw new InvalidOperationException($"{type} has not been cached"); + } return true; }); AddAssert("check OsuGameBase DI members", () => { foreach (var type in requiredGameBaseDependencies) + { if (gameBase.Dependencies.Get(type) == null) - throw new Exception($"{type} has not been cached"); + throw new InvalidOperationException($"{type} has not been cached"); + } return true; }); diff --git a/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs b/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs index a68fd0ef40..c55988d1bb 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs +++ b/osu.Game.Tests/Visual/TestSceneOsuScreenStack.cs @@ -42,7 +42,7 @@ namespace osu.Game.Tests.Visual AddAssert("Parallax is off", () => stack.ParallaxAmount == 0); } - private class TestScreen : ScreenWithBeatmapBackground + public class TestScreen : ScreenWithBeatmapBackground { private readonly string screenText; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs index 38a9af05d8..2440911c11 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBackButton.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -14,14 +12,10 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneBackButton : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(TwoLayerButton) - }; - public TestSceneBackButton() { BackButton button; + BackButton.Receptor receptor = new BackButton.Receptor(); Child = new Container { @@ -31,12 +25,13 @@ namespace osu.Game.Tests.Visual.UserInterface Masking = true, Children = new Drawable[] { + receptor, new Box { RelativeSizeAxes = Axes.Both, Colour = Color4.SlateGray }, - button = new BackButton + button = new BackButton(receptor) { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs index 28f0cc027e..82b7e65c4f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs @@ -2,13 +2,15 @@ // 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.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Lists; using osu.Framework.Timing; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; @@ -22,30 +24,32 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneBeatSyncedContainer : OsuTestScene { - private readonly MusicController mc; + private readonly NowPlayingOverlay np; public TestSceneBeatSyncedContainer() { Clock = new FramedClock(); Clock.ProcessFrame(); - Add(new BeatContainer + AddRange(new Drawable[] { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - }); - - Add(mc = new MusicController - { - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, + new BeatContainer + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + np = new NowPlayingOverlay + { + Origin = Anchor.TopRight, + Anchor = Anchor.TopRight, + } }); } protected override void LoadComplete() { base.LoadComplete(); - mc.ToggleVisibility(); + np.ToggleVisibility(); } private class BeatContainer : BeatSyncedContainer @@ -63,6 +67,9 @@ namespace osu.Game.Tests.Visual.UserInterface private readonly Box flashLayer; + [Resolved] + private MusicController musicController { get; set; } + public BeatContainer() { RelativeSizeAxes = Axes.X; @@ -140,22 +147,24 @@ namespace osu.Game.Tests.Visual.UserInterface }; } - private SortedList timingPoints => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints; + private List timingPoints => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ToList(); private TimingControlPoint getNextTimingPoint(TimingControlPoint current) { - if (timingPoints[timingPoints.Count - 1] == current) + if (timingPoints[^1] == current) return current; - return timingPoints[timingPoints.IndexOf(current) + 1]; + int index = timingPoints.IndexOf(current); // -1 means that this is a "default beat" + + return index == -1 ? current : timingPoints[index + 1]; } private int calculateBeatCount(TimingControlPoint current) { if (timingPoints.Count == 0) return 0; - if (timingPoints[timingPoints.Count - 1] == current) - return (int)Math.Ceiling((Beatmap.Value.Track.Length - current.Time) / current.BeatLength); + if (timingPoints[^1] == current) + return (int)Math.Ceiling((musicController.CurrentTrack.Length - current.Time) / current.BeatLength); return (int)Math.Ceiling((getNextTimingPoint(current).Time - current.Time) / current.BeatLength); } @@ -167,7 +176,7 @@ namespace osu.Game.Tests.Visual.UserInterface timeSinceLastBeat.Value = TimeSinceLastBeat; } - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs new file mode 100644 index 0000000000..3f757031f8 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.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.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneBeatmapListingSearchControl : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly BeatmapListingSearchControl control; + + public TestSceneBeatmapListingSearchControl() + { + OsuSpriteText query; + OsuSpriteText ruleset; + OsuSpriteText category; + OsuSpriteText genre; + OsuSpriteText language; + OsuSpriteText extra; + OsuSpriteText ranks; + OsuSpriteText played; + + Add(control = new BeatmapListingSearchControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + query = new OsuSpriteText(), + ruleset = new OsuSpriteText(), + category = new OsuSpriteText(), + genre = new OsuSpriteText(), + language = new OsuSpriteText(), + extra = new OsuSpriteText(), + ranks = new OsuSpriteText(), + played = new OsuSpriteText() + } + }); + + control.Query.BindValueChanged(q => query.Text = $"Query: {q.NewValue}", 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); + control.Language.BindValueChanged(l => language.Text = $"Language: {l.NewValue}", true); + control.Extra.BindCollectionChanged((u, v) => extra.Text = $"Extra: {(control.Extra.Any() ? string.Join('.', control.Extra.Select(i => i.ToString().ToLowerInvariant())) : "")}", true); + control.Ranks.BindCollectionChanged((u, v) => ranks.Text = $"Ranks: {(control.Ranks.Any() ? string.Join('.', control.Ranks.Select(i => i.ToString())) : "")}", true); + control.Played.BindValueChanged(p => played.Text = $"Played: {p.NewValue}", true); + } + + [Test] + public void TestCovers() + { + AddStep("Set beatmap", () => control.BeatmapSet = beatmap_set); + AddStep("Set beatmap (no cover)", () => control.BeatmapSet = no_cover_beatmap_set); + AddStep("Set null beatmap", () => control.BeatmapSet = null); + } + + private static readonly BeatmapSetInfo beatmap_set = new BeatmapSetInfo + { + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = "https://assets.ppy.sh/beatmaps/1094296/covers/cover@2x.jpg?1581416305" + } + } + }; + + private static readonly BeatmapSetInfo no_cover_beatmap_set = new BeatmapSetInfo + { + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = string.Empty + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.cs new file mode 100644 index 0000000000..5364f0bef5 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSortTabControl.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneBeatmapListingSortTabControl : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + public TestSceneBeatmapListingSortTabControl() + { + BeatmapListingSortTabControl control; + OsuSpriteText current; + OsuSpriteText direction; + + Add(control = new BeatmapListingSortTabControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + current = new OsuSpriteText(), + direction = new OsuSpriteText() + } + }); + + control.SortDirection.BindValueChanged(sortDirection => direction.Text = $"Sort direction: {sortDirection.NewValue}", true); + control.Current.BindValueChanged(criteria => current.Text = $"Criteria: {criteria.NewValue}", true); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapSearchFilter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapSearchFilter.cs new file mode 100644 index 0000000000..37b7b64615 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapSearchFilter.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Overlays.BeatmapListing; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneBeatmapSearchFilter : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly ReverseChildIDFillFlowContainer resizableContainer; + + public TestSceneBeatmapSearchFilter() + { + Add(resizableContainer = new ReverseChildIDFillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new BeatmapSearchRulesetFilterRow(), + new BeatmapSearchFilterRow("Categories"), + new BeatmapSearchFilterRow("Header Name") + } + }); + } + + [Test] + public void TestResize() + { + AddStep("Resize to 0.3", () => resizableContainer.ResizeWidthTo(0.3f, 1000)); + AddStep("Resize to 1", () => resizableContainer.ResizeWidthTo(1, 1000)); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbs.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs similarity index 54% rename from osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbs.cs rename to osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs index 554696765e..3967b62c95 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbs.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBreadcrumbControl.cs @@ -1,22 +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.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneBreadcrumbs : OsuTestScene + public class TestSceneBreadcrumbControl : OsuTestScene { - private readonly BreadcrumbControl breadcrumbs; + private readonly TestBreadcrumbControl breadcrumbs; - public TestSceneBreadcrumbs() + public TestSceneBreadcrumbControl() { - Add(breadcrumbs = new BreadcrumbControl + Add(breadcrumbs = new TestBreadcrumbControl { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -25,8 +27,13 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddStep(@"first", () => breadcrumbs.Current.Value = BreadcrumbTab.Click); + assertVisible(1); + AddStep(@"second", () => breadcrumbs.Current.Value = BreadcrumbTab.The); + assertVisible(2); + AddStep(@"third", () => breadcrumbs.Current.Value = BreadcrumbTab.Circles); + assertVisible(3); } [BackgroundDependencyLoader] @@ -35,11 +42,27 @@ namespace osu.Game.Tests.Visual.UserInterface breadcrumbs.StripColour = colours.Blue; } + private void assertVisible(int count) => AddAssert($"first {count} item(s) visible", () => + { + for (int i = 0; i < count; i++) + { + if (breadcrumbs.GetDrawable((BreadcrumbTab)i).State != Visibility.Visible) + return false; + } + + return true; + }); + private enum BreadcrumbTab { Click, The, Circles, } + + private class TestBreadcrumbControl : BreadcrumbControl + { + public BreadcrumbTabItem GetDrawable(BreadcrumbTab tab) => (BreadcrumbTabItem)TabContainer.First(t => t.Value == tab); + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs index f0e1c38525..1bb5cadc6a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneButtonSystem.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -17,13 +16,6 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneButtonSystem : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ButtonSystem), - typeof(ButtonArea), - typeof(Button) - }; - private OsuLogo logo; private ButtonSystem buttons; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs new file mode 100644 index 0000000000..920b437f57 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Overlays.Comments; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneCommentEditor : OsuManualInputManagerTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private TestCommentEditor commentEditor; + private TestCancellableCommentEditor cancellableCommentEditor; + + [SetUp] + public void SetUp() => Schedule(() => + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + Width = 800, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + commentEditor = new TestCommentEditor(), + cancellableCommentEditor = new TestCancellableCommentEditor() + } + })); + + [Test] + public void TestCommitViaKeyboard() + { + AddStep("click on text box", () => + { + InputManager.MoveMouseTo(commentEditor); + InputManager.Click(MouseButton.Left); + }); + AddStep("enter text", () => commentEditor.Current.Value = "text"); + + AddStep("press Enter", () => InputManager.Key(Key.Enter)); + + AddAssert("text committed", () => commentEditor.CommittedText == "text"); + AddAssert("button is loading", () => commentEditor.IsLoading); + } + + [Test] + public void TestCommitViaKeyboardWhenEmpty() + { + AddStep("click on text box", () => + { + InputManager.MoveMouseTo(commentEditor); + InputManager.Click(MouseButton.Left); + }); + + AddStep("press Enter", () => InputManager.Key(Key.Enter)); + + AddAssert("no text committed", () => commentEditor.CommittedText == null); + AddAssert("button is not loading", () => !commentEditor.IsLoading); + } + + [Test] + public void TestCommitViaButton() + { + AddStep("click on text box", () => + { + InputManager.MoveMouseTo(commentEditor); + InputManager.Click(MouseButton.Left); + }); + AddStep("enter text", () => commentEditor.Current.Value = "some other text"); + + AddStep("click submit", () => + { + InputManager.MoveMouseTo(commentEditor.ButtonsContainer); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("text committed", () => commentEditor.CommittedText == "some other text"); + AddAssert("button is loading", () => commentEditor.IsLoading); + } + + [Test] + public void TestCancelAction() + { + AddStep("click cancel button", () => + { + InputManager.MoveMouseTo(cancellableCommentEditor.ButtonsContainer); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("cancel action fired", () => cancellableCommentEditor.Cancelled); + } + + private class TestCommentEditor : CommentEditor + { + public new Bindable Current => base.Current; + public new FillFlowContainer ButtonsContainer => base.ButtonsContainer; + + public string CommittedText { get; private set; } + + public TestCommentEditor() + { + OnCommit = onCommit; + } + + private void onCommit(string value) + { + CommittedText = value; + Scheduler.AddDelayed(() => IsLoading = false, 1000); + } + + protected override string FooterText => @"Footer text. And it is pretty long. Cool."; + protected override string CommitButtonText => @"Commit"; + protected override string TextBoxPlaceholder => @"This text box is empty"; + } + + private class TestCancellableCommentEditor : CancellableCommentEditor + { + public new FillFlowContainer ButtonsContainer => base.ButtonsContainer; + protected override string FooterText => @"Wow, another one. Sicc"; + + public bool Cancelled { get; private set; } + + public TestCancellableCommentEditor() + { + OnCancel = () => Cancelled = true; + } + + protected override string CommitButtonText => @"Save"; + protected override string TextBoxPlaceholder => @"Multiline textboxes soon"; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.cs new file mode 100644 index 0000000000..c2ac5179c9 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentRepliesButton.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 osu.Game.Overlays.Comments.Buttons; +using osu.Framework.Graphics; +using osu.Framework.Allocation; +using osu.Game.Overlays; +using osu.Framework.Graphics.Containers; +using osuTK; +using NUnit.Framework; +using System.Linq; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneCommentRepliesButton : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private readonly TestButton button; + + public TestSceneCommentRepliesButton() + { + Child = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + button = new TestButton(), + new LoadRepliesButton + { + Action = () => { } + }, + new ShowRepliesButton(1), + new ShowRepliesButton(2) + } + }; + } + + [Test] + public void TestArrowDirection() + { + AddStep("Set upwards", () => button.SetIconDirection(true)); + AddAssert("Icon facing upwards", () => button.Icon.Scale.Y == -1); + AddStep("Set downwards", () => button.SetIconDirection(false)); + AddAssert("Icon facing downwards", () => button.Icon.Scale.Y == 1); + } + + private class TestButton : CommentRepliesButton + { + public SpriteIcon Icon => this.ChildrenOfType().First(); + + public TestButton() + { + Text = "sample text"; + } + + public new void SetIconDirection(bool upwards) => base.SetIconDirection(upwards); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs index 23d9112b25..5b74852259 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCursors.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; using osuTK; @@ -17,7 +17,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneCursors : ManualInputManagerTestScene + public class TestSceneCursors : OsuManualInputManagerTestScene { private readonly MenuCursorContainer menuCursorContainer; private readonly CustomCursorBox[] cursorBoxes = new CustomCursorBox[6]; @@ -249,7 +249,7 @@ namespace osu.Game.Tests.Visual.UserInterface Size = new Vector2(50); Masking = true; - Blending = BlendingMode.Additive; + Blending = BlendingParameters.Additive; Alpha = 0.5f; Child = new Box { RelativeSizeAxes = Axes.Both }; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs new file mode 100644 index 0000000000..c51204eaba --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs @@ -0,0 +1,150 @@ +// 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.Framework.Graphics; +using osu.Game.Overlays.Dashboard.Home; +using osu.Game.Beatmaps; +using osu.Game.Overlays; +using osu.Framework.Allocation; +using osu.Game.Users; +using System; +using osu.Framework.Graphics.Shapes; +using System.Collections.Generic; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneDashboardBeatmapListing : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private readonly Container content; + + public TestSceneDashboardBeatmapListing() + { + Add(content = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + Width = 300, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4 + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10 }, + Child = new DashboardBeatmapListing(new_beatmaps, popular_beatmaps) + } + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddStep("Set width to 500", () => content.ResizeWidthTo(500, 500)); + AddStep("Set width to 300", () => content.ResizeWidthTo(300, 500)); + } + + private static readonly List new_beatmaps = new List + { + new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + Title = "Very Long Title (TV size) [TATOE]", + Artist = "This artist has a really long name how is this possible", + Author = new User + { + Username = "author", + Id = 100 + } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = "https://assets.ppy.sh/beatmaps/1189904/covers/cover.jpg?1595456608", + }, + Ranked = DateTimeOffset.Now + } + }, + new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + Title = "Very Long Title (TV size) [TATOE]", + Artist = "This artist has a really long name how is this possible", + Author = new User + { + Username = "author", + Id = 100 + } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = "https://assets.ppy.sh/beatmaps/1189904/covers/cover.jpg?1595456608", + }, + Ranked = DateTimeOffset.MinValue + } + } + }; + + private static readonly List popular_beatmaps = new List + { + new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + Title = "Title", + Artist = "Artist", + Author = new User + { + Username = "author", + Id = 100 + } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = "https://assets.ppy.sh/beatmaps/1079428/covers/cover.jpg?1595295586", + }, + FavouriteCount = 100 + } + }, + new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + Title = "Title 2", + Artist = "Artist 2", + Author = new User + { + Username = "someone", + Id = 100 + } + }, + OnlineInfo = new BeatmapSetOnlineInfo + { + Covers = new BeatmapSetOnlineCovers + { + Cover = "https://assets.ppy.sh/beatmaps/1079428/covers/cover.jpg?1595295586", + }, + FavouriteCount = 10 + } + } + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs new file mode 100644 index 0000000000..e54292f7cc --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -0,0 +1,170 @@ +// 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.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Leaderboards; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Tests.Resources; +using osu.Game.Users; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneDeleteLocalScore : OsuManualInputManagerTestScene + { + private readonly ContextMenuContainer contextMenuContainer; + private readonly BeatmapLeaderboard leaderboard; + + private RulesetStore rulesetStore; + private BeatmapManager beatmapManager; + private ScoreManager scoreManager; + + private readonly List scores = new List(); + private BeatmapInfo beatmap; + + [Cached] + private readonly DialogOverlay dialogOverlay; + + public TestSceneDeleteLocalScore() + { + Children = new Drawable[] + { + contextMenuContainer = new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = leaderboard = new BeatmapLeaderboard + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Size = new Vector2(550f, 450f), + Scope = BeatmapLeaderboardScope.Local, + Beatmap = new BeatmapInfo + { + ID = 1, + Metadata = new BeatmapMetadata + { + ID = 1, + Title = "TestSong", + Artist = "TestArtist", + Author = new User + { + Username = "TestAuthor" + }, + }, + Version = "Insane" + }, + } + }, + dialogOverlay = new DialogOverlay() + }; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + 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(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory)); + + beatmap = beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Result.Beatmaps[0]; + + for (int i = 0; i < 50; i++) + { + var score = new ScoreInfo + { + OnlineScoreID = i, + Beatmap = beatmap, + BeatmapInfoID = beatmap.ID, + Accuracy = RNG.NextDouble(), + TotalScore = RNG.Next(1, 1000000), + MaxCombo = RNG.Next(1, 1000), + Rank = ScoreRank.XH, + User = new User { Username = "TestUser" }, + }; + + scores.Add(scoreManager.Import(score).Result); + } + + scores.Sort(Comparer.Create((s1, s2) => s2.TotalScore.CompareTo(s1.TotalScore))); + + return dependencies; + } + + [SetUp] + public void Setup() => Schedule(() => + { + // Due to soft deletions, we can re-use deleted scores between test runs + scoreManager.Undelete(scoreManager.QueryScores(s => s.DeletePending).ToList()); + + leaderboard.Scores = null; + leaderboard.FinishTransforms(true); // After setting scores, we may be waiting for transforms to expire drawables + + leaderboard.Beatmap = beatmap; + leaderboard.RefreshScores(); // Required in the case that the beatmap hasn't changed + }); + + [SetUpSteps] + public void SetupSteps() + { + // Ensure the leaderboard has finished async-loading drawables + AddUntilStep("wait for drawables", () => leaderboard.ChildrenOfType().Any()); + + // Ensure the leaderboard items have finished showing up + AddStep("finish transforms", () => leaderboard.FinishTransforms(true)); + } + + [Test] + public void TestDeleteViaRightClick() + { + AddStep("open menu for top score", () => + { + InputManager.MoveMouseTo(leaderboard.ChildrenOfType().First()); + InputManager.Click(MouseButton.Right); + }); + + // Ensure the context menu has finished showing + AddStep("finish transforms", () => contextMenuContainer.FinishTransforms(true)); + + AddStep("click delete option", () => + { + InputManager.MoveMouseTo(contextMenuContainer.ChildrenOfType().First(i => i.Item.Text.Value.ToLowerInvariant() == "delete")); + InputManager.Click(MouseButton.Left); + }); + + // Ensure the dialog has finished showing + AddStep("finish transforms", () => dialogOverlay.FinishTransforms(true)); + + AddStep("click delete button", () => + { + InputManager.MoveMouseTo(dialogOverlay.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scores[0].OnlineScoreID)); + } + + [Test] + public void TestDeleteViaDatabase() + { + AddStep("delete top score", () => scoreManager.Delete(scores[0])); + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scores[0].OnlineScoreID)); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs index a6ff3462d4..cc4a57fb83 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDialogOverlay.cs @@ -17,7 +17,7 @@ namespace osu.Game.Tests.Visual.UserInterface Add(overlay = new DialogOverlay()); - AddStep("dialog #1", () => overlay.Push(new PopupDialog + AddStep("dialog #1", () => overlay.Push(new TestPopupDialog { Icon = FontAwesome.Regular.TrashAlt, HeaderText = @"Confirm deletion of", @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Visual.UserInterface }, })); - AddStep("dialog #2", () => overlay.Push(new PopupDialog + AddStep("dialog #2", () => overlay.Push(new TestPopupDialog { Icon = FontAwesome.Solid.Cog, HeaderText = @"What do you want to do with", @@ -71,5 +71,9 @@ namespace osu.Game.Tests.Visual.UserInterface }, })); } + + private class TestPopupDialog : PopupDialog + { + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.cs new file mode 100644 index 0000000000..1e3b1c2ffd --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFooterButtonMods.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 System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Select; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneFooterButtonMods : OsuTestScene + { + private readonly TestFooterButtonMods footerButtonMods; + + public TestSceneFooterButtonMods() + { + Add(footerButtonMods = new TestFooterButtonMods()); + } + + [Test] + public void TestIncrementMultiplier() + { + var hiddenMod = new Mod[] { new OsuModHidden() }; + AddStep(@"Add Hidden", () => changeMods(hiddenMod)); + AddAssert(@"Check Hidden multiplier", () => assertModsMultiplier(hiddenMod)); + + var hardRockMod = new Mod[] { new OsuModHardRock() }; + AddStep(@"Add HardRock", () => changeMods(hardRockMod)); + AddAssert(@"Check HardRock multiplier", () => assertModsMultiplier(hardRockMod)); + + var doubleTimeMod = new Mod[] { new OsuModDoubleTime() }; + AddStep(@"Add DoubleTime", () => changeMods(doubleTimeMod)); + AddAssert(@"Check DoubleTime multiplier", () => assertModsMultiplier(doubleTimeMod)); + + var mutlipleIncrementMods = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModHardRock() }; + AddStep(@"Add multiple Mods", () => changeMods(mutlipleIncrementMods)); + AddAssert(@"Check multiple mod multiplier", () => assertModsMultiplier(mutlipleIncrementMods)); + } + + [Test] + public void TestDecrementMultiplier() + { + var easyMod = new Mod[] { new OsuModEasy() }; + AddStep(@"Add Easy", () => changeMods(easyMod)); + AddAssert(@"Check Easy multiplier", () => assertModsMultiplier(easyMod)); + + var noFailMod = new Mod[] { new OsuModNoFail() }; + AddStep(@"Add NoFail", () => changeMods(noFailMod)); + AddAssert(@"Check NoFail multiplier", () => assertModsMultiplier(noFailMod)); + + var multipleDecrementMods = new Mod[] { new OsuModEasy(), new OsuModNoFail() }; + AddStep(@"Add Multiple Mods", () => changeMods(multipleDecrementMods)); + AddAssert(@"Check multiple mod multiplier", () => assertModsMultiplier(multipleDecrementMods)); + } + + [Test] + public void TestClearMultiplier() + { + var multipleMods = new Mod[] { new OsuModDoubleTime(), new OsuModFlashlight() }; + AddStep(@"Add mods", () => changeMods(multipleMods)); + AddStep(@"Clear selected mod", () => changeMods(Array.Empty())); + AddAssert(@"Check empty multiplier", () => assertModsMultiplier(Array.Empty())); + } + + private void changeMods(IReadOnlyList mods) + { + footerButtonMods.Current.Value = mods; + } + + private bool assertModsMultiplier(IEnumerable mods) + { + 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; + } + + private class TestFooterButtonMods : FooterButtonMods + { + public new OsuSpriteText MultiplierText => base.MultiplierText; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.cs new file mode 100644 index 0000000000..9fa5c83dba --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFriendsOnlineStatusControl.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 System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneFriendsOnlineStatusControl : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private FriendOnlineStreamControl control; + + [SetUp] + public void SetUp() => Schedule(() => Child = control = new FriendOnlineStreamControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + [Test] + public void Populate() + { + AddStep("Populate", () => control.Populate(new List + { + new User + { + IsOnline = true + }, + new User + { + IsOnline = false + }, + new User + { + IsOnline = false + } + })); + + AddAssert("3 users", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.All)?.Count == 3); + AddAssert("1 online user", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.Online)?.Count == 1); + AddAssert("2 offline users", () => control.Items.FirstOrDefault(item => item.Status == OnlineStatus.Offline)?.Count == 2); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs index 7e6cf1285e..cea91d422e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneHoldToConfirmOverlay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -12,7 +10,7 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneHoldToConfirmOverlay : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(ExitConfirmOverlay) }; + protected override double TimePerAction => 100; // required for the early exit test, since hold-to-confirm delay is 200ms public TestSceneHoldToConfirmOverlay() { @@ -45,19 +43,23 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("start confirming", () => overlay.Begin()); AddStep("abort confirming", () => overlay.Abort()); + AddAssert("ensure not fired internally", () => !overlay.Fired); AddAssert("ensure aborted", () => !fired); AddStep("start confirming", () => overlay.Begin()); AddUntilStep("wait until confirmed", () => fired); + AddAssert("ensure fired internally", () => overlay.Fired); + + AddStep("abort after fire", () => overlay.Abort()); + AddAssert("ensure not fired internally", () => !overlay.Fired); + AddStep("start confirming", () => overlay.Begin()); + AddUntilStep("wait until fired again", () => overlay.Fired); } private class TestHoldToConfirmOverlay : ExitConfirmOverlay { - protected override bool AllowMultipleFires => true; - public void Begin() => BeginConfirm(); - public void Abort() => AbortConfirm(); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.cs new file mode 100644 index 0000000000..8179f92ffc --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneLabelledDrawable : OsuTestScene + { + [TestCase(false)] + [TestCase(true)] + public void TestPadded(bool hasDescription) => createPaddedComponent(hasDescription); + + [TestCase(false)] + [TestCase(true)] + public void TestNonPadded(bool hasDescription) => createPaddedComponent(hasDescription, false); + + private void createPaddedComponent(bool hasDescription = false, bool padded = true) + { + AddStep("create component", () => + { + LabelledDrawable component; + + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + AutoSizeAxes = Axes.Y, + Child = component = padded ? (LabelledDrawable)new PaddedLabelledDrawable() : new NonPaddedLabelledDrawable(), + }; + + component.Label = "a sample component"; + component.Description = hasDescription ? "this text describes the component" : string.Empty; + }); + } + + private class PaddedLabelledDrawable : LabelledDrawable + { + public PaddedLabelledDrawable() + : base(true) + { + } + + protected override Drawable CreateComponent() => new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.Red, + Text = @"(( Component ))" + }; + } + + private class NonPaddedLabelledDrawable : LabelledDrawable + { + public NonPaddedLabelledDrawable() + : base(false) + { + } + + protected override Drawable CreateComponent() => new Container + { + RelativeSizeAxes = Axes.X, + Height = 40, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.SlateGray + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.Red, + Text = @"(( Component ))" + } + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs new file mode 100644 index 0000000000..393420e700 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.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.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneLabelledSliderBar : OsuTestScene + { + [TestCase(false)] + [TestCase(true)] + public void TestSliderBar(bool hasDescription) => createSliderBar(hasDescription); + + private void createSliderBar(bool hasDescription = false) + { + AddStep("create component", () => + { + LabelledSliderBar component; + + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + AutoSizeAxes = Axes.Y, + Child = component = new LabelledSliderBar + { + Current = new BindableDouble(5) + { + MinValue = 0, + MaxValue = 10, + Precision = 1, + } + } + }; + + component.Label = "a sample component"; + component.Description = hasDescription ? "this text describes the component" : string.Empty; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.cs new file mode 100644 index 0000000000..903f1242b4 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSwitchButton.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneLabelledSwitchButton : OsuTestScene + { + [TestCase(false)] + [TestCase(true)] + public void TestSwitchButton(bool hasDescription) => createSwitchButton(hasDescription); + + private void createSwitchButton(bool hasDescription = false) + { + AddStep("create component", () => + { + LabelledSwitchButton component; + + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + AutoSizeAxes = Axes.Y, + Child = component = new LabelledSwitchButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + + component.Label = "a sample component"; + component.Description = hasDescription ? "this text describes the component" : string.Empty; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs index 395905a30d..c11ba0aa59 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledTextBox.cs @@ -1,23 +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 System.Collections.Generic; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Screens.Edit.Setup.Components.LabelledComponents; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] public class TestSceneLabelledTextBox : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] + [TestCase(false)] + [TestCase(true)] + public void TestTextBox(bool hasDescription) => createTextBox(hasDescription); + + private void createTextBox(bool hasDescription = false) { - typeof(LabelledTextBox), - }; + AddStep("create component", () => + { + LabelledTextBox component; + + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + AutoSizeAxes = Axes.Y, + Child = component = new LabelledTextBox + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Label = "Testing text", + PlaceholderText = "This is definitely working as intended", + } + }; + + component.Label = "a sample component"; + component.Description = hasDescription ? "this text describes the component" : string.Empty; + }); + } [BackgroundDependencyLoader] private void load() @@ -32,7 +55,7 @@ namespace osu.Game.Tests.Visual.UserInterface { Anchor = Anchor.Centre, Origin = Anchor.Centre, - LabelText = "Testing text", + Label = "Testing text", PlaceholderText = "This is definitely working as intended", } }; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.cs new file mode 100644 index 0000000000..1be191fc29 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingLayer.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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneLoadingLayer : OsuTestScene + { + private Drawable dimContent; + private LoadingLayer overlay; + + private Container content; + + [SetUp] + public void SetUp() => Schedule(() => + { + Children = new[] + { + content = new Container + { + Size = new Vector2(300), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new[] + { + new Box + { + Colour = Color4.SlateGray, + RelativeSizeAxes = Axes.Both, + }, + dimContent = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.9f), + Children = new Drawable[] + { + new OsuSpriteText { Text = "Sample content" }, + new TriangleButton { Text = "can't puush me", Width = 200, }, + new TriangleButton { Text = "puush me", Width = 200, Action = () => { } }, + } + }, + overlay = new LoadingLayer(dimContent), + } + }, + }; + }); + + [Test] + public void TestShowHide() + { + AddAssert("not visible", () => !overlay.IsPresent); + + AddStep("show", () => overlay.Show()); + + AddUntilStep("wait for content dim", () => dimContent.Colour != Color4.White); + + AddStep("hide", () => overlay.Hide()); + + AddUntilStep("wait for content restore", () => dimContent.Colour == Color4.White); + } + + [Test] + public void TestContentRestoreOnDispose() + { + AddAssert("not visible", () => !overlay.IsPresent); + + AddStep("show", () => overlay.Show()); + + AddUntilStep("wait for content dim", () => dimContent.Colour != Color4.White); + + AddStep("expire", () => overlay.Expire()); + + AddUntilStep("wait for content restore", () => dimContent.Colour == Color4.White); + } + + [Test] + public void TestLargeArea() + { + AddStep("show", () => + { + content.RelativeSizeAxes = Axes.Both; + content.Size = new Vector2(1); + + overlay.Show(); + }); + + AddStep("hide", () => overlay.Hide()); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingAnimation.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs similarity index 79% rename from osu.Game.Tests/Visual/UserInterface/TestSceneLoadingAnimation.cs rename to osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs index b0233d35f9..47f5bdfe17 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingAnimation.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLoadingSpinner.cs @@ -8,12 +8,12 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLoadingAnimation : OsuGridTestScene + public class TestSceneLoadingSpinner : OsuGridTestScene { - public TestSceneLoadingAnimation() + public TestSceneLoadingSpinner() : base(2, 2) { - LoadingAnimation loading; + LoadingSpinner loading; Cell(0).AddRange(new Drawable[] { @@ -22,7 +22,7 @@ namespace osu.Game.Tests.Visual.UserInterface Colour = Color4.Black, RelativeSizeAxes = Axes.Both }, - loading = new LoadingAnimation() + loading = new LoadingSpinner() }); loading.Show(); @@ -34,7 +34,7 @@ namespace osu.Game.Tests.Visual.UserInterface Colour = Color4.White, RelativeSizeAxes = Axes.Both }, - loading = new LoadingAnimation() + loading = new LoadingSpinner(true) }); loading.Show(); @@ -46,14 +46,14 @@ namespace osu.Game.Tests.Visual.UserInterface Colour = Color4.Gray, RelativeSizeAxes = Axes.Both }, - loading = new LoadingAnimation() + loading = new LoadingSpinner() }); loading.Show(); Cell(3).AddRange(new Drawable[] { - loading = new LoadingAnimation() + loading = new LoadingSpinner() }); Scheduler.AddDelayed(() => loading.ToggleVisibility(), 200, true); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoAnimation.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoAnimation.cs new file mode 100644 index 0000000000..155d043bf9 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoAnimation.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.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneLogoAnimation : OsuTestScene + { + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + LogoAnimation anim2; + + Add(anim2 = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get("Intro/Triangles/logo-highlight"), + Colour = Colour4.White, + }); + + LogoAnimation anim; + + Add(anim = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get("Intro/Triangles/logo-background"), + Colour = OsuColour.Gray(0.6f), + }); + + AddSliderStep("Progress", 0f, 1f, 0f, newValue => + { + anim2.AnimationProgress = newValue; + anim.AnimationProgress = newValue; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs index 54876dbbda..5582cc6826 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLogoTrackingContainer.cs @@ -2,17 +2,15 @@ // 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.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Testing; using osu.Game.Graphics.Containers; using osu.Game.Screens.Menu; -using osu.Game.Screens.Play; using osuTK; using osuTK.Graphics; @@ -20,17 +18,6 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneLogoTrackingContainer : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(PlayerLoader), - typeof(Player), - typeof(LogoTrackingContainer), - typeof(ButtonSystem), - typeof(ButtonSystemState), - typeof(Menu), - typeof(MainMenu) - }; - private OsuLogo logo; private TestLogoTrackingContainer trackingContainer; private Container transferContainer; @@ -277,7 +264,7 @@ namespace osu.Game.Tests.Visual.UserInterface private void moveLogoFacade() { - if (logoFacade?.Transforms.Count == 0 && transferContainer?.Transforms.Count == 0) + 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 new file mode 100644 index 0000000000..443cf59003 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModButton.cs @@ -0,0 +1,62 @@ +// 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.Overlays.Mods; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModButton : OsuTestScene + { + public TestSceneModButton() + { + Children = new Drawable[] + { + new ModButton(new MultiMod(new TestMod1(), new TestMod2(), new TestMod3(), new TestMod4())) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }; + } + + private class TestMod1 : TestMod + { + public override string Name => "Test mod 1"; + + public override string Acronym => "M1"; + } + + private class TestMod2 : TestMod + { + public override string Name => "Test mod 2"; + + public override string Acronym => "M2"; + + public override IconUsage? Icon => FontAwesome.Solid.Exclamation; + } + + private class TestMod3 : TestMod + { + public override string Name => "Test mod 3"; + + public override string Acronym => "M3"; + + public override IconUsage? Icon => FontAwesome.Solid.ArrowRight; + } + + private class TestMod4 : TestMod + { + public override string Name => "Test mod 4"; + + public override string Acronym => "M4"; + } + + private abstract class TestMod : Mod, IApplicableMod + { + public override double ScoreMultiplier => 1.0; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs new file mode 100644 index 0000000000..8168faa106 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs @@ -0,0 +1,40 @@ +// 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.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModDisplay : OsuTestScene + { + [TestCase(ExpansionMode.ExpandOnHover)] + [TestCase(ExpansionMode.AlwaysExpanded)] + [TestCase(ExpansionMode.AlwaysContracted)] + public void TestMode(ExpansionMode mode) + { + AddStep("create mod display", () => + { + Child = new ModDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + ExpansionMode = mode, + Current = + { + Value = new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime(), + new OsuModDifficultyAdjust(), + new OsuModEasy(), + } + } + }; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 80408ab43b..6f083f4ab6 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -8,15 +8,16 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Mods; -using osu.Game.Overlays.Mods.Sections; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mania.Mods; 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.HUD; using osuTK; using osuTK.Graphics; @@ -26,20 +27,6 @@ namespace osu.Game.Tests.Visual.UserInterface [Description("mod select and icon display")] public class TestSceneModSelectOverlay : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ModDisplay), - typeof(ModSection), - typeof(ModIcon), - typeof(ModButton), - typeof(ModButtonEmpty), - typeof(DifficultyReductionSection), - typeof(DifficultyIncreaseSection), - typeof(AutomationSection), - typeof(ConversionSection), - typeof(FunSection), - }; - private RulesetStore rulesets; private ModDisplay modDisplay; private TestModSelectOverlay modSelect; @@ -48,48 +35,54 @@ namespace osu.Game.Tests.Visual.UserInterface private void load(RulesetStore rulesets) { this.rulesets = rulesets; + } - Add(modSelect = new TestModSelectOverlay + [SetUp] + public void SetUp() => Schedule(() => + { + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - }); + modSelect = new TestModSelectOverlay + { + Origin = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + SelectedMods = { BindTarget = SelectedMods } + }, - Add(modDisplay = new ModDisplay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Position = new Vector2(0, 25), - }); + modDisplay = new ModDisplay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Position = new Vector2(-5, 25), + Current = { BindTarget = modSelect.SelectedMods } + } + }; + }); - modDisplay.Current.UnbindBindings(); - modDisplay.Current.BindTo(modSelect.SelectedMods); - - AddStep("Show", modSelect.Show); - AddStep("Toggle", modSelect.ToggleVisibility); - AddStep("Toggle", modSelect.ToggleVisibility); + [SetUpSteps] + public void SetUpSteps() + { + AddStep("show", () => modSelect.Show()); } [Test] public void TestOsuMods() { - var ruleset = rulesets.AvailableRulesets.First(r => r.ID == 0); - changeRuleset(ruleset); + changeRuleset(0); - var instance = ruleset.CreateInstance(); + var osu = new OsuRuleset(); - var easierMods = instance.GetModsFor(ModType.DifficultyReduction); - var harderMods = instance.GetModsFor(ModType.DifficultyIncrease); - var assistMods = instance.GetModsFor(ModType.Automation); + var easierMods = osu.GetModsFor(ModType.DifficultyReduction); + var harderMods = osu.GetModsFor(ModType.DifficultyIncrease); + var conversionMods = osu.GetModsFor(ModType.Conversion); - var noFailMod = easierMods.FirstOrDefault(m => m is OsuModNoFail); + var noFailMod = osu.GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail); var hiddenMod = harderMods.FirstOrDefault(m => m is OsuModHidden); var doubleTimeMod = harderMods.OfType().FirstOrDefault(m => m.Mods.Any(a => a is OsuModDoubleTime)); - var autoPilotMod = assistMods.FirstOrDefault(m => m is OsuModAutopilot); + var targetMod = conversionMods.FirstOrDefault(m => m is OsuModTarget); var easy = easierMods.FirstOrDefault(m => m is OsuModEasy); var hardRock = harderMods.FirstOrDefault(m => m is OsuModHardRock); @@ -98,46 +91,44 @@ namespace osu.Game.Tests.Visual.UserInterface testMultiMod(doubleTimeMod); testIncompatibleMods(easy, hardRock); testDeselectAll(easierMods.Where(m => !(m is MultiMod))); - testMultiplierTextColour(noFailMod, modSelect.LowMultiplierColour); - testMultiplierTextColour(hiddenMod, modSelect.HighMultiplierColour); + testMultiplierTextColour(noFailMod, () => modSelect.LowMultiplierColour); + testMultiplierTextColour(hiddenMod, () => modSelect.HighMultiplierColour); - testUnimplementedMod(autoPilotMod); + testUnimplementedMod(targetMod); } [Test] public void TestManiaMods() { - var ruleset = rulesets.AvailableRulesets.First(r => r.ID == 3); - changeRuleset(ruleset); + changeRuleset(3); - testRankedText(ruleset.CreateInstance().GetModsFor(ModType.Conversion).First(m => m is ManiaModRandom)); + var mania = new ManiaRuleset(); + + testModsWithSameBaseType( + mania.GetAllMods().Single(m => m.GetType() == typeof(ManiaModFadeIn)), + mania.GetAllMods().Single(m => m.GetType() == typeof(ManiaModHidden))); } [Test] public void TestRulesetChanges() { - var rulesetOsu = rulesets.AvailableRulesets.First(r => r.ID == 0); - var rulesetMania = rulesets.AvailableRulesets.First(r => r.ID == 3); + changeRuleset(0); - changeRuleset(null); + var noFailMod = new OsuRuleset().GetModsFor(ModType.DifficultyReduction).FirstOrDefault(m => m is OsuModNoFail); - var instance = rulesetOsu.CreateInstance(); - var easierMods = instance.GetModsFor(ModType.DifficultyReduction); - var noFailMod = easierMods.FirstOrDefault(m => m is OsuModNoFail); + AddStep("set mods externally", () => { SelectedMods.Value = new[] { noFailMod }; }); - AddStep("set mods externally", () => { modDisplay.Current.Value = new[] { noFailMod }; }); - - changeRuleset(rulesetOsu); + changeRuleset(0); AddAssert("ensure mods still selected", () => modDisplay.Current.Value.Single(m => m is OsuModNoFail) != null); - changeRuleset(rulesetMania); + changeRuleset(3); - AddAssert("ensure mods not selected", () => !modDisplay.Current.Value.Any(m => m is OsuModNoFail)); + AddAssert("ensure mods not selected", () => modDisplay.Current.Value.Count == 0); - changeRuleset(rulesetOsu); + changeRuleset(0); - AddAssert("ensure mods not selected", () => !modDisplay.Current.Value.Any()); + AddAssert("ensure mods not selected", () => modDisplay.Current.Value.Count == 0); } private void testSingleMod(Mod mod) @@ -199,28 +190,31 @@ namespace osu.Game.Tests.Visual.UserInterface selectNext(mod); AddAssert("check for any selection", () => modSelect.SelectedMods.Value.Any()); - AddStep("deselect all", modSelect.DeselectAllButton.Action.Invoke); + AddStep("deselect all", () => modSelect.DeselectAllButton.Action.Invoke()); AddAssert("check for no selection", () => !modSelect.SelectedMods.Value.Any()); } - private void testMultiplierTextColour(Mod mod, Color4 colour) + private void testMultiplierTextColour(Mod mod, Func getCorrectColour) { - checkLabelColor(Color4.White); + checkLabelColor(() => Color4.White); selectNext(mod); AddWaitStep("wait for changing colour", 1); - checkLabelColor(colour); + checkLabelColor(getCorrectColour); selectPrevious(mod); AddWaitStep("wait for changing colour", 1); - checkLabelColor(Color4.White); + checkLabelColor(() => Color4.White); } - private void testRankedText(Mod mod) + private void testModsWithSameBaseType(Mod modA, Mod modB) { - AddUntilStep("check for ranked", () => modSelect.UnrankedLabel.Alpha == 0); - selectNext(mod); - AddUntilStep("check for unranked", () => modSelect.UnrankedLabel.Alpha != 0); - selectPrevious(mod); - AddUntilStep("check for ranked", () => modSelect.UnrankedLabel.Alpha == 0); + selectNext(modA); + checkSelected(modA); + selectNext(modB); + checkSelected(modB); + + // Backwards + selectPrevious(modA); + checkSelected(modA); } private void selectNext(Mod mod) => AddStep($"left click {mod.Name}", () => modSelect.GetModButton(mod)?.SelectNext(1)); @@ -236,9 +230,9 @@ namespace osu.Game.Tests.Visual.UserInterface }); } - private void changeRuleset(RulesetInfo ruleset) + private void changeRuleset(int? id) { - AddStep($"change ruleset to {ruleset}", () => { Ruleset.Value = ruleset; }); + AddStep($"change ruleset to {(id?.ToString() ?? "none")}", () => { Ruleset.Value = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == id); }); waitForLoad(); } @@ -254,7 +248,7 @@ namespace osu.Game.Tests.Visual.UserInterface }); } - private void checkLabelColor(Color4 color) => AddAssert("check label has expected colour", () => modSelect.MultiplierLabel.Colour.AverageColour == color); + private void checkLabelColor(Func getColour) => AddAssert("check label has expected colour", () => modSelect.MultiplierLabel.Colour.AverageColour == getColour()); private class TestModSelectOverlay : ModSelectOverlay { @@ -269,7 +263,6 @@ namespace osu.Game.Tests.Visual.UserInterface } public new OsuSpriteText MultiplierLabel => base.MultiplierLabel; - public new OsuSpriteText UnrankedLabel => base.UnrankedLabel; public new TriangleButton DeselectAllButton => base.DeselectAllButton; public new Color4 LowMultiplierColour => base.LowMultiplierColour; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs new file mode 100644 index 0000000000..645b83758c --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs @@ -0,0 +1,244 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.UI; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModSettings : OsuManualInputManagerTestScene + { + private TestModSelectOverlay modSelect; + + private readonly Mod testCustomisableMod = new TestModCustomisable1(); + + private readonly Mod testCustomisableAutoOpenMod = new TestModCustomisable2(); + + [SetUp] + public void SetUp() => Schedule(() => + { + SelectedMods.Value = Array.Empty(); + Ruleset.Value = new TestRulesetInfo(); + }); + + [Test] + public void TestButtonShowsOnCustomisableMod() + { + createModSelect(); + openModSelect(); + + AddAssert("button disabled", () => !modSelect.CustomiseButton.Enabled.Value); + AddUntilStep("wait for button load", () => modSelect.ButtonsLoaded); + AddStep("select mod", () => modSelect.SelectMod(testCustomisableMod)); + AddAssert("button enabled", () => modSelect.CustomiseButton.Enabled.Value); + AddStep("open Customisation", () => modSelect.CustomiseButton.Click()); + AddStep("deselect mod", () => modSelect.SelectMod(testCustomisableMod)); + AddAssert("controls hidden", () => modSelect.ModSettingsContainer.Alpha == 0); + } + + [Test] + public void TestButtonShowsOnModAlreadyAdded() + { + AddStep("set active mods", () => SelectedMods.Value = new List { testCustomisableMod }); + + createModSelect(); + + AddAssert("mods still active", () => SelectedMods.Value.Count == 1); + + openModSelect(); + AddAssert("button enabled", () => modSelect.CustomiseButton.Enabled.Value); + } + + [Test] + public void TestCustomisationMenuVisibility() + { + createModSelect(); + openModSelect(); + + AddAssert("Customisation closed", () => modSelect.ModSettingsContainer.Alpha == 0); + AddStep("select mod", () => modSelect.SelectMod(testCustomisableAutoOpenMod)); + AddAssert("Customisation opened", () => modSelect.ModSettingsContainer.Alpha == 1); + AddStep("deselect mod", () => modSelect.SelectMod(testCustomisableAutoOpenMod)); + AddAssert("Customisation closed", () => modSelect.ModSettingsContainer.Alpha == 0); + } + + [Test] + public void TestModSettingsUnboundWhenCopied() + { + OsuModDoubleTime original = null; + OsuModDoubleTime copy = null; + + AddStep("create mods", () => + { + original = new OsuModDoubleTime(); + copy = (OsuModDoubleTime)original.CreateCopy(); + }); + + AddStep("change property", () => original.SpeedChange.Value = 2); + + AddAssert("original has new value", () => Precision.AlmostEquals(2.0, original.SpeedChange.Value)); + AddAssert("copy has original value", () => Precision.AlmostEquals(1.5, copy.SpeedChange.Value)); + } + + [Test] + public void TestMultiModSettingsUnboundWhenCopied() + { + MultiMod original = null; + MultiMod copy = null; + + AddStep("create mods", () => + { + original = new MultiMod(new OsuModDoubleTime()); + copy = (MultiMod)original.CreateCopy(); + }); + + AddStep("change property", () => ((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value = 2); + + AddAssert("original has new value", () => Precision.AlmostEquals(2.0, ((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value)); + AddAssert("copy has original value", () => Precision.AlmostEquals(1.5, ((OsuModDoubleTime)copy.Mods[0]).SpeedChange.Value)); + } + + [Test] + public void TestCustomisationMenuNoClickthrough() + { + createModSelect(); + openModSelect(); + + AddStep("change mod settings menu width to full screen", () => modSelect.SetModSettingsWidth(1.0f)); + AddStep("select cm2", () => modSelect.SelectMod(testCustomisableAutoOpenMod)); + AddAssert("Customisation opened", () => modSelect.ModSettingsContainer.Alpha == 1); + AddStep("hover over mod behind settings menu", () => InputManager.MoveMouseTo(modSelect.GetModButton(testCustomisableMod))); + AddAssert("Mod is not considered hovered over", () => !modSelect.GetModButton(testCustomisableMod).IsHovered); + AddStep("left click mod", () => InputManager.Click(MouseButton.Left)); + AddAssert("only cm2 is active", () => SelectedMods.Value.Count == 1); + AddStep("right click mod", () => InputManager.Click(MouseButton.Right)); + AddAssert("only cm2 is active", () => SelectedMods.Value.Count == 1); + } + + private void createModSelect() + { + AddStep("create mod select", () => + { + Child = modSelect = new TestModSelectOverlay + { + Origin = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + SelectedMods = { BindTarget = SelectedMods } + }; + }); + } + + private void openModSelect() + { + AddStep("open", () => modSelect.Show()); + AddUntilStep("wait for ready", () => modSelect.State.Value == Visibility.Visible && modSelect.ButtonsLoaded); + } + + private class TestModSelectOverlay : ModSelectOverlay + { + public new Container ModSettingsContainer => base.ModSettingsContainer; + public new TriangleButton CustomiseButton => base.CustomiseButton; + + public bool ButtonsLoaded => ModSectionsContainer.Children.All(c => c.ModIconsLoaded); + + public ModButton GetModButton(Mod mod) + { + return ModSectionsContainer.ChildrenOfType().Single(b => b.Mods.Any(m => m.GetType() == mod.GetType())); + } + + public void SelectMod(Mod mod) => + GetModButton(mod).SelectNext(1); + + public void SetModSettingsWidth(float newWidth) => + ModSettingsContainer.Width = newWidth; + } + + public class TestRulesetInfo : RulesetInfo + { + public override Ruleset CreateInstance() => new TestCustomisableModRuleset(); + + public TestRulesetInfo() + { + Available = true; + } + + public class TestCustomisableModRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) + { + if (type == ModType.Conversion) + { + return new Mod[] + { + new TestModCustomisable1(), + new TestModCustomisable2() + }; + } + + return Array.Empty(); + } + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => throw new NotImplementedException(); + + public override string Description { get; } = "test"; + public override string ShortName { get; } = "tst"; + } + } + + private class TestModCustomisable1 : TestModCustomisable + { + public override string Name => "Customisable Mod 1"; + + public override string Acronym => "CM1"; + } + + private class TestModCustomisable2 : TestModCustomisable + { + public override string Name => "Customisable Mod 2"; + + public override string Acronym => "CM2"; + + public override bool RequiresConfiguration => true; + } + + private abstract class TestModCustomisable : Mod, IApplicableMod + { + public override double ScoreMultiplier => 1.0; + + public override ModType Type => ModType.Conversion; + + [SettingSource("Sample float", "Change something for a mod")] + public BindableFloat SliderBindable { get; } = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Default = 5, + Value = 7 + }; + + [SettingSource("Sample bool", "Clicking this changes a setting")] + public BindableBool TickBindable { get; } = new BindableBool(); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneMusicController.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneMusicController.cs deleted file mode 100644 index ab2ca47100..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneMusicController.cs +++ /dev/null @@ -1,30 +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.Graphics; -using osu.Framework.Timing; -using osu.Game.Overlays; - -namespace osu.Game.Tests.Visual.UserInterface -{ - [TestFixture] - public class TestSceneMusicController : OsuTestScene - { - public TestSceneMusicController() - { - Clock = new FramedClock(); - - var mc = new MusicController - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre - }; - Add(mc); - - AddStep(@"show", () => mc.Show()); - AddToggleStep(@"toggle beatmap lock", state => Beatmap.Disabled = state); - AddStep(@"show", () => mc.Hide()); - } - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index d8a4514df1..43ba23e6c6 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs @@ -1,14 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -18,16 +17,6 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneNotificationOverlay : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(NotificationSection), - typeof(SimpleNotification), - typeof(ProgressNotification), - typeof(ProgressCompletionNotification), - typeof(IHasCompletionTarget), - typeof(Notification) - }; - private NotificationOverlay notificationOverlay; private readonly List progressingNotifications = new List(); @@ -67,9 +56,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddRepeatStep(@"add many simple", sendManyNotifications, 3); - AddWaitStep("wait some", 5); - - checkProgressingCount(0); + waitForCompletion(); AddStep(@"progress #3", sendUploadProgress); @@ -77,9 +64,7 @@ namespace osu.Game.Tests.Visual.UserInterface checkDisplayedCount(33); - AddWaitStep("wait some", 10); - - checkProgressingCount(0); + waitForCompletion(); } [Test] @@ -109,9 +94,9 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep(@"background progress #1", sendBackgroundUploadProgress); - AddWaitStep("wait some", 5); + checkProgressingCount(1); - checkProgressingCount(0); + waitForCompletion(); checkDisplayedCount(2); @@ -190,6 +175,8 @@ namespace osu.Game.Tests.Visual.UserInterface private void checkProgressingCount(int expected) => AddAssert($"progressing count is {expected}", () => progressingNotifications.Count == expected); + private void waitForCompletion() => AddUntilStep("wait for notification progress completion", () => progressingNotifications.Count == 0); + private void sendBarrage() { switch (RNG.Next(0, 4)) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs new file mode 100644 index 0000000000..475ab0c414 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Rulesets.Osu; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneNowPlayingOverlay : OsuTestScene + { + [Cached] + private MusicController musicController = new MusicController(); + + private NowPlayingOverlay nowPlayingOverlay; + + [BackgroundDependencyLoader] + private void load() + { + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + + nowPlayingOverlay = new NowPlayingOverlay + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre + }; + + Add(musicController); + Add(nowPlayingOverlay); + } + + [Test] + public void TestShowHideDisable() + { + AddStep(@"show", () => nowPlayingOverlay.Show()); + AddToggleStep(@"toggle beatmap lock", state => Beatmap.Disabled = state); + AddStep(@"hide", () => nowPlayingOverlay.Hide()); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNumberBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNumberBox.cs deleted file mode 100644 index f73450db60..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNumberBox.cs +++ /dev/null @@ -1,55 +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.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Tests.Visual.UserInterface -{ - [TestFixture] - public class TestSceneNumberBox : OsuTestScene - { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(OsuNumberBox), - }; - - private OsuNumberBox numberBox; - - [BackgroundDependencyLoader] - private void load() - { - Child = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 250 }, - Child = numberBox = new OsuNumberBox - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - PlaceholderText = "Insert numbers here" - } - }; - - clearInput(); - AddStep("enter numbers", () => numberBox.Text = "987654321"); - expectedValue("987654321"); - clearInput(); - AddStep("enter text + single number", () => numberBox.Text = "1 hello 2 world 3"); - expectedValue("123"); - clearInput(); - } - - private void clearInput() => AddStep("clear input", () => numberBox.Text = null); - - private void expectedValue(string value) => AddAssert("expect number", () => numberBox.Text == value); - } -} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs index d900526c07..45720548c8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs @@ -7,6 +7,7 @@ using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Graphics; using osu.Game.Overlays; +using osu.Game.Overlays.OSD; namespace osu.Game.Tests.Visual.UserInterface { @@ -22,6 +23,12 @@ namespace osu.Game.Tests.Visual.UserInterface osd.BeginTracking(this, config); Add(osd); + AddStep("Display empty osd toast", () => osd.Display(new EmptyToast())); + AddAssert("Toast width is 240", () => osd.Child.Width == 240); + + AddStep("Display toast with lengthy text", () => osd.Display(new LengthyToast())); + AddAssert("Toast width is greater than 240", () => osd.Child.Width > 240); + AddRepeatStep("Change toggle (no bind)", () => config.ToggleSetting(TestConfigSetting.ToggleSettingNoKeybind), 2); AddRepeatStep("Change toggle (with bind)", () => config.ToggleSetting(TestConfigSetting.ToggleSettingWithKeybind), 2); AddRepeatStep("Change enum (no bind)", () => config.IncrementEnumSetting(TestConfigSetting.EnumSettingNoKeybind), 3); @@ -86,6 +93,22 @@ namespace osu.Game.Tests.Visual.UserInterface Setting4 } + private class EmptyToast : Toast + { + public EmptyToast() + : base("", "", "") + { + } + } + + private class LengthyToast : Toast + { + public LengthyToast() + : base("Toast with a very very very long text", "A very very very very very very long text also", "A very very very very very long shortcut") + { + } + } + private class TestOnScreenDisplay : OnScreenDisplay { protected override void DisplayTemporarily(Drawable toDisplay) => toDisplay.FadeIn().ResizeHeightTo(110); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs index dbef7d1686..396bec51b6 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuHoverContainer.cs @@ -12,7 +12,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] - public class TestSceneOsuHoverContainer : ManualInputManagerTestScene + public class TestSceneOsuHoverContainer : OsuManualInputManagerTestScene { private OsuHoverTestContainer hoverContainer; private Box colourContainer; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs index 061039b297..c5374d50ab 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.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.Diagnostics; using System.Reflection; using NUnit.Framework; using osu.Framework.Extensions.IEnumerableExtensions; @@ -45,7 +46,12 @@ namespace osu.Game.Tests.Visual.UserInterface }); foreach (var p in typeof(OsuIcon).GetProperties(BindingFlags.Public | BindingFlags.Static)) - flow.Add(new Icon($"{nameof(OsuIcon)}.{p.Name}", (IconUsage)p.GetValue(null))); + { + var propValue = p.GetValue(null); + Debug.Assert(propValue != null); + + flow.Add(new Icon($"{nameof(OsuIcon)}.{p.Name}", (IconUsage)propValue)); + } AddStep("toggle shadows", () => flow.Children.ForEach(i => i.SpriteIcon.Shadow = !i.SpriteIcon.Shadow)); AddStep("change icons", () => flow.Children.ForEach(i => i.SpriteIcon.Icon = new IconUsage((char)(i.SpriteIcon.Icon.Icon + 1)))); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.cs new file mode 100644 index 0000000000..387deea76c --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMenu.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.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuMenu : OsuManualInputManagerTestScene + { + private OsuMenu menu; + private bool actionPerformed; + + [SetUp] + public void Setup() => Schedule(() => + { + actionPerformed = false; + + Child = menu = new OsuMenu(Direction.Vertical, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Items = new[] + { + new OsuMenuItem("standard", MenuItemType.Standard, performAction), + new OsuMenuItem("highlighted", MenuItemType.Highlighted, performAction), + new OsuMenuItem("destructive", MenuItemType.Destructive, performAction), + } + }; + }); + + [Test] + public void TestClickEnabledMenuItem() + { + AddStep("move to first menu item", () => InputManager.MoveMouseTo(menu.ChildrenOfType().First())); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("action performed", () => actionPerformed); + } + + [Test] + public void TestDisableMenuItemsAndClick() + { + AddStep("disable menu items", () => + { + foreach (var item in menu.Items) + ((OsuMenuItem)item).Action.Disabled = true; + }); + + AddStep("move to first menu item", () => InputManager.MoveMouseTo(menu.ChildrenOfType().First())); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("action not performed", () => !actionPerformed); + } + + [Test] + public void TestEnableMenuItemsAndClick() + { + AddStep("disable menu items", () => + { + foreach (var item in menu.Items) + ((OsuMenuItem)item).Action.Disabled = true; + }); + + AddStep("enable menu items", () => + { + foreach (var item in menu.Items) + ((OsuMenuItem)item).Action.Disabled = false; + }); + + AddStep("move to first menu item", () => InputManager.MoveMouseTo(menu.ChildrenOfType().First())); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("action performed", () => actionPerformed); + } + + private void performAction() => actionPerformed = true; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs new file mode 100644 index 0000000000..756928d3ec --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.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.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuTextBox : OsuTestScene + { + private readonly OsuNumberBox numberBox; + + public TestSceneOsuTextBox() + { + Child = new Container + { + Masking = true, + CornerRadius = 10f, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(15f), + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.DarkSlateGray, + Alpha = 0.75f, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(50f), + Spacing = new Vector2(0f, 50f), + Children = new[] + { + new OsuTextBox + { + Width = 500f, + PlaceholderText = "Normal textbox", + }, + new OsuPasswordTextBox + { + Width = 500f, + PlaceholderText = "Password textbox", + }, + numberBox = new OsuNumberBox + { + Width = 500f, + PlaceholderText = "Number textbox" + } + } + } + } + }; + } + + [Test] + public void TestNumberBox() + { + clearTextbox(numberBox); + AddStep("enter numbers", () => numberBox.Text = "987654321"); + expectedValue(numberBox, "987654321"); + + clearTextbox(numberBox); + AddStep("enter text + single number", () => numberBox.Text = "1 hello 2 world 3"); + expectedValue(numberBox, "123"); + + clearTextbox(numberBox); + } + + private void clearTextbox(OsuTextBox textBox) => AddStep("clear textbox", () => textBox.Text = null); + private void expectedValue(OsuTextBox textBox, string value) => AddAssert("expected textbox value", () => textBox.Text == value); + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs new file mode 100644 index 0000000000..2a76b8e265 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeader.cs @@ -0,0 +1,160 @@ +// 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.Overlays; +using osu.Framework.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOverlayHeader : OsuTestScene + { + private readonly FillFlowContainer flow; + + public TestSceneOverlayHeader() + { + AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = flow = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical + } + } + }); + + addHeader("Orange OverlayHeader (no background, 100 padding)", new TestNoBackgroundHeader(), OverlayColourScheme.Orange); + addHeader("Blue OverlayHeader (default 50 padding)", new TestNoControlHeader(), OverlayColourScheme.Blue); + addHeader("Green TabControlOverlayHeader (string) with ruleset selector", new TestStringTabControlHeader(), OverlayColourScheme.Green); + addHeader("Pink TabControlOverlayHeader (enum, 30 padding)", new TestEnumTabControlHeader(), OverlayColourScheme.Pink); + addHeader("Red BreadcrumbControlOverlayHeader (no background, 10 padding)", new TestBreadcrumbControlHeader(), OverlayColourScheme.Red); + } + + private void addHeader(string name, OverlayHeader header, OverlayColourScheme colourScheme) + { + flow.Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding(20), + Text = name, + }, + new ColourProvidedContainer(colourScheme, header) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } + } + }); + } + + private class ColourProvidedContainer : Container + { + [Cached] + private readonly OverlayColourProvider colourProvider; + + public ColourProvidedContainer(OverlayColourScheme colourScheme, OverlayHeader header) + { + colourProvider = new OverlayColourProvider(colourScheme); + + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + Add(header); + } + } + + private class TestNoBackgroundHeader : OverlayHeader + { + protected override OverlayTitle CreateTitle() => new TestTitle(); + + public TestNoBackgroundHeader() + { + ContentSidePadding = 100; + } + } + + private class TestNoControlHeader : OverlayHeader + { + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/changelog"); + + protected override OverlayTitle CreateTitle() => new TestTitle(); + } + + private class TestStringTabControlHeader : TabControlOverlayHeader + { + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/news"); + + protected override OverlayTitle CreateTitle() => new TestTitle(); + + protected override Drawable CreateTitleContent() => new OverlayRulesetSelector(); + + public TestStringTabControlHeader() + { + TabControl.AddItem("tab1"); + TabControl.AddItem("tab2"); + } + } + + private class TestEnumTabControlHeader : TabControlOverlayHeader + { + public TestEnumTabControlHeader() + { + ContentSidePadding = 30; + } + + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/rankings"); + + protected override OverlayTitle CreateTitle() => new TestTitle(); + } + + private enum TestEnum + { + Some, + Cool, + Tabs + } + + private class TestBreadcrumbControlHeader : BreadcrumbControlOverlayHeader + { + protected override OverlayTitle CreateTitle() => new TestTitle(); + + public TestBreadcrumbControlHeader() + { + ContentSidePadding = 10; + + TabControl.AddItem("tab1"); + TabControl.AddItem("tab2"); + TabControl.Current.Value = "tab2"; + } + } + + private class TestTitle : OverlayTitle + { + public TestTitle() + { + Title = "title"; + IconTexture = "Icons/changelog"; + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeaderBackground.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeaderBackground.cs new file mode 100644 index 0000000000..db414d23a0 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayHeaderBackground.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.Containers; +using osu.Game.Overlays; +using osu.Framework.Graphics; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOverlayHeaderBackground : OsuTestScene + { + public TestSceneOverlayHeaderBackground() + { + Add(new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new[] + { + new OverlayHeaderBackground(@"Headers/changelog"), + new OverlayHeaderBackground(@"Headers/news"), + new OverlayHeaderBackground(@"Headers/rankings"), + new OverlayHeaderBackground(@"Headers/search"), + } + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayRulesetSelector.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayRulesetSelector.cs new file mode 100644 index 0000000000..f4fa41a3b7 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayRulesetSelector.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 osu.Framework.Graphics; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; +using osu.Framework.Bindables; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Framework.Allocation; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOverlayRulesetSelector : OsuTestScene + { + private readonly OverlayRulesetSelector selector; + private readonly Bindable ruleset = new Bindable(); + + public TestSceneOverlayRulesetSelector() + { + Add(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new[] + { + new ColourProvidedContainer(OverlayColourScheme.Green, selector = new OverlayRulesetSelector { Current = ruleset }), + new ColourProvidedContainer(OverlayColourScheme.Blue, new OverlayRulesetSelector { Current = ruleset }), + new ColourProvidedContainer(OverlayColourScheme.Orange, new OverlayRulesetSelector { Current = ruleset }), + new ColourProvidedContainer(OverlayColourScheme.Pink, new OverlayRulesetSelector { Current = ruleset }), + new ColourProvidedContainer(OverlayColourScheme.Purple, new OverlayRulesetSelector { Current = ruleset }), + new ColourProvidedContainer(OverlayColourScheme.Red, new OverlayRulesetSelector { Current = ruleset }), + } + }); + } + + private class ColourProvidedContainer : Container + { + [Cached] + private readonly OverlayColourProvider colourProvider; + + public ColourProvidedContainer(OverlayColourScheme colourScheme, OverlayRulesetSelector rulesetSelector) + { + colourProvider = new OverlayColourProvider(colourScheme); + AutoSizeAxes = Axes.Both; + Add(rulesetSelector); + } + } + + [Test] + public void TestSelection() + { + AddStep("Select osu!", () => ruleset.Value = new OsuRuleset().RulesetInfo); + AddAssert("Check osu! selected", () => selector.Current.Value.Equals(new OsuRuleset().RulesetInfo)); + + AddStep("Select mania", () => ruleset.Value = new ManiaRuleset().RulesetInfo); + AddAssert("Check mania selected", () => selector.Current.Value.Equals(new ManiaRuleset().RulesetInfo)); + + AddStep("Select taiko", () => ruleset.Value = new TaikoRuleset().RulesetInfo); + AddAssert("Check taiko selected", () => selector.Current.Value.Equals(new TaikoRuleset().RulesetInfo)); + + AddStep("Select catch", () => ruleset.Value = new CatchRuleset().RulesetInfo); + AddAssert("Check catch selected", () => selector.Current.Value.Equals(new CatchRuleset().RulesetInfo)); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayScrollContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayScrollContainer.cs new file mode 100644 index 0000000000..7fa730e02b --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayScrollContainer.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.Graphics.Containers; +using osu.Game.Overlays; +using osu.Framework.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; +using NUnit.Framework; +using osu.Framework.Utils; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOverlayScrollContainer : OsuManualInputManagerTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + private TestScrollContainer scroll; + + private int invocationCount; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = scroll = new TestScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new Container + { + Height = 3000, + RelativeSizeAxes = Axes.X, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Gray + } + } + }; + + invocationCount = 0; + + scroll.Button.Action += () => invocationCount++; + }); + + [Test] + public void TestButtonVisibility() + { + AddAssert("button is hidden", () => scroll.Button.State == Visibility.Hidden); + + AddStep("scroll to end", () => scroll.ScrollToEnd(false)); + AddAssert("button is visible", () => scroll.Button.State == Visibility.Visible); + + AddStep("scroll to start", () => scroll.ScrollToStart(false)); + AddAssert("button is hidden", () => scroll.Button.State == Visibility.Hidden); + + AddStep("scroll to 500", () => scroll.ScrollTo(500)); + AddUntilStep("scrolled to 500", () => Precision.AlmostEquals(scroll.Current, 500, 0.1f)); + AddAssert("button is visible", () => scroll.Button.State == Visibility.Visible); + } + + [Test] + public void TestButtonAction() + { + AddStep("scroll to end", () => scroll.ScrollToEnd(false)); + + AddStep("invoke action", () => scroll.Button.Action.Invoke()); + + AddUntilStep("scrolled back to start", () => Precision.AlmostEquals(scroll.Current, 0, 0.1f)); + } + + [Test] + public void TestClick() + { + AddStep("scroll to end", () => scroll.ScrollToEnd(false)); + + AddStep("click button", () => + { + InputManager.MoveMouseTo(scroll.Button); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("scrolled back to start", () => Precision.AlmostEquals(scroll.Current, 0, 0.1f)); + } + + [Test] + public void TestMultipleClicks() + { + AddStep("scroll to end", () => scroll.ScrollToEnd(false)); + + AddAssert("invocation count is 0", () => invocationCount == 0); + + AddStep("hover button", () => InputManager.MoveMouseTo(scroll.Button)); + AddRepeatStep("click button", () => InputManager.Click(MouseButton.Left), 3); + + AddAssert("invocation count is 1", () => invocationCount == 1); + } + + private class TestScrollContainer : OverlayScrollContainer + { + public new ScrollToTopButton Button => base.Button; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePaginatedContainerHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePaginatedContainerHeader.cs new file mode 100644 index 0000000000..2e9f919cfd --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePaginatedContainerHeader.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.Game.Overlays.Profile.Sections; +using osu.Framework.Testing; +using System.Linq; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Framework.Allocation; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestScenePaginatedContainerHeader : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + private PaginatedContainerHeader header; + + [Test] + public void TestHiddenCounter() + { + AddStep("Create header", () => createHeader("Header with hidden counter", CounterVisibilityState.AlwaysHidden)); + AddAssert("Value is 0", () => header.Current.Value == 0); + AddAssert("Counter is hidden", () => header.ChildrenOfType().First().Alpha == 0); + AddStep("Set count 10", () => header.Current.Value = 10); + AddAssert("Value is 10", () => header.Current.Value == 10); + AddAssert("Counter is hidden", () => header.ChildrenOfType().First().Alpha == 0); + } + + [Test] + public void TestVisibleCounter() + { + AddStep("Create header", () => createHeader("Header with visible counter", CounterVisibilityState.AlwaysVisible)); + AddAssert("Value is 0", () => header.Current.Value == 0); + AddAssert("Counter is visible", () => header.ChildrenOfType().First().Alpha == 1); + AddStep("Set count 10", () => header.Current.Value = 10); + AddAssert("Value is 10", () => header.Current.Value == 10); + AddAssert("Counter is visible", () => header.ChildrenOfType().First().Alpha == 1); + } + + [Test] + public void TestVisibleWhenZeroCounter() + { + AddStep("Create header", () => createHeader("Header with visible when zero counter", CounterVisibilityState.VisibleWhenZero)); + AddAssert("Value is 0", () => header.Current.Value == 0); + AddAssert("Counter is visible", () => header.ChildrenOfType().First().Alpha == 1); + AddStep("Set count 10", () => header.Current.Value = 10); + AddAssert("Value is 10", () => header.Current.Value == 10); + AddAssert("Counter is hidden", () => header.ChildrenOfType().First().Alpha == 0); + AddStep("Set count 0", () => header.Current.Value = 0); + AddAssert("Value is 0", () => header.Current.Value == 0); + AddAssert("Counter is visible", () => header.ChildrenOfType().First().Alpha == 1); + } + + [Test] + public void TestInitialVisibility() + { + AddStep("Create header with 0 value", () => createHeader("Header with visible when zero counter", CounterVisibilityState.VisibleWhenZero, 0)); + AddAssert("Value is 0", () => header.Current.Value == 0); + AddAssert("Counter is visible", () => header.ChildrenOfType().First().Alpha == 1); + + AddStep("Create header with 1 value", () => createHeader("Header with visible when zero counter", CounterVisibilityState.VisibleWhenZero, 1)); + AddAssert("Value is 1", () => header.Current.Value == 1); + AddAssert("Counter is hidden", () => header.ChildrenOfType().First().Alpha == 0); + } + + private void createHeader(string text, CounterVisibilityState state, int initialValue = 0) + { + Clear(); + Add(header = new PaginatedContainerHeader(text, state) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = { Value = initialValue } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs new file mode 100644 index 0000000000..52141dea1a --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePlaylistOverlay.cs @@ -0,0 +1,100 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Overlays.Music; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestScenePlaylistOverlay : OsuManualInputManagerTestScene + { + private readonly BindableList beatmapSets = new BindableList(); + + private PlaylistOverlay playlistOverlay; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(300, 500), + Child = playlistOverlay = new PlaylistOverlay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + State = { Value = Visibility.Visible } + } + }; + + beatmapSets.Clear(); + + for (int i = 0; i < 100; i++) + { + beatmapSets.Add(new BeatmapSetInfo + { + Metadata = new BeatmapMetadata + { + // Create random metadata, then we can check if sorting works based on these + Artist = "Some Artist " + RNG.Next(0, 9), + Title = $"Some Song {i + 1}", + AuthorString = "Some Guy " + RNG.Next(0, 9), + }, + DateAdded = DateTimeOffset.UtcNow, + }); + } + + playlistOverlay.BeatmapSets.BindTo(beatmapSets); + }); + + [Test] + public void TestRearrangeItems() + { + AddUntilStep("wait for animations to complete", () => !playlistOverlay.Transforms.Any()); + + AddStep("hold 1st item handle", () => + { + var handle = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(handle.ScreenSpaceDrawQuad.Centre); + InputManager.PressButton(MouseButton.Left); + }); + + AddStep("drag to 5th", () => + { + var item = this.ChildrenOfType().ElementAt(4); + InputManager.MoveMouseTo(item.ScreenSpaceDrawQuad.Centre); + }); + + AddAssert("song 1 is 5th", () => beatmapSets[4].Metadata.Title == "Some Song 1"); + + AddStep("release handle", () => InputManager.ReleaseButton(MouseButton.Left)); + } + + [Test] + public void TestFiltering() + { + AddStep("set filter to \"10\"", () => + { + var filterControl = playlistOverlay.ChildrenOfType().Single(); + filterControl.Search.Current.Value = "10"; + }); + + AddAssert("results filtered correctly", + () => playlistOverlay.ChildrenOfType() + .Where(item => item.MatchingFilter) + .All(item => item.FilterTerms.Any(term => term.Contains("10")))); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs index 9ddd8f4038..8e53c7c402 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs @@ -13,13 +13,23 @@ namespace osu.Game.Tests.Visual.UserInterface { public TestScenePopupDialog() { - var popup = new PopupDialog + AddStep("new popup", () => + Add(new TestPopupDialog + { + RelativeSizeAxes = Axes.Both, + State = { Value = Framework.Graphics.Containers.Visibility.Visible }, + })); + } + + private class TestPopupDialog : PopupDialog + { + public TestPopupDialog() { - RelativeSizeAxes = Axes.Both, - State = { Value = Framework.Graphics.Containers.Visibility.Visible }, - Icon = FontAwesome.Solid.AssistiveListeningSystems, - HeaderText = @"This is a test popup", - BodyText = "I can say lots of stuff and even wrap my words!", + Icon = FontAwesome.Solid.AssistiveListeningSystems; + + HeaderText = @"This is a test popup"; + BodyText = "I can say lots of stuff and even wrap my words!"; + Buttons = new PopupDialogButton[] { new PopupDialogCancelButton @@ -30,10 +40,8 @@ namespace osu.Game.Tests.Visual.UserInterface { Text = @"You're a fake!", }, - } - }; - - Add(popup); + }; + } } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.cs new file mode 100644 index 0000000000..24bc0dbc97 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRankingsSortTabControl.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.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Rankings; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneRankingsSortTabControl : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + public TestSceneRankingsSortTabControl() + { + Child = new RankingsSortTabControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs index 0cb8683d72..77a7d819a9 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScreenBreadcrumbControl.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -26,7 +25,9 @@ namespace osu.Game.Tests.Visual.UserInterface OsuSpriteText titleText; IScreen startScreen = new TestScreenOne(); - screenStack = new OsuScreenStack(startScreen) { RelativeSizeAxes = Axes.Both }; + + screenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }; + screenStack.Push(startScreen); Children = new Drawable[] { @@ -62,7 +63,7 @@ namespace osu.Game.Tests.Visual.UserInterface waitForCurrent(); pushNext(); waitForCurrent(); - AddAssert(@"only 2 items", () => breadcrumbs.Items.Count() == 2); + AddAssert(@"only 2 items", () => breadcrumbs.Items.Count == 2); AddStep(@"exit current", () => screenStack.CurrentScreen.Exit()); AddAssert(@"current screen is first", () => startScreen == screenStack.CurrentScreen); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.cs new file mode 100644 index 0000000000..29aeb6a4b2 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneStatefulMenuItem.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; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneStatefulMenuItem : OsuManualInputManagerTestScene + { + [Test] + public void TestTernaryMenuItem() + { + 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 TernaryStateMenuItem("First"), + new TernaryStateMenuItem("Second") { State = { BindTarget = state } }, + new TernaryStateMenuItem("Third") { State = { Value = TernaryState.True } }, + } + }; + }); + + checkState(TernaryState.Indeterminate); + + click(); + checkState(TernaryState.True); + + click(); + checkState(TernaryState.False); + + click(); + checkState(TernaryState.True); + + click(); + checkState(TernaryState.False); + + 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 TestCustomState() + { + AddStep("create menu", () => + { + Child = new OsuMenu(Direction.Vertical, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Items = new[] + { + new TestMenuItem("First", MenuItemType.Standard, getNextState), + new TestMenuItem("Second", MenuItemType.Standard, getNextState) { State = { Value = TestStates.State2 } }, + new TestMenuItem("Third", MenuItemType.Standard, getNextState) { State = { Value = TestStates.State3 } }, + } + }; + }); + } + + private TestStates getNextState(TestStates state) + { + switch (state) + { + case TestStates.State1: + return TestStates.State2; + + case TestStates.State2: + return TestStates.State3; + + case TestStates.State3: + return TestStates.State1; + } + + return TestStates.State1; + } + + private class TestMenuItem : StatefulMenuItem + { + public TestMenuItem(string text, MenuItemType type, Func changeStateFunc) + : base(text, changeStateFunc, type) + { + } + + public override IconUsage? GetIconForState(TestStates state) + { + switch (state) + { + case TestStates.State1: + return FontAwesome.Solid.DiceOne; + + case TestStates.State2: + return FontAwesome.Solid.DiceTwo; + + case TestStates.State3: + return FontAwesome.Solid.DiceThree; + + default: + throw new ArgumentOutOfRangeException(nameof(state), state, null); + } + } + } + + private enum TestStates + { + State1, + State2, + State3 + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.cs new file mode 100644 index 0000000000..37fab75aee --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSwitchButton.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 NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneSwitchButton : OsuManualInputManagerTestScene + { + private SwitchButton switchButton; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = switchButton = new SwitchButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + }); + + [Test] + public void TestChangeThroughInput() + { + AddStep("move to switch button", () => InputManager.MoveMouseTo(switchButton)); + AddStep("click on", () => InputManager.Click(MouseButton.Left)); + AddStep("click off", () => InputManager.Click(MouseButton.Left)); + } + + [Test] + public void TestChangeThroughBindable() + { + BindableBool bindable = null; + + AddStep("bind bindable", () => switchButton.Current.BindTo(bindable = new BindableBool())); + AddStep("toggle bindable", () => bindable.Toggle()); + AddStep("toggle bindable", () => bindable.Toggle()); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.cs new file mode 100644 index 0000000000..9fb8e747f3 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneToggleMenuItem.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.Framework.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneToggleMenuItem : OsuTestScene + { + public TestSceneToggleMenuItem() + { + Add(new OsuMenu(Direction.Vertical, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Items = new[] + { + new ToggleMenuItem("First"), + new ToggleMenuItem("Second") { State = { Value = true } } + } + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs index 0da256855a..cdfbb14cba 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneToolbarRulesetSelector.cs @@ -3,25 +3,17 @@ using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Toolbar; -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Rulesets; namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneToolbarRulesetSelector : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(ToolbarRulesetSelector), - typeof(ToolbarRulesetTabButton), - }; - [Resolved] private RulesetStore rulesets { get; set; } @@ -44,7 +36,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("Select random", () => { - selector.Current.Value = selector.Items.ElementAt(RNG.Next(selector.Items.Count())); + selector.Current.Value = selector.Items.ElementAt(RNG.Next(selector.Items.Count)); }); AddStep("Toggle disabled state", () => selector.Current.Disabled = !selector.Current.Disabled); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs index 9cdfcb6cc4..198cc70e01 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs @@ -20,6 +20,8 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneUpdateableBeatmapBackgroundSprite : OsuTestScene { + protected override bool UseOnlineAPI => true; + private BeatmapSetInfo testBeatmap; private IAPIProvider api; private RulesetStore rulesets; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.cs new file mode 100644 index 0000000000..8f7140ed7c --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUserListToolbar.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Overlays.Dashboard.Friends; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneUserListToolbar : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + public TestSceneUserListToolbar() + { + UserListToolbar toolbar; + OsuSpriteText sort; + OsuSpriteText displayStyle; + + Add(toolbar = new UserListToolbar + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + sort = new OsuSpriteText(), + displayStyle = new OsuSpriteText() + } + }); + + toolbar.SortCriteria.BindValueChanged(criteria => sort.Text = $"Criteria: {criteria.NewValue}", true); + toolbar.DisplayStyle.BindValueChanged(style => displayStyle.Text = $"Style: {style.NewValue}", true); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs index 2fe6240b22..c8478c8eca 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumePieces.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using System.Collections.Generic; using osu.Framework.Graphics; using osu.Game.Overlays.Volume; using osuTK; @@ -12,8 +10,6 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneVolumePieces : OsuTestScene { - public override IReadOnlyList RequiredTypes => new[] { typeof(VolumeMeter), typeof(MuteButton) }; - protected override void LoadComplete() { VolumeMeter meter; diff --git a/osu.Game.Tests/Visual/Editor/TestSceneWaveContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneWaveContainer.cs similarity index 97% rename from osu.Game.Tests/Visual/Editor/TestSceneWaveContainer.cs rename to osu.Game.Tests/Visual/UserInterface/TestSceneWaveContainer.cs index de19727251..5b130b9224 100644 --- a/osu.Game.Tests/Visual/Editor/TestSceneWaveContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneWaveContainer.cs @@ -12,7 +12,7 @@ using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; -namespace osu.Game.Tests.Visual.Editor +namespace osu.Game.Tests.Visual.UserInterface { [TestFixture] public class TestSceneWaveContainer : OsuTestScene diff --git a/osu.Game.Tests/WaveformTestBeatmap.cs b/osu.Game.Tests/WaveformTestBeatmap.cs index 3e0df8d45e..f9613d9e25 100644 --- a/osu.Game.Tests/WaveformTestBeatmap.cs +++ b/osu.Game.Tests/WaveformTestBeatmap.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.IO; using System.Linq; using osu.Framework.Audio; @@ -8,7 +9,10 @@ using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; +using osu.Game.IO; using osu.Game.IO.Archives; +using osu.Game.Rulesets.Catch; +using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; namespace osu.Game.Tests @@ -18,43 +22,62 @@ namespace osu.Game.Tests ///
public class WaveformTestBeatmap : WorkingBeatmap { - private readonly ZipArchiveReader reader; - private readonly Stream stream; + private readonly Beatmap beatmap; private readonly ITrackStore trackStore; public WaveformTestBeatmap(AudioManager audioManager) - : base(new BeatmapInfo(), audioManager) + : this(audioManager, new WaveformBeatmap()) { - stream = TestResources.GetTestBeatmapStream(); - reader = new ZipArchiveReader(stream); - trackStore = audioManager.GetTrackStore(reader); } - protected override void Dispose(bool isDisposing) + public WaveformTestBeatmap(AudioManager audioManager, Beatmap beatmap) + : base(beatmap.BeatmapInfo, audioManager) { - base.Dispose(isDisposing); - stream?.Dispose(); - reader?.Dispose(); + this.beatmap = beatmap; + trackStore = audioManager.GetTrackStore(getZipReader()); + } + + ~WaveformTestBeatmap() + { + // Remove the track store from the audio manager trackStore?.Dispose(); } - protected override IBeatmap GetBeatmap() => createTestBeatmap(); + private static Stream getStream() => TestResources.GetTestBeatmapStream(); + + private static ZipArchiveReader getZipReader() => new ZipArchiveReader(getStream()); + + protected override IBeatmap GetBeatmap() => beatmap; protected override Texture GetBackground() => null; protected override Waveform GetWaveform() => new Waveform(trackStore.GetStream(firstAudioFile)); - protected override Track GetTrack() => trackStore.Get(firstAudioFile); + protected override Track GetBeatmapTrack() => trackStore.Get(firstAudioFile); - private string firstAudioFile => reader.Filenames.First(f => f.EndsWith(".mp3")); - - private Stream getBeatmapStream() => reader.GetStream(reader.Filenames.First(f => f.EndsWith(".osu"))); - - private Beatmap createTestBeatmap() + private string firstAudioFile { - using (var beatmapStream = getBeatmapStream()) - using (var beatmapReader = new StreamReader(beatmapStream)) - return Decoder.GetDecoder(beatmapReader).Decode(beatmapReader); + get + { + using (var reader = getZipReader()) + return reader.Filenames.First(f => f.EndsWith(".mp3", StringComparison.Ordinal)); + } + } + + private class WaveformBeatmap : TestBeatmap + { + public WaveformBeatmap() + : base(new CatchRuleset().RulesetInfo) + { + } + + protected override Beatmap CreateBeatmap() + { + using (var reader = getZipReader()) + using (var beatmapStream = reader.GetStream(reader.Filenames.First(f => f.EndsWith(".osu", StringComparison.Ordinal)))) + using (var beatmapReader = new LineBufferedReader(beatmapStream)) + return Decoder.GetDecoder(beatmapReader).Decode(beatmapReader); + } } } } diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index 659f5415c3..c692bcd5e4 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -3,14 +3,14 @@ - + - + WinExe - netcoreapp2.2 + netcoreapp3.1 diff --git a/osu.Game.Tournament.Tests/.vscode/tasks.json b/osu.Game.Tournament.Tests/.vscode/tasks.json index 37f2f32874..c69ac0391a 100644 --- a/osu.Game.Tournament.Tests/.vscode/tasks.json +++ b/osu.Game.Tournament.Tests/.vscode/tasks.json @@ -11,9 +11,9 @@ "build", "--no-restore", "osu.Game.Tournament.Tests.csproj", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" @@ -26,10 +26,10 @@ "build", "--no-restore", "osu.Game.Tournament.Tests.csproj", - "/p:Configuration=Release", - "/p:GenerateFullPaths=true", - "/m", - "/verbosity:m" + "-p:Configuration=Release", + "-p:GenerateFullPaths=true", + "-m", + "-verbosity:m" ], "group": "build", "problemMatcher": "$msCompile" diff --git a/osu.Game.Tournament.Tests/Components/TestSceneDateTextBox.cs b/osu.Game.Tournament.Tests/Components/TestSceneDateTextBox.cs new file mode 100644 index 0000000000..33165d385a --- /dev/null +++ b/osu.Game.Tournament.Tests/Components/TestSceneDateTextBox.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 NUnit.Framework; +using osu.Game.Tests.Visual; +using osu.Game.Tournament.Components; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tournament.Tests.Components +{ + public class TestSceneDateTextBox : OsuManualInputManagerTestScene + { + private DateTextBox textBox; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = textBox = new DateTextBox + { + Width = 0.3f + }; + }); + + [Test] + public void TestCommitWithoutSettingBindable() + { + AddStep("click textbox", () => + { + InputManager.MoveMouseTo(textBox); + InputManager.Click(MouseButton.Left); + }); + + AddStep("unfocus", () => + { + InputManager.MoveMouseTo(Vector2.Zero); + InputManager.Click(MouseButton.Left); + }); + } + } +} diff --git a/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs index f329623703..f98f55dfbc 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentMatch.cs @@ -1,25 +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 osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Tests.Visual; -using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Ladder.Components; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneDrawableTournamentMatch : OsuTestScene + public class TestSceneDrawableTournamentMatch : TournamentTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(TournamentMatch), - typeof(DrawableTournamentTeam), - }; - public TestSceneDrawableTournamentMatch() { Container level1; diff --git a/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentTeam.cs b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentTeam.cs new file mode 100644 index 0000000000..376c59ec2d --- /dev/null +++ b/osu.Game.Tournament.Tests/Components/TestSceneDrawableTournamentTeam.cs @@ -0,0 +1,112 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Tests.Visual; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; +using osu.Game.Tournament.Screens.Drawings.Components; +using osu.Game.Tournament.Screens.Gameplay.Components; +using osu.Game.Tournament.Screens.Ladder.Components; +using osu.Game.Users; + +namespace osu.Game.Tournament.Tests.Components +{ + public class TestSceneDrawableTournamentTeam : OsuGridTestScene + { + public TestSceneDrawableTournamentTeam() + : base(4, 3) + { + var team = new TournamentTeam + { + FlagName = { Value = "AU" }, + FullName = { Value = "Australia" }, + Players = + { + new User { Username = "ASecretBox" }, + new User { Username = "Dereban" }, + new User { Username = "mReKk" }, + new User { Username = "uyghti" }, + new User { Username = "Parkes" }, + new User { Username = "Shiroha" }, + new User { Username = "Jordan The Bear" }, + } + }; + + var match = new TournamentMatch { Team1 = { Value = team } }; + + int i = 0; + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "DrawableTeamFlag" }, + new DrawableTeamFlag(team) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "DrawableTeamTitle" }, + new DrawableTeamTitle(team) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "DrawableTeamTitleWithHeader" }, + new DrawableTeamTitleWithHeader(team, TeamColour.Red) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "DrawableMatchTeam" }, + new DrawableMatchTeam(team, match, false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "TeamWithPlayers" }, + new DrawableTeamWithPlayers(team, TeamColour.Blue) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i++).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "GroupTeam" }, + new GroupTeam(team) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + + Cell(i).AddRange(new Drawable[] + { + new TournamentSpriteText { Text = "TeamDisplay" }, + new TeamDisplay(team, TeamColour.Red, new Bindable(2), 6) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }); + } + } +} diff --git a/osu.Game.Tournament.Tests/Components/TestSceneMatchHeader.cs b/osu.Game.Tournament.Tests/Components/TestSceneMatchHeader.cs new file mode 100644 index 0000000000..b29e4964b6 --- /dev/null +++ b/osu.Game.Tournament.Tests/Components/TestSceneMatchHeader.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.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Tournament.Screens.Gameplay.Components; +using osuTK; + +namespace osu.Game.Tournament.Tests.Components +{ + public class TestSceneMatchHeader : TournamentTestScene + { + public TestSceneMatchHeader() + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(50), + Children = new Drawable[] + { + new TournamentSpriteText { Text = "with logo", Font = OsuFont.Torus.With(size: 30) }, + new MatchHeader(), + new TournamentSpriteText { Text = "without logo", Font = OsuFont.Torus.With(size: 30) }, + new MatchHeader { ShowLogo = false }, + new TournamentSpriteText { Text = "without scores", Font = OsuFont.Torus.With(size: 30) }, + new MatchHeader { ShowScores = false }, + } + }; + } + } +} diff --git a/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs index 72d9eb0e07..acd5d53310 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneMatchScoreDisplay.cs @@ -3,13 +3,13 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Screens.Gameplay.Components; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneMatchScoreDisplay : LadderTestScene + public class TestSceneMatchScoreDisplay : TournamentTestScene { [Cached(Type = typeof(MatchIPCInfo))] private MatchIPCInfo matchInfo = new MatchIPCInfo(); diff --git a/osu.Game.Tournament.Tests/Components/TestSceneRoundDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneRoundDisplay.cs new file mode 100644 index 0000000000..13bca7bea1 --- /dev/null +++ b/osu.Game.Tournament.Tests/Components/TestSceneRoundDisplay.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.Tournament.Components; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Tests.Components +{ + public class TestSceneRoundDisplay : TournamentTestScene + { + public TestSceneRoundDisplay() + { + Children = new Drawable[] + { + new RoundDisplay(new TournamentMatch + { + Round = + { + Value = new TournamentRound + { + Name = { Value = "Test Round" } + } + } + }) + { + Margin = new MarginPadding(20) + } + }; + } + } +} diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs index 77fa411058..bc32a12ab7 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs @@ -8,12 +8,11 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; -using osu.Game.Tests.Visual; using osu.Game.Tournament.Components; namespace osu.Game.Tournament.Tests.Components { - public class TestSceneTournamentBeatmapPanel : OsuTestScene + public class TestSceneTournamentBeatmapPanel : TournamentTestScene { [Resolved] private IAPIProvider api { get; set; } diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs index 41d32d9448..9905e17824 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentMatchChatDisplay.cs @@ -102,7 +102,8 @@ namespace osu.Game.Tournament.Tests.Components Content = "Okay okay, calm down guys. Let's do this!" })); - AddStep("multiple messages", () => testChannel.AddNewMessages(new Message(nextMessageId()) + AddStep("multiple messages", () => testChannel.AddNewMessages( + new Message(nextMessageId()) { Sender = admin, Content = "I spam you!" diff --git a/osu.Game.Tournament.Tests/LadderTestScene.cs b/osu.Game.Tournament.Tests/LadderTestScene.cs deleted file mode 100644 index b49341d0d1..0000000000 --- a/osu.Game.Tournament.Tests/LadderTestScene.cs +++ /dev/null @@ -1,15 +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.Game.Tests.Visual; -using osu.Game.Tournament.Models; - -namespace osu.Game.Tournament.Tests -{ - public abstract class LadderTestScene : OsuTestScene - { - [Resolved] - protected LadderInfo Ladder { get; private set; } - } -} diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs new file mode 100644 index 0000000000..567d9f0d62 --- /dev/null +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -0,0 +1,169 @@ +// 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.Configuration; +using osu.Game.Tests; + +namespace osu.Game.Tournament.Tests.NonVisual +{ + [TestFixture] + public class CustomTourneyDirectoryTest + { + [Test] + public void TestDefaultDirectory() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = loadOsu(host); + var storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"))); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestCustomDirectory() + { + using (HeadlessGameHost host = new HeadlessGameHost(nameof(TestCustomDirectory))) // don't use clean run as we are writing a config file. + { + string osuDesktopStorage = basePath(nameof(TestCustomDirectory)); + const string custom_tournament = "custom"; + + // need access before the game has constructed its own storage yet. + Storage storage = new DesktopStorage(osuDesktopStorage, host); + // manual cleaning so we can prepare a config file. + storage.DeleteDirectory(string.Empty); + + using (var storageConfig = new TournamentStorageManager(storage)) + storageConfig.Set(StorageConfig.CurrentTournament, custom_tournament); + + try + { + var osu = loadOsu(host); + + storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(Path.Combine(host.Storage.GetFullPath("."), "tournaments", custom_tournament))); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigration() + { + using (HeadlessGameHost host = new HeadlessGameHost(nameof(TestMigration))) // don't use clean run as we are writing test files for migration. + { + string osuRoot = basePath(nameof(TestMigration)); + string configFile = Path.Combine(osuRoot, "tournament.ini"); + + if (File.Exists(configFile)) + File.Delete(configFile); + + // Recreate the old setup that uses "tournament" as the base path. + string oldPath = Path.Combine(osuRoot, "tournament"); + + string videosPath = Path.Combine(oldPath, "videos"); + string modsPath = Path.Combine(oldPath, "mods"); + string flagsPath = Path.Combine(oldPath, "flags"); + + Directory.CreateDirectory(videosPath); + Directory.CreateDirectory(modsPath); + Directory.CreateDirectory(flagsPath); + + // Define testing files corresponding to the specific file migrations that are needed + string bracketFile = Path.Combine(osuRoot, "bracket.json"); + + string drawingsConfig = Path.Combine(osuRoot, "drawings.ini"); + string drawingsFile = Path.Combine(osuRoot, "drawings.txt"); + string drawingsResult = Path.Combine(osuRoot, "drawings_results.txt"); + + // Define sample files to test recursive copying + string videoFile = Path.Combine(videosPath, "video.mp4"); + string modFile = Path.Combine(modsPath, "mod.png"); + string flagFile = Path.Combine(flagsPath, "flag.png"); + + File.WriteAllText(bracketFile, "{}"); + File.WriteAllText(drawingsConfig, "test"); + File.WriteAllText(drawingsFile, "test"); + File.WriteAllText(drawingsResult, "test"); + File.WriteAllText(videoFile, "test"); + File.WriteAllText(modFile, "test"); + File.WriteAllText(flagFile, "test"); + + try + { + var osu = loadOsu(host); + + var storage = osu.Dependencies.Get(); + + string migratedPath = Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"); + + videosPath = Path.Combine(migratedPath, "videos"); + modsPath = Path.Combine(migratedPath, "mods"); + flagsPath = Path.Combine(migratedPath, "flags"); + + videoFile = Path.Combine(videosPath, "video.mp4"); + modFile = Path.Combine(modsPath, "mod.png"); + flagFile = Path.Combine(flagsPath, "flag.png"); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(migratedPath)); + + Assert.True(storage.Exists("bracket.json")); + Assert.True(storage.Exists("drawings.txt")); + Assert.True(storage.Exists("drawings_results.txt")); + + Assert.True(storage.Exists("drawings.ini")); + + Assert.True(storage.Exists(videoFile)); + Assert.True(storage.Exists(modFile)); + Assert.True(storage.Exists(flagFile)); + } + finally + { + host.Storage.Delete("tournament.ini"); + 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); + } + + private string basePath(string testInstance) => Path.Combine(RuntimeInfo.StartupDirectory, "headless", testInstance); + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs index 201736f38a..c1159dc000 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneGameplayScreen.cs @@ -2,16 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Game.Tests.Visual; using osu.Game.Tournament.Components; using osu.Game.Tournament.Screens.Gameplay; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneGameplayScreen : OsuTestScene + public class TestSceneGameplayScreen : TournamentTestScene { [Cached] - private TournamentMatchChatDisplay chat = new TournamentMatchChatDisplay(); + private TournamentMatchChatDisplay chat = new TournamentMatchChatDisplay { Width = 0.5f }; [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs index a45c5de2bd..bceb3e6b74 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneLadderEditorScreen.cs @@ -8,7 +8,7 @@ using osu.Game.Tournament.Screens.Editors; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneLadderEditorScreen : LadderTestScene + public class TestSceneLadderEditorScreen : TournamentTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneLadderScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneLadderScreen.cs index 2be0564c82..c4c100d506 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneLadderScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneLadderScreen.cs @@ -8,7 +8,7 @@ using osu.Game.Tournament.Screens.Ladder; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneLadderScreen : LadderTestScene + public class TestSceneLadderScreen : TournamentTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs index a7011c6d3c..f4032fdd54 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs @@ -1,24 +1,140 @@ // 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.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.MapPool; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneMapPoolScreen : LadderTestScene + public class TestSceneMapPoolScreen : TournamentTestScene { - public override IReadOnlyList RequiredTypes => new[] - { - typeof(MapPoolScreen) - }; + private MapPoolScreen screen; [BackgroundDependencyLoader] private void load() { - Add(new MapPoolScreen { Width = 0.7f }); + Add(screen = new MapPoolScreen { Width = 0.7f }); + } + + [Test] + public void TestFewMaps() + { + AddStep("load few maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 8; i++) + addBeatmap(); + }); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + + assertTwoWide(); + } + + [Test] + public void TestJustEnoughMaps() + { + AddStep("load just enough maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 18; i++) + addBeatmap(); + }); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + + assertTwoWide(); + } + + [Test] + public void TestManyMaps() + { + AddStep("load many maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 19; i++) + addBeatmap(); + }); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + + assertThreeWide(); + } + + [Test] + public void TestJustEnoughMods() + { + AddStep("load many maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 11; i++) + addBeatmap(i > 4 ? $"M{i}" : "NM"); + }); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + + assertTwoWide(); + } + + private void assertTwoWide() => + AddAssert("ensure layout width is 2", () => screen.ChildrenOfType>>().First().Padding.Left > 0); + + private void assertThreeWide() => + AddAssert("ensure layout width is 3", () => screen.ChildrenOfType>>().First().Padding.Left == 0); + + [Test] + public void TestManyMods() + { + AddStep("load many maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 12; i++) + addBeatmap(i > 4 ? $"M{i}" : "NM"); + }); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + + assertThreeWide(); + } + + private void addBeatmap(string mods = "nm") + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Add(new RoundBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Mods = mods + }); } } } diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs index e15ac416b0..5c2b59df3a 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneRoundEditorScreen.cs @@ -5,7 +5,7 @@ using osu.Game.Tournament.Screens.Editors; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneRoundEditorScreen : LadderTestScene + public class TestSceneRoundEditorScreen : TournamentTestScene { public TestSceneRoundEditorScreen() { diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs index f3e65919eb..b240ef3ae5 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs @@ -2,16 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Game.Tests.Visual; +using osu.Framework.Graphics; +using osu.Game.Tournament.Components; using osu.Game.Tournament.Screens.Schedule; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneScheduleScreen : OsuTestScene + public class TestSceneScheduleScreen : TournamentTestScene { [BackgroundDependencyLoader] private void load() { + Add(new TourneyVideo("main") { RelativeSizeAxes = Axes.Both }); Add(new ScheduleScreen()); } } diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingEditorScreen.cs new file mode 100644 index 0000000000..2722021216 --- /dev/null +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingEditorScreen.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.Framework.Allocation; +using osu.Game.Tournament.Models; +using osu.Game.Tournament.Screens.Editors; + +namespace osu.Game.Tournament.Tests.Screens +{ + public class TestSceneSeedingEditorScreen : TournamentTestScene + { + [Cached] + private readonly LadderInfo ladder = new LadderInfo(); + + public TestSceneSeedingEditorScreen() + { + var match = CreateSampleMatch(); + + Add(new SeedingEditorScreen(match.Team1.Value, new TeamEditorScreen()) + { + Width = 0.85f // create room for control panel + }); + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs new file mode 100644 index 0000000000..d414d8e36e --- /dev/null +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.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.Allocation; +using osu.Framework.Graphics; +using osu.Game.Tournament.Models; +using osu.Game.Tournament.Screens.TeamIntro; + +namespace osu.Game.Tournament.Tests.Screens +{ + public class TestSceneSeedingScreen : TournamentTestScene + { + [Cached] + private readonly LadderInfo ladder = new LadderInfo(); + + [BackgroundDependencyLoader] + private void load() + { + Add(new SeedingScreen + { + FillMode = FillMode.Fit, + FillAspectRatio = 16 / 9f + }); + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.cs new file mode 100644 index 0000000000..650b4c5412 --- /dev/null +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSetupScreen.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 osu.Game.Tournament.Screens; + +namespace osu.Game.Tournament.Tests.Screens +{ + public class TestSceneSetupScreen : TournamentTestScene + { + [BackgroundDependencyLoader] + private void load() + { + Add(new SetupScreen()); + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneShowcaseScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneShowcaseScreen.cs index edf1477b06..8c43e25416 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneShowcaseScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneShowcaseScreen.cs @@ -2,12 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Game.Tests.Visual; using osu.Game.Tournament.Screens.Showcase; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneShowcaseScreen : OsuTestScene + public class TestSceneShowcaseScreen : TournamentTestScene { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneStablePathSelectScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneStablePathSelectScreen.cs new file mode 100644 index 0000000000..6e63b2d799 --- /dev/null +++ b/osu.Game.Tournament.Tests/Screens/TestSceneStablePathSelectScreen.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 osu.Game.Tournament.Screens; + +namespace osu.Game.Tournament.Tests.Screens +{ + public class TestSceneStablePathSelectScreen : TournamentTestScene + { + public TestSceneStablePathSelectScreen() + { + AddStep("Add screen", () => Add(new StablePathSelectTestScreen())); + } + + private class StablePathSelectTestScreen : StablePathSelectScreen + { + protected override void ChangePath() + { + Expire(); + } + + protected override void AutoDetect() + { + Expire(); + } + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs index 097bad4a02..fc6574ec8a 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamEditorScreen.cs @@ -5,7 +5,7 @@ using osu.Game.Tournament.Screens.Editors; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneTeamEditorScreen : LadderTestScene + public class TestSceneTeamEditorScreen : TournamentTestScene { public TestSceneTeamEditorScreen() { diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs index 3d340e393c..b3f78c92d9 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamIntroScreen.cs @@ -3,17 +3,16 @@ using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.TeamIntro; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneTeamIntroScreen : LadderTestScene + public class TestSceneTeamIntroScreen : TournamentTestScene { [Cached] - private readonly Bindable currentMatch = new Bindable(); + private readonly LadderInfo ladder = new LadderInfo(); [BackgroundDependencyLoader] private void load() @@ -22,7 +21,7 @@ namespace osu.Game.Tournament.Tests.Screens 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"); - currentMatch.Value = match; + ladder.CurrentMatch.Value = match; Add(new TeamIntroScreen { diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs index 6f5e17a36e..3ca58dcaf4 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneTeamWinScreen.cs @@ -3,26 +3,20 @@ using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.TeamWin; namespace osu.Game.Tournament.Tests.Screens { - public class TestSceneTeamWinScreen : LadderTestScene + public class TestSceneTeamWinScreen : TournamentTestScene { - [Cached] - private readonly Bindable currentMatch = new Bindable(); - [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"); + var match = Ladder.CurrentMatch.Value; + match.Round.Value = Ladder.Rounds.FirstOrDefault(g => g.Name.Value == "Finals"); - currentMatch.Value = match; + match.Completed.Value = true; Add(new TeamWinScreen { diff --git a/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs b/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs index 378614343a..4d134ce4af 100644 --- a/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs +++ b/osu.Game.Tournament.Tests/TestSceneTournamentSceneManager.cs @@ -3,11 +3,10 @@ using osu.Framework.Allocation; using osu.Framework.Platform; -using osu.Game.Tests.Visual; namespace osu.Game.Tournament.Tests { - public class TestSceneTournamentSceneManager : OsuTestScene + public class TestSceneTournamentSceneManager : TournamentTestScene { [BackgroundDependencyLoader] private void load(Storage storage) diff --git a/osu.Game.Tournament.Tests/TournamentTestScene.cs b/osu.Game.Tournament.Tests/TournamentTestScene.cs new file mode 100644 index 0000000000..d22da25f9d --- /dev/null +++ b/osu.Game.Tournament.Tests/TournamentTestScene.cs @@ -0,0 +1,166 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Tests.Visual; +using osu.Game.Tournament.IPC; +using osu.Game.Tournament.Models; +using osu.Game.Users; + +namespace osu.Game.Tournament.Tests +{ + public abstract class TournamentTestScene : OsuTestScene + { + [Cached] + protected LadderInfo Ladder { get; private set; } = new LadderInfo(); + + [Resolved] + private RulesetStore rulesetStore { get; set; } + + [Cached] + protected MatchIPCInfo IPCInfo { get; private set; } = new MatchIPCInfo(); + + [BackgroundDependencyLoader] + private void load(Storage storage) + { + Ladder.Ruleset.Value ??= rulesetStore.AvailableRulesets.First(); + + TournamentMatch match = CreateSampleMatch(); + + Ladder.Rounds.Add(match.Round.Value); + Ladder.Matches.Add(match); + Ladder.Teams.Add(match.Team1.Value); + Ladder.Teams.Add(match.Team2.Value); + + Ladder.CurrentMatch.Value = match; + + Ruleset.BindTo(Ladder.Ruleset); + Dependencies.CacheAs(new StableInfo(storage)); + } + + public static TournamentMatch CreateSampleMatch() => new TournamentMatch + { + Team1 = + { + Value = new TournamentTeam + { + Acronym = { Value = "JPN" }, + FlagName = { Value = "JP" }, + FullName = { Value = "Japan" }, + LastYearPlacing = { Value = 10 }, + Seed = { Value = "Low" }, + SeedingResults = + { + new SeedingResult + { + Mod = { Value = "NM" }, + Seed = { Value = 10 }, + Beatmaps = + { + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 12345672, + Seed = { Value = 24 }, + }, + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 1234567, + Seed = { Value = 12 }, + }, + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 1234567, + Seed = { Value = 16 }, + } + } + }, + new SeedingResult + { + Mod = { Value = "DT" }, + Seed = { Value = 5 }, + Beatmaps = + { + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 234567, + Seed = { Value = 3 }, + }, + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 234567, + Seed = { Value = 6 }, + }, + new SeedingBeatmap + { + BeatmapInfo = CreateSampleBeatmapInfo(), + Score = 234567, + Seed = { Value = 12 }, + } + } + } + }, + Players = + { + new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 12 } } }, + new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 16 } } }, + new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 20 } } }, + new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 24 } } }, + new User { Username = "Hello", Statistics = new UserStatistics { Ranks = new UserStatistics.UserRanks { Global = 30 } } }, + } + } + }, + Team2 = + { + Value = new TournamentTeam + { + Acronym = { Value = "USA" }, + FlagName = { Value = "US" }, + FullName = { Value = "United States" }, + Players = + { + new User { Username = "Hello" }, + new User { Username = "Hello" }, + new User { Username = "Hello" }, + new User { Username = "Hello" }, + new User { Username = "Hello" }, + } + } + }, + Round = + { + Value = new TournamentRound { Name = { Value = "Quarterfinals" } } + } + }; + + public static BeatmapInfo CreateSampleBeatmapInfo() => + new BeatmapInfo { Metadata = new BeatmapMetadata { Title = "Test Title", Artist = "Test Artist", ID = RNG.Next(0, 1000000) } }; + + protected override ITestSceneTestRunner CreateRunner() => new TournamentTestSceneTestRunner(); + + public class TournamentTestSceneTestRunner : TournamentGameBase, ITestSceneTestRunner + { + private TestSceneTestRunner.TestRunner runner; + + protected override void LoadAsyncComplete() + { + // this has to be run here rather than LoadComplete because + // TestScene.cs is checking the IsLoaded state (on another thread) and expects + // the runner to be loaded at that point. + Add(runner = new TestSceneTestRunner.TestRunner()); + } + + public void RunTestBlocking(TestScene test) => runner.RunTestBlocking(test); + } + } +} diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index dad2fe0877..5d55196dcf 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -5,13 +5,13 @@ - + - + WinExe - netcoreapp2.2 + netcoreapp3.1 diff --git a/osu.Game.Tournament/Components/ControlPanel.cs b/osu.Game.Tournament/Components/ControlPanel.cs index a9bb1bf42f..ef8c8767e0 100644 --- a/osu.Game.Tournament/Components/ControlPanel.cs +++ b/osu.Game.Tournament/Components/ControlPanel.cs @@ -5,7 +5,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; @@ -23,9 +22,9 @@ namespace osu.Game.Tournament.Components public ControlPanel() { - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.Y; AlwaysPresent = true; - Width = 0.15f; + Width = TournamentSceneManager.CONTROL_AREA_WIDTH; Anchor = Anchor.TopRight; InternalChildren = new Drawable[] @@ -35,7 +34,7 @@ namespace osu.Game.Tournament.Components RelativeSizeAxes = Axes.Both, Colour = new Color4(54, 54, 54, 255) }, - new OsuSpriteText + new TournamentSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -48,8 +47,8 @@ namespace osu.Game.Tournament.Components Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Width = 0.75f, Position = new Vector2(0, 35f), + Padding = new MarginPadding(5), Direction = FillDirection.Vertical, Spacing = new Vector2(0, 5f), }, diff --git a/osu.Game.Tournament/Components/DateTextBox.cs b/osu.Game.Tournament/Components/DateTextBox.cs index ee7e350970..5782301a65 100644 --- a/osu.Game.Tournament/Components/DateTextBox.cs +++ b/osu.Game.Tournament/Components/DateTextBox.cs @@ -10,33 +10,34 @@ namespace osu.Game.Tournament.Components { public class DateTextBox : SettingsTextBox { - public new Bindable Bindable + public new Bindable Current { - get => bindable; + get => current; set { - bindable = value.GetBoundCopy(); - bindable.BindValueChanged(dto => - base.Bindable.Value = dto.NewValue.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"), true); + current = value.GetBoundCopy(); + current.BindValueChanged(dto => + base.Current.Value = dto.NewValue.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ"), true); } } // hold a reference to the provided bindable so we don't have to in every settings section. - private Bindable bindable; + private Bindable current = new Bindable(); public DateTextBox() { - base.Bindable = new Bindable(); - ((OsuTextBox)Control).OnCommit = (sender, newText) => + base.Current = new Bindable(); + + ((OsuTextBox)Control).OnCommit += (sender, newText) => { try { - bindable.Value = DateTimeOffset.Parse(sender.Text); + current.Value = DateTimeOffset.Parse(sender.Text); } catch { // reset textbox content to its last valid state on a parse failure. - bindable.TriggerChange(); + current.TriggerChange(); } }; } diff --git a/osu.Game.Tournament/Components/DrawableTeamFlag.cs b/osu.Game.Tournament/Components/DrawableTeamFlag.cs new file mode 100644 index 0000000000..75991a1ab8 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTeamFlag.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTeamFlag : Container + { + private readonly TournamentTeam team; + + [UsedImplicitly] + private Bindable flag; + + private Sprite flagSprite; + + public DrawableTeamFlag(TournamentTeam team) + { + this.team = team; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + if (team == null) return; + + Size = new Vector2(75, 50); + Masking = true; + CornerRadius = 5; + Child = flagSprite = new Sprite + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill + }; + + (flag = team.FlagName.GetBoundCopy()).BindValueChanged(acronym => flagSprite.Texture = textures.Get($@"Flags/{team.FlagName}"), true); + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTeamHeader.cs b/osu.Game.Tournament/Components/DrawableTeamHeader.cs new file mode 100644 index 0000000000..3d9e8a6e00 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTeamHeader.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.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTeamHeader : TournamentSpriteTextWithBackground + { + public DrawableTeamHeader(TeamColour colour) + { + Background.Colour = TournamentGame.GetTeamColour(colour); + + Text.Colour = TournamentGame.TEXT_COLOUR; + Text.Text = $"Team {colour}".ToUpperInvariant(); + Text.Scale = new Vector2(0.6f); + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTeamTitle.cs b/osu.Game.Tournament/Components/DrawableTeamTitle.cs new file mode 100644 index 0000000000..5aac37259f --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTeamTitle.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Textures; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTeamTitle : TournamentSpriteTextWithBackground + { + private readonly TournamentTeam team; + + [UsedImplicitly] + private Bindable acronym; + + public DrawableTeamTitle(TournamentTeam team) + { + this.team = team; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + if (team == null) return; + + (acronym = team.Acronym.GetBoundCopy()).BindValueChanged(acronym => Text.Text = team?.FullName.Value ?? string.Empty, true); + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTeamTitleWithHeader.cs b/osu.Game.Tournament/Components/DrawableTeamTitleWithHeader.cs new file mode 100644 index 0000000000..ceffe3d315 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTeamTitleWithHeader.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTeamTitleWithHeader : CompositeDrawable + { + public DrawableTeamTitleWithHeader(TournamentTeam team, TeamColour colour) + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new DrawableTeamHeader(colour), + new DrawableTeamTitle(team), + } + }; + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTeamWithPlayers.cs b/osu.Game.Tournament/Components/DrawableTeamWithPlayers.cs new file mode 100644 index 0000000000..e949bf9881 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTeamWithPlayers.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 System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Tournament.Models; +using osu.Game.Users; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTeamWithPlayers : CompositeDrawable + { + public DrawableTeamWithPlayers(TournamentTeam team, TeamColour colour) + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(30), + Children = new Drawable[] + { + new DrawableTeamTitleWithHeader(team, colour), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding { Left = 10 }, + Spacing = new Vector2(30), + Children = new Drawable[] + { + new FillFlowContainer + { + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + ChildrenEnumerable = team?.Players.Select(createPlayerText).Take(5) ?? Enumerable.Empty() + }, + new FillFlowContainer + { + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + ChildrenEnumerable = team?.Players.Select(createPlayerText).Skip(5) ?? Enumerable.Empty() + }, + } + }, + } + }, + }; + + TournamentSpriteText createPlayerText(User p) => + new TournamentSpriteText + { + Text = p.Username, + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.SemiBold), + Colour = Color4.White, + }; + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTournamentHeaderLogo.cs b/osu.Game.Tournament/Components/DrawableTournamentHeaderLogo.cs new file mode 100644 index 0000000000..3f5ab42fd7 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTournamentHeaderLogo.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.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTournamentHeaderLogo : CompositeDrawable + { + public DrawableTournamentHeaderLogo() + { + InternalChild = new LogoSprite(); + + Height = 82; + RelativeSizeAxes = Axes.X; + } + + private class LogoSprite : Sprite + { + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + RelativeSizeAxes = Axes.Both; + FillMode = FillMode.Fit; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Texture = textures.Get("header-logo"); + } + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTournamentHeaderText.cs b/osu.Game.Tournament/Components/DrawableTournamentHeaderText.cs new file mode 100644 index 0000000000..99d914fed4 --- /dev/null +++ b/osu.Game.Tournament/Components/DrawableTournamentHeaderText.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Tournament.Components +{ + public class DrawableTournamentHeaderText : CompositeDrawable + { + public DrawableTournamentHeaderText(bool center = true) + { + InternalChild = new TextSprite + { + Anchor = center ? Anchor.Centre : Anchor.TopLeft, + Origin = center ? Anchor.Centre : Anchor.TopLeft, + }; + + Height = 22; + RelativeSizeAxes = Axes.X; + } + + private class TextSprite : Sprite + { + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + RelativeSizeAxes = Axes.Both; + FillMode = FillMode.Fit; + + Texture = textures.Get("header-text"); + } + } + } +} diff --git a/osu.Game.Tournament/Components/DrawableTournamentTeam.cs b/osu.Game.Tournament/Components/DrawableTournamentTeam.cs index 361bd92770..b9442a67f5 100644 --- a/osu.Game.Tournament/Components/DrawableTournamentTeam.cs +++ b/osu.Game.Tournament/Components/DrawableTournamentTeam.cs @@ -4,12 +4,9 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Components @@ -18,28 +15,20 @@ namespace osu.Game.Tournament.Components { public readonly TournamentTeam Team; - protected readonly Sprite Flag; - protected readonly OsuSpriteText AcronymText; + protected readonly Container Flag; + protected readonly TournamentSpriteText AcronymText; [UsedImplicitly] private Bindable acronym; - [UsedImplicitly] - private Bindable flag; - protected DrawableTournamentTeam(TournamentTeam team) { Team = team; - Flag = new Sprite + Flag = new DrawableTeamFlag(team); + AcronymText = new TournamentSpriteText { - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit - }; - - AcronymText = new OsuSpriteText - { - Font = OsuFont.GetFont(weight: FontWeight.Regular), + Font = OsuFont.Torus.With(weight: FontWeight.Regular), }; } @@ -49,7 +38,6 @@ namespace osu.Game.Tournament.Components if (Team == null) return; (acronym = Team.Acronym.GetBoundCopy()).BindValueChanged(acronym => AcronymText.Text = Team?.Acronym.Value?.ToUpperInvariant() ?? string.Empty, true); - (flag = Team.FlagName.GetBoundCopy()).BindValueChanged(acronym => Flag.Texture = textures.Get($@"Flags/{Team.FlagName}"), true); } } } diff --git a/osu.Game.Tournament/Components/IPCErrorDialog.cs b/osu.Game.Tournament/Components/IPCErrorDialog.cs new file mode 100644 index 0000000000..dc039cd3bc --- /dev/null +++ b/osu.Game.Tournament/Components/IPCErrorDialog.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.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Tournament.Components +{ + public class IPCErrorDialog : PopupDialog + { + public IPCErrorDialog(string headerText, string bodyText) + { + Icon = FontAwesome.Regular.SadTear; + HeaderText = headerText; + BodyText = bodyText; + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Alright.", + Action = () => Expire() + } + }; + } + } +} diff --git a/osu.Game.Tournament/Components/RoundDisplay.cs b/osu.Game.Tournament/Components/RoundDisplay.cs new file mode 100644 index 0000000000..c0002e6804 --- /dev/null +++ b/osu.Game.Tournament/Components/RoundDisplay.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Components +{ + public class RoundDisplay : CompositeDrawable + { + public RoundDisplay(TournamentMatch match) + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new DrawableTournamentHeaderText(false) + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + }, + new TournamentSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Text = match.Round.Value?.Name.Value ?? "Unknown Round", + Font = OsuFont.Torus.With(size: 26, weight: FontWeight.SemiBold) + }, + } + } + }; + } + } +} diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index 7005c068ae..cafec0a88b 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -3,16 +3,16 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; using osu.Game.Screens.Menu; using osuTK; using osuTK.Graphics; @@ -23,6 +23,11 @@ namespace osu.Game.Tournament.Components { private BeatmapInfo beatmap; + public const float HEIGHT = 145 / 2f; + + [Resolved] + private IBindable ruleset { get; set; } + public BeatmapInfo Beatmap { get => beatmap; @@ -48,15 +53,7 @@ namespace osu.Game.Tournament.Components } } - private Container panelContents; - private Container innerPanel; - private Container outerPanel; - private TournamentBeatmapPanel panel; - - private float panelWidth => expanded ? 0.6f : 1; - - private const float main_width = 0.97f; - private const float inner_panel_width = 0.7f; + private FillFlowContainer flow; private bool expanded; @@ -66,84 +63,30 @@ namespace osu.Game.Tournament.Components set { expanded = value; - panel?.ResizeWidthTo(panelWidth, 800, Easing.OutQuint); - - if (expanded) - { - innerPanel.ResizeWidthTo(inner_panel_width, 800, Easing.OutQuint); - outerPanel.ResizeWidthTo(main_width, 800, Easing.OutQuint); - } - else - { - innerPanel.ResizeWidthTo(1, 800, Easing.OutQuint); - outerPanel.ResizeWidthTo(0.25f, 800, Easing.OutQuint); - } + flow.Direction = expanded ? FillDirection.Full : FillDirection.Vertical; } } + // Todo: This is a hack for https://github.com/ppy/osu-framework/issues/3617 since this container is at the very edge of the screen and potentially initially masked away. + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + [BackgroundDependencyLoader] private void load() { - RelativeSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; InternalChildren = new Drawable[] { - outerPanel = new Container + flow = new FillFlowContainer { - Masking = true, - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.2f), - Type = EdgeEffectType.Shadow, - Radius = 5, - }, RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + LayoutDuration = 500, + LayoutEasing = Easing.OutQuint, + Direction = FillDirection.Full, Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - RelativePositionAxes = Axes.X, - X = -(1 - main_width) / 2, - Y = -10, - Width = main_width, - Height = TournamentBeatmapPanel.HEIGHT, - CornerRadius = TournamentBeatmapPanel.HEIGHT / 2, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.93f), - }, - new OsuLogo - { - Triangles = false, - Colour = OsuColour.Gray(0.33f), - Scale = new Vector2(0.08f), - Margin = new MarginPadding(50), - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - }, - innerPanel = new Container - { - Masking = true, - CornerRadius = TournamentBeatmapPanel.HEIGHT / 2, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Width = inner_panel_width, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.86f), - }, - panelContents = new Container - { - RelativeSizeAxes = Axes.Both, - } - } - } - } } }; @@ -154,7 +97,7 @@ namespace osu.Game.Tournament.Components { if (beatmap == null) { - panelContents.Clear(); + flow.Clear(); return; } @@ -163,7 +106,8 @@ namespace osu.Game.Tournament.Components string hardRockExtra = ""; string srExtra = ""; - //var ar = beatmap.BaseDifficulty.ApproachRate; + var ar = beatmap.BaseDifficulty.ApproachRate; + if ((mods & LegacyMods.HardRock) > 0) { hardRockExtra = "*"; @@ -172,45 +116,126 @@ namespace osu.Game.Tournament.Components if ((mods & LegacyMods.DoubleTime) > 0) { - //ar *= 1.5f; + // temporary local calculation (taken from OsuDifficultyCalculator) + double preempt = (int)BeatmapDifficulty.DifficultyRange(ar, 1800, 1200, 450) / 1.5; + ar = (float)(preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5); + bpm *= 1.5f; length /= 1.5f; srExtra = "*"; } - panelContents.Children = new Drawable[] + (string heading, string content)[] stats; + + switch (ruleset.Value.ID) { - new DiffPiece(("Length", TimeSpan.FromMilliseconds(length).ToString(@"mm\:ss"))) + default: + stats = new (string heading, string content)[] + { + ("CS", $"{beatmap.BaseDifficulty.CircleSize:0.#}{hardRockExtra}"), + ("AR", $"{ar:0.#}{hardRockExtra}"), + ("OD", $"{beatmap.BaseDifficulty.OverallDifficulty:0.#}{hardRockExtra}"), + }; + break; + + case 1: + case 3: + stats = new (string heading, string content)[] + { + ("OD", $"{beatmap.BaseDifficulty.OverallDifficulty:0.#}{hardRockExtra}"), + ("HP", $"{beatmap.BaseDifficulty.DrainRate:0.#}{hardRockExtra}") + }; + break; + + case 2: + stats = new (string heading, string content)[] + { + ("CS", $"{beatmap.BaseDifficulty.CircleSize:0.#}{hardRockExtra}"), + ("AR", $"{ar:0.#}"), + }; + break; + } + + flow.Children = new Drawable[] + { + new Container { - Anchor = Anchor.CentreLeft, - Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = HEIGHT, + Width = 0.5f, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + + Content = new[] + { + new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new DiffPiece(stats), + new DiffPiece(("Star Rating", $"{beatmap.StarDifficulty:0.#}{srExtra}")) + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new DiffPiece(("Length", TimeSpan.FromMilliseconds(length).ToString(@"mm\:ss"))), + new DiffPiece(("BPM", $"{bpm:0.#}")) + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f, + }, + new OsuLogo + { + Triangles = false, + Scale = new Vector2(0.08f), + Margin = new MarginPadding(50), + X = -10, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + }, + } + }, + }, + } + } + } }, - new DiffPiece(("BPM", $"{bpm:0.#}")) + new TournamentBeatmapPanel(beatmap) { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopLeft - }, - new DiffPiece( - //("CS", $"{beatmap.BaseDifficulty.CircleSize:0.#}{hardRockExtra}"), - //("AR", $"{ar:0.#}{srExtra}"), - ("OD", $"{beatmap.BaseDifficulty.OverallDifficulty:0.#}{hardRockExtra}"), - ("HP", $"{beatmap.BaseDifficulty.DrainRate:0.#}{hardRockExtra}") - ) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.BottomRight - }, - new DiffPiece(("Star Rating", $"{beatmap.StarDifficulty:0.#}{srExtra}")) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.TopRight - }, - panel = new TournamentBeatmapPanel(beatmap) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(panelWidth, 1) + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Height = HEIGHT, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, } }; } @@ -222,28 +247,27 @@ namespace osu.Game.Tournament.Components Margin = new MarginPadding { Horizontal = 15, Vertical = 1 }; AutoSizeAxes = Axes.Both; - void cp(SpriteText s, Color4 colour) + static void cp(SpriteText s, bool bold) { - s.Colour = colour; - s.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 15); + s.Font = OsuFont.Torus.With(weight: bold ? FontWeight.Bold : FontWeight.Regular, size: 15); } for (var i = 0; i < tuples.Length; i++) { - var tuple = tuples[i]; + var (heading, content) = tuples[i]; if (i > 0) { AddText(" / ", s => { - cp(s, OsuColour.Gray(0.33f)); + cp(s, false); s.Spacing = new Vector2(-2, 0); }); } - AddText(new OsuSpriteText { Text = tuple.heading }, s => cp(s, OsuColour.Gray(0.33f))); - AddText(" ", s => cp(s, OsuColour.Gray(0.33f))); - AddText(new OsuSpriteText { Text = tuple.content }, s => cp(s, OsuColour.Gray(0.5f))); + AddText(new TournamentSpriteText { Text = heading }, s => cp(s, false)); + AddText(" ", s => cp(s, false)); + AddText(new TournamentSpriteText { Text = content }, s => cp(s, true)); } } } diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs index d5e28c1e3e..477bf4bd63 100644 --- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs +++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs @@ -15,9 +15,7 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Models; -using osuTK; using osuTK.Graphics; namespace osu.Game.Tournament.Components @@ -28,7 +26,7 @@ namespace osu.Game.Tournament.Components private readonly string mods; private const float horizontal_padding = 10; - private const float vertical_padding = 5; + private const float vertical_padding = 10; public const float HEIGHT = 50; @@ -51,7 +49,6 @@ namespace osu.Game.Tournament.Components currentMatch.BindValueChanged(matchChanged); currentMatch.BindTo(ladder.CurrentMatch); - CornerRadius = HEIGHT / 2; Masking = true; AddRangeInternal(new Drawable[] @@ -70,52 +67,47 @@ namespace osu.Game.Tournament.Components new FillFlowContainer { AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Padding = new MarginPadding(vertical_padding), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding(15), Direction = FillDirection.Vertical, Children = new Drawable[] { - new OsuSpriteText + new TournamentSpriteText { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, Text = new LocalisedString(( $"{Beatmap.Metadata.ArtistUnicode ?? Beatmap.Metadata.Artist} - {Beatmap.Metadata.TitleUnicode ?? Beatmap.Metadata.Title}", $"{Beatmap.Metadata.Artist} - {Beatmap.Metadata.Title}")), - Font = OsuFont.GetFont(weight: FontWeight.Bold, italics: true), + Font = OsuFont.Torus.With(weight: FontWeight.Bold), }, new FillFlowContainer { AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Padding = new MarginPadding(vertical_padding), Direction = FillDirection.Horizontal, Children = new Drawable[] { - new OsuSpriteText + new TournamentSpriteText { Text = "mapper", Padding = new MarginPadding { Right = 5 }, - Font = OsuFont.GetFont(italics: true, weight: FontWeight.Regular, size: 14) + Font = OsuFont.Torus.With(weight: FontWeight.Regular, size: 14) }, - new OsuSpriteText + new TournamentSpriteText { Text = Beatmap.Metadata.AuthorString, Padding = new MarginPadding { Right = 20 }, - Font = OsuFont.GetFont(italics: true, weight: FontWeight.Bold, size: 14) + Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 14) }, - new OsuSpriteText + new TournamentSpriteText { Text = "difficulty", Padding = new MarginPadding { Right = 5 }, - Font = OsuFont.GetFont(italics: true, weight: FontWeight.Regular, size: 14) + Font = OsuFont.Torus.With(weight: FontWeight.Regular, size: 14) }, - new OsuSpriteText + new TournamentSpriteText { Text = Beatmap.Version, - Font = OsuFont.GetFont(italics: true, weight: FontWeight.Bold, size: 14) + Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 14) }, } } @@ -125,20 +117,30 @@ namespace osu.Game.Tournament.Components { RelativeSizeAxes = Axes.Both, Colour = Color4.Gray, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Alpha = 0, }, }); if (!string.IsNullOrEmpty(mods)) - AddInternal(new Sprite + { + AddInternal(new Container { - Texture = textures.Get($"mods/{mods}"), + RelativeSizeAxes = Axes.Y, + Width = 60, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - Margin = new MarginPadding(20), - Scale = new Vector2(0.5f) + Margin = new MarginPadding(10), + Child = new Sprite + { + FillMode = FillMode.Fit, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Texture = textures.Get($"mods/{mods}"), + } }); + } } private void matchChanged(ValueChangedEvent match) @@ -168,16 +170,7 @@ namespace osu.Game.Tournament.Components BorderThickness = 6; - switch (found.Team) - { - case TeamColour.Red: - BorderColour = Color4.Red; - break; - - case TeamColour.Blue: - BorderColour = Color4.Blue; - break; - } + BorderColour = TournamentGame.GetTeamColour(found.Team); switch (found.Type) { diff --git a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs index 48c5b9bd35..fe22d1e76d 100644 --- a/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs +++ b/osu.Game.Tournament/Components/TournamentMatchChatDisplay.cs @@ -9,8 +9,6 @@ using osu.Game.Online.Chat; using osu.Game.Overlays.Chat; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; -using osuTK; -using osuTK.Graphics; namespace osu.Game.Tournament.Components { @@ -23,11 +21,11 @@ namespace osu.Game.Tournament.Components public TournamentMatchChatDisplay() { RelativeSizeAxes = Axes.X; - Y = 100; - Size = new Vector2(0.45f, 112); - Margin = new MarginPadding(10); - Anchor = Anchor.BottomCentre; - Origin = Anchor.BottomCentre; + Height = 144; + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + + CornerRadius = 0; } [BackgroundDependencyLoader(true)] @@ -66,8 +64,23 @@ namespace osu.Game.Tournament.Components } } + public void Expand() => this.FadeIn(300); + + public void Contract() => this.FadeOut(200); + protected override ChatLine CreateMessage(Message message) => new MatchMessage(message); + protected override StandAloneDrawableChannel CreateDrawableChannel(Channel channel) => new MatchChannel(channel); + + public class MatchChannel : StandAloneDrawableChannel + { + public MatchChannel(Channel channel) + : base(channel) + { + ScrollbarVisible = false; + } + } + protected class MatchMessage : StandAloneMessage { public MatchMessage(Message message) @@ -75,19 +88,15 @@ namespace osu.Game.Tournament.Components { } - [BackgroundDependencyLoader] private void load(LadderInfo info) { - //if (info.CurrentMatch.Value.Team1.Value.Players.Any(u => u.Id == Message.Sender.Id)) - // ColourBox.Colour = red; - //else if (info.CurrentMatch.Value.Team2.Value.Players.Any(u => u.Id == Message.Sender.Id)) - // ColourBox.Colour = blue; - //else if (Message.Sender.Colour != null) - // SenderText.Colour = ColourBox.Colour = OsuColour.FromHex(Message.Sender.Colour); + // if (info.CurrentMatch.Value.Team1.Value.Players.Any(u => u.Id == Message.Sender.Id)) + // SenderText.Colour = TournamentGame.COLOUR_RED; + // else if (info.CurrentMatch.Value.Team2.Value.Players.Any(u => u.Id == Message.Sender.Id)) + // SenderText.Colour = TournamentGame.COLOUR_BLUE; + // else if (Message.Sender.Colour != null) + // SenderText.Colour = ColourBox.Colour = Color4Extensions.FromHex(Message.Sender.Colour); } - - private readonly Color4 red = new Color4(186, 0, 18, 255); - private readonly Color4 blue = new Color4(17, 136, 170, 255); } } } diff --git a/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs b/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.cs new file mode 100644 index 0000000000..d92b9eb605 --- /dev/null +++ b/osu.Game.Tournament/Components/TournamentSpriteTextWithBackground.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.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; + +namespace osu.Game.Tournament.Components +{ + public class TournamentSpriteTextWithBackground : CompositeDrawable + { + protected readonly TournamentSpriteText Text; + protected readonly Box Background; + + public TournamentSpriteTextWithBackground(string text = "") + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + Background = new Box + { + Colour = TournamentGame.ELEMENT_BACKGROUND_COLOUR, + RelativeSizeAxes = Axes.Both, + }, + Text = new TournamentSpriteText + { + Colour = TournamentGame.ELEMENT_FOREGROUND_COLOUR, + Font = OsuFont.Torus.With(weight: FontWeight.SemiBold, size: 50), + Padding = new MarginPadding { Left = 10, Right = 20 }, + Text = text + } + }; + } + } +} diff --git a/osu.Game.Tournament/Components/TourneyVideo.cs b/osu.Game.Tournament/Components/TourneyVideo.cs index 4f4660f645..2709580385 100644 --- a/osu.Game.Tournament/Components/TourneyVideo.cs +++ b/osu.Game.Tournament/Components/TourneyVideo.cs @@ -1,23 +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 System.IO; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Video; +using osu.Framework.Timing; using osu.Game.Graphics; +using osu.Game.Tournament.IO; namespace osu.Game.Tournament.Components { public class TourneyVideo : CompositeDrawable { - private readonly VideoSprite video; + private readonly string filename; + private readonly bool drawFallbackGradient; + private Video video; + private ManualClock manualClock; - public TourneyVideo(Stream stream) + public TourneyVideo(string filename, bool drawFallbackGradient = false) { - if (stream == null) + this.filename = filename; + this.drawFallbackGradient = drawFallbackGradient; + } + + [BackgroundDependencyLoader] + private void load(TournamentVideoResourceStore storage) + { + var stream = storage.GetStream(filename); + + if (stream != null) + { + InternalChild = video = new Video(stream, false) + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Clock = new FramedClock(manualClock = new ManualClock()), + Loop = loop, + }; + } + else if (drawFallbackGradient) { InternalChild = new Box { @@ -25,21 +49,36 @@ namespace osu.Game.Tournament.Components RelativeSizeAxes = Axes.Both, }; } - else - InternalChild = video = new VideoSprite(stream) - { - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - }; } + private bool loop; + public bool Loop { set { + loop = value; if (video != null) video.Loop = value; } } + + public void Reset() + { + if (manualClock != null) + manualClock.CurrentTime = 0; + } + + protected override void Update() + { + base.Update(); + + if (manualClock != null && Clock.ElapsedFrameTime < 100) + { + // we want to avoid seeking as much as possible, because we care about performance, not sync. + // to avoid seeking completely, we only increment out local clock when in an updating state. + manualClock.CurrentTime += Clock.ElapsedFrameTime; + } + } } } diff --git a/osu.Game.Tournament/Configuration/TournamentStorageManager.cs b/osu.Game.Tournament/Configuration/TournamentStorageManager.cs new file mode 100644 index 0000000000..e3d0a9e75c --- /dev/null +++ b/osu.Game.Tournament/Configuration/TournamentStorageManager.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.Framework.Configuration; +using osu.Framework.Platform; + +namespace osu.Game.Tournament.Configuration +{ + public class TournamentStorageManager : IniConfigManager + { + protected override string Filename => "tournament.ini"; + + public TournamentStorageManager(Storage storage) + : base(storage) + { + } + } + + public enum StorageConfig + { + CurrentTournament, + } +} diff --git a/osu.Game.Tournament/IO/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentStorage.cs new file mode 100644 index 0000000000..2e8a6ce667 --- /dev/null +++ b/osu.Game.Tournament/IO/TournamentStorage.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 osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.IO; +using System.IO; +using osu.Game.Tournament.Configuration; + +namespace osu.Game.Tournament.IO +{ + public class TournamentStorage : MigratableStorage + { + private const string default_tournament = "default"; + private readonly Storage storage; + private readonly TournamentStorageManager storageConfig; + + public TournamentStorage(Storage storage) + : base(storage.GetStorageForDirectory("tournaments"), string.Empty) + { + this.storage = storage; + + storageConfig = new TournamentStorageManager(storage); + + if (storage.Exists("tournament.ini")) + { + ChangeTargetStorage(UnderlyingStorage.GetStorageForDirectory(storageConfig.Get(StorageConfig.CurrentTournament))); + } + else + Migrate(UnderlyingStorage.GetStorageForDirectory(default_tournament)); + + Logger.Log("Using tournament storage: " + GetFullPath(string.Empty)); + } + + public override void Migrate(Storage newStorage) + { + // this migration only happens once on moving to the per-tournament storage system. + // listed files are those known at that point in time. + // this can be removed at some point in the future (6 months obsoletion would mean 2021-04-19) + + var source = new DirectoryInfo(storage.GetFullPath("tournament")); + var destination = new DirectoryInfo(newStorage.GetFullPath(".")); + + if (source.Exists) + { + Logger.Log("Migrating tournament assets to default tournament storage."); + CopyRecursive(source, destination); + DeleteRecursive(source); + } + + moveFileIfExists("bracket.json", destination); + moveFileIfExists("drawings.txt", destination); + moveFileIfExists("drawings_results.txt", destination); + moveFileIfExists("drawings.ini", destination); + + ChangeTargetStorage(newStorage); + storageConfig.Set(StorageConfig.CurrentTournament, default_tournament); + storageConfig.Save(); + } + + private void moveFileIfExists(string file, DirectoryInfo destination) + { + if (!storage.Exists(file)) + return; + + Logger.Log($"Migrating {file} to default tournament storage."); + var fileInfo = new System.IO.FileInfo(storage.GetFullPath(file)); + AttemptOperation(() => fileInfo.CopyTo(Path.Combine(destination.FullName, fileInfo.Name), true)); + fileInfo.Delete(); + } + } +} diff --git a/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs b/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs new file mode 100644 index 0000000000..4b26840b79 --- /dev/null +++ b/osu.Game.Tournament/IO/TournamentVideoResourceStore.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.IO.Stores; +using osu.Framework.Platform; + +namespace osu.Game.Tournament.IO +{ + public class TournamentVideoResourceStore : NamespacedResourceStore + { + public TournamentVideoResourceStore(Storage storage) + : base(new StorageBackedResourceStore(storage), "videos") + { + AddExtension("m4v"); + AddExtension("avi"); + AddExtension("mp4"); + } + } +} diff --git a/osu.Game.Tournament/IPC/FileBasedIPC.cs b/osu.Game.Tournament/IPC/FileBasedIPC.cs index 4fd858bd12..999ce61ac8 100644 --- a/osu.Game.Tournament/IPC/FileBasedIPC.cs +++ b/osu.Game.Tournament/IPC/FileBasedIPC.cs @@ -4,11 +4,12 @@ using System; using System.IO; using System.Linq; +using JetBrains.Annotations; using Microsoft.Win32; using osu.Framework.Allocation; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Framework.Platform.Windows; +using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Online.API; @@ -20,175 +21,238 @@ namespace osu.Game.Tournament.IPC { public class FileBasedIPC : MatchIPCInfo { + public Storage IPCStorage { get; private set; } + [Resolved] protected IAPIProvider API { get; private set; } [Resolved] protected RulesetStore Rulesets { get; private set; } + [Resolved] + private GameHost host { get; set; } + + [Resolved] + private LadderInfo ladder { get; set; } + + [Resolved] + private StableInfo stableInfo { get; set; } + private int lastBeatmapId; + private ScheduledDelegate scheduled; + private GetBeatmapRequest beatmapLookupRequest; [BackgroundDependencyLoader] - private void load(LadderInfo ladder, GameHost host) + private void load() { - StableStorage stable; + var stablePath = stableInfo.StablePath ?? findStablePath(); + initialiseIPCStorage(stablePath); + } + + [CanBeNull] + private Storage initialiseIPCStorage(string path) + { + scheduled?.Cancel(); + + IPCStorage = null; try { - stable = new StableStorage(host as DesktopGameHost); + if (string.IsNullOrEmpty(path)) + return null; + + IPCStorage = new DesktopStorage(path, host as DesktopGameHost); + + const string file_ipc_filename = "ipc.txt"; + const string file_ipc_state_filename = "ipc-state.txt"; + const string file_ipc_scores_filename = "ipc-scores.txt"; + const string file_ipc_channel_filename = "ipc-channel.txt"; + + if (IPCStorage.Exists(file_ipc_filename)) + { + scheduled = Scheduler.AddDelayed(delegate + { + try + { + 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()); + + if (lastBeatmapId != beatmapId) + { + beatmapLookupRequest?.Cancel(); + + lastBeatmapId = beatmapId; + + var existing = ladder.CurrentMatch.Value?.Round.Value?.Beatmaps.FirstOrDefault(b => b.ID == beatmapId && b.BeatmapInfo != null); + + if (existing != null) + Beatmap.Value = existing.BeatmapInfo; + else + { + beatmapLookupRequest = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = beatmapId }); + beatmapLookupRequest.Success += b => Beatmap.Value = b.ToBeatmap(Rulesets); + API.Queue(beatmapLookupRequest); + } + } + + Mods.Value = (LegacyMods)mods; + } + } + catch + { + // file might be in use. + } + + try + { + using (var stream = IPCStorage.GetStream(file_ipc_channel_filename)) + using (var sr = new StreamReader(stream)) + { + ChatChannel.Value = sr.ReadLine(); + } + } + catch (Exception) + { + // file might be in use. + } + + try + { + using (var stream = IPCStorage.GetStream(file_ipc_state_filename)) + using (var sr = new StreamReader(stream)) + { + State.Value = (TourneyState)Enum.Parse(typeof(TourneyState), sr.ReadLine()); + } + } + catch (Exception) + { + // file might be in use. + } + + try + { + using (var stream = IPCStorage.GetStream(file_ipc_scores_filename)) + using (var sr = new StreamReader(stream)) + { + Score1.Value = int.Parse(sr.ReadLine()); + Score2.Value = int.Parse(sr.ReadLine()); + } + } + catch (Exception) + { + // file might be in use. + } + }, 250, true); + } } catch (Exception e) { Logger.Error(e, "Stable installation could not be found; disabling file based IPC"); - return; } - const string file_ipc_filename = "ipc.txt"; - const string file_ipc_state_filename = "ipc-state.txt"; - const string file_ipc_scores_filename = "ipc-scores.txt"; - const string file_ipc_channel_filename = "ipc-channel.txt"; - - if (stable.Exists(file_ipc_filename)) - Scheduler.AddDelayed(delegate - { - try - { - using (var stream = stable.GetStream(file_ipc_filename)) - using (var sr = new StreamReader(stream)) - { - var beatmapId = int.Parse(sr.ReadLine()); - var mods = int.Parse(sr.ReadLine()); - - if (lastBeatmapId != beatmapId) - { - lastBeatmapId = beatmapId; - - var existing = ladder.CurrentMatch.Value?.Round.Value?.Beatmaps.FirstOrDefault(b => b.ID == beatmapId && b.BeatmapInfo != null); - - if (existing != null) - Beatmap.Value = existing.BeatmapInfo; - else - { - var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = beatmapId }); - req.Success += b => Beatmap.Value = b.ToBeatmap(Rulesets); - API.Queue(req); - } - } - - Mods.Value = (LegacyMods)mods; - } - } - catch - { - // file might be in use. - } - - try - { - using (var stream = stable.GetStream(file_ipc_channel_filename)) - using (var sr = new StreamReader(stream)) - { - ChatChannel.Value = sr.ReadLine(); - } - } - catch (Exception) - { - // file might be in use. - } - - try - { - using (var stream = stable.GetStream(file_ipc_state_filename)) - using (var sr = new StreamReader(stream)) - { - State.Value = (TourneyState)Enum.Parse(typeof(TourneyState), sr.ReadLine()); - } - } - catch (Exception) - { - // file might be in use. - } - - try - { - using (var stream = stable.GetStream(file_ipc_scores_filename)) - using (var sr = new StreamReader(stream)) - { - Score1.Value = int.Parse(sr.ReadLine()); - Score2.Value = int.Parse(sr.ReadLine()); - } - } - catch (Exception) - { - // file might be in use. - } - }, 250, true); + return IPCStorage; } /// - /// A method of accessing an osu-stable install in a controlled fashion. + /// Manually sets the path to the directory used for inter-process communication with a cutting-edge install. /// - private class StableStorage : WindowsStorage + /// Path to the IPC directory + /// Whether the supplied path was a valid IPC directory. + public bool SetIPCLocation(string path) { - protected override string LocateBasePath() + if (path == null || !ipcFileExistsInDirectory(path)) + return false; + + var newStorage = initialiseIPCStorage(stableInfo.StablePath = path); + if (newStorage == null) + return false; + + stableInfo.SaveChanges(); + return true; + } + + /// + /// Tries to automatically detect the path to the directory used for inter-process communication + /// with a cutting-edge install. + /// + /// Whether an IPC directory was successfully auto-detected. + public bool AutoDetectIPCLocation() => SetIPCLocation(findStablePath()); + + private static bool ipcFileExistsInDirectory(string p) => p != null && File.Exists(Path.Combine(p, "ipc.txt")); + + [CanBeNull] + private string findStablePath() + { + var stableInstallPath = findFromEnvVar() ?? + findFromRegistry() ?? + findFromLocalAppData() ?? + findFromDotFolder(); + + Logger.Log($"Stable path for tourney usage: {stableInstallPath}"); + return stableInstallPath; + } + + private string findFromEnvVar() + { + try { - bool checkExists(string p) - { - return File.Exists(Path.Combine(p, "ipc.txt")); - } + Logger.Log("Trying to find stable with environment variables"); + string stableInstallPath = Environment.GetEnvironmentVariable("OSU_STABLE_PATH"); - string stableInstallPath = string.Empty; - - try - { - try - { - stableInstallPath = "G:\\My Drive\\Main\\osu!tourney"; - - if (checkExists(stableInstallPath)) - return stableInstallPath; - - stableInstallPath = "G:\\My Drive\\Main\\osu!mappool"; - - if (checkExists(stableInstallPath)) - return stableInstallPath; - } - catch - { - } - - try - { - using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) - stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(String.Empty).ToString().Split('"')[1].Replace("osu!.exe", ""); - - if (checkExists(stableInstallPath)) - return stableInstallPath; - } - catch - { - } - - stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"osu!"); - if (checkExists(stableInstallPath)) - return stableInstallPath; - - stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".osu"); - if (checkExists(stableInstallPath)) - return stableInstallPath; - - return null; - } - finally - { - Logger.Log($"Stable path for tourney usage: {stableInstallPath}"); - } + if (ipcFileExistsInDirectory(stableInstallPath)) + return stableInstallPath; } - - public StableStorage(DesktopGameHost host) - : base(string.Empty, host) + catch { } + + return null; + } + + private string findFromLocalAppData() + { + Logger.Log("Trying to find stable in %LOCALAPPDATA%"); + string stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"osu!"); + + if (ipcFileExistsInDirectory(stableInstallPath)) + return stableInstallPath; + + return null; + } + + private string findFromDotFolder() + { + Logger.Log("Trying to find stable in dotfolders"); + string stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".osu"); + + if (ipcFileExistsInDirectory(stableInstallPath)) + return stableInstallPath; + + return null; + } + + private string findFromRegistry() + { + Logger.Log("Trying to find stable in registry"); + + try + { + string stableInstallPath; + + using (RegistryKey key = Registry.ClassesRoot.OpenSubKey("osu")) + stableInstallPath = key?.OpenSubKey(@"shell\open\command")?.GetValue(string.Empty).ToString().Split('"')[1].Replace("osu!.exe", ""); + + if (ipcFileExistsInDirectory(stableInstallPath)) + return stableInstallPath; + } + catch + { + } + + return null; } } } diff --git a/osu.Game.Tournament/Models/LadderInfo.cs b/osu.Game.Tournament/Models/LadderInfo.cs index 547c4eab08..7794019437 100644 --- a/osu.Game.Tournament/Models/LadderInfo.cs +++ b/osu.Game.Tournament/Models/LadderInfo.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; using osu.Framework.Bindables; +using osu.Game.Rulesets; namespace osu.Game.Tournament.Models { @@ -14,6 +15,8 @@ namespace osu.Game.Tournament.Models [Serializable] public class LadderInfo { + public Bindable Ruleset = new Bindable(); + public BindableList Matches = new BindableList(); public BindableList Rounds = new BindableList(); public BindableList Teams = new BindableList(); @@ -21,7 +24,19 @@ namespace osu.Game.Tournament.Models // only used for serialisation public List Progressions = new List(); - [JsonIgnore] + [JsonIgnore] // updated manually in TournamentGameBase public Bindable CurrentMatch = new Bindable(); + + public Bindable ChromaKeyWidth = new BindableInt(1024) + { + MinValue = 640, + MaxValue = 1366, + }; + + public Bindable PlayersPerTeam = new BindableInt(4) + { + MinValue = 3, + MaxValue = 4, + }; } } diff --git a/osu.Game.Tournament/Models/SeedingBeatmap.cs b/osu.Game.Tournament/Models/SeedingBeatmap.cs new file mode 100644 index 0000000000..2cd6fa7188 --- /dev/null +++ b/osu.Game.Tournament/Models/SeedingBeatmap.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.Framework.Bindables; +using osu.Game.Beatmaps; + +namespace osu.Game.Tournament.Models +{ + public class SeedingBeatmap + { + public int ID; + + public BeatmapInfo BeatmapInfo; + + public long Score; + + public Bindable Seed = new BindableInt + { + MinValue = 1, + MaxValue = 64 + }; + } +} diff --git a/osu.Game.Tournament/Models/SeedingResult.cs b/osu.Game.Tournament/Models/SeedingResult.cs new file mode 100644 index 0000000000..87aaf8bf36 --- /dev/null +++ b/osu.Game.Tournament/Models/SeedingResult.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.Framework.Bindables; + +namespace osu.Game.Tournament.Models +{ + public class SeedingResult + { + public List Beatmaps = new List(); + + public Bindable Mod = new Bindable(); + + public Bindable Seed = new BindableInt + { + MinValue = 1, + MaxValue = 64 + }; + } +} diff --git a/osu.Game.Tournament/Models/StableInfo.cs b/osu.Game.Tournament/Models/StableInfo.cs new file mode 100644 index 0000000000..0b0050a245 --- /dev/null +++ b/osu.Game.Tournament/Models/StableInfo.cs @@ -0,0 +1,62 @@ +// 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 Newtonsoft.Json; +using osu.Framework.Platform; + +namespace osu.Game.Tournament.Models +{ + /// + /// Holds the path to locate the osu! stable cutting-edge installation. + /// + [Serializable] + public class StableInfo + { + /// + /// Path to the IPC directory used by the stable (cutting-edge) install. + /// + public string StablePath { get; set; } + + /// + /// Fired whenever stable info is successfully saved to file. + /// + public event Action OnStableInfoSaved; + + private const string config_path = "tournament/stable.json"; + + private readonly Storage storage; + + public StableInfo(Storage storage) + { + this.storage = storage; + + if (!storage.Exists(config_path)) + return; + + using (Stream stream = storage.GetStream(config_path, FileAccess.Read, FileMode.Open)) + using (var sr = new StreamReader(stream)) + { + JsonConvert.PopulateObject(sr.ReadToEnd(), this); + } + } + + public void SaveChanges() + { + using (var stream = storage.GetStream(config_path, FileAccess.Write, FileMode.Create)) + using (var sw = new StreamWriter(stream)) + { + sw.Write(JsonConvert.SerializeObject(this, + new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Ignore, + })); + } + + OnStableInfoSaved?.Invoke(); + } + } +} diff --git a/osu.Game.Tournament/Models/TournamentMatch.cs b/osu.Game.Tournament/Models/TournamentMatch.cs index 06cce3d59e..8ebcbf4e15 100644 --- a/osu.Game.Tournament/Models/TournamentMatch.cs +++ b/osu.Game.Tournament/Models/TournamentMatch.cs @@ -90,6 +90,8 @@ namespace osu.Game.Tournament.Models [JsonIgnore] public TournamentTeam Loser => !Completed.Value ? null : Team1Score.Value > Team2Score.Value ? Team2.Value : Team1.Value; + public TeamColour WinnerColour => Winner == Team1.Value ? TeamColour.Red : TeamColour.Blue; + public int PointsToWin => Round.Value?.BestOf.Value / 2 + 1 ?? 0; /// diff --git a/osu.Game.Tournament/Models/TournamentTeam.cs b/osu.Game.Tournament/Models/TournamentTeam.cs index 54b8a35180..7fca75cea4 100644 --- a/osu.Game.Tournament/Models/TournamentTeam.cs +++ b/osu.Game.Tournament/Models/TournamentTeam.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Users; @@ -29,6 +30,32 @@ namespace osu.Game.Tournament.Models /// public Bindable Acronym = new Bindable(string.Empty); + public BindableList SeedingResults = new BindableList(); + + public double AverageRank + { + get + { + var ranks = Players.Select(p => p.Statistics?.Ranks.Global) + .Where(i => i.HasValue) + .Select(i => i.Value) + .ToArray(); + + if (ranks.Length == 0) + return 0; + + return ranks.Average(); + } + } + + public Bindable Seed = new Bindable(string.Empty); + + public Bindable LastYearPlacing = new BindableInt + { + MinValue = 1, + MaxValue = 64 + }; + [JsonProperty] public BindableList Players { get; set; } = new BindableList(); diff --git a/osu.Game.Tournament/Resources/Fonts/Aquatico-Light.bin b/osu.Game.Tournament/Resources/Fonts/Aquatico-Light.bin deleted file mode 100644 index 42cfdf08de..0000000000 Binary files a/osu.Game.Tournament/Resources/Fonts/Aquatico-Light.bin and /dev/null differ diff --git a/osu.Game.Tournament/Resources/Fonts/Aquatico-Light_0.png b/osu.Game.Tournament/Resources/Fonts/Aquatico-Light_0.png deleted file mode 100644 index 332d9ca056..0000000000 Binary files a/osu.Game.Tournament/Resources/Fonts/Aquatico-Light_0.png and /dev/null differ diff --git a/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular.bin b/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular.bin deleted file mode 100644 index 3047c2eb3e..0000000000 Binary files a/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular.bin and /dev/null differ diff --git a/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular_0.png b/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular_0.png deleted file mode 100644 index 1252d233d3..0000000000 Binary files a/osu.Game.Tournament/Resources/Fonts/Aquatico-Regular_0.png and /dev/null differ diff --git a/osu.Game.Tournament/Resources/countries.json b/osu.Game.Tournament/Resources/countries.json index ec2ca2bf37..7306a8bec5 100644 --- a/osu.Game.Tournament/Resources/countries.json +++ b/osu.Game.Tournament/Resources/countries.json @@ -541,7 +541,7 @@ }, { "FlagName": "MK", - "FullName": "Macedonia", + "FullName": "North Macedonia", "Acronym": "MKD" }, { @@ -811,7 +811,7 @@ }, { "FlagName": "CV", - "FullName": "Cape Verde", + "FullName": "Cabo Verde", "Acronym": "CPV" }, { @@ -821,7 +821,7 @@ }, { "FlagName": "SZ", - "FullName": "Swaziland", + "FullName": "Eswatini", "Acronym": "SWZ" }, { diff --git a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs index fccd35ca9e..0a3163ef43 100644 --- a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs +++ b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs @@ -21,6 +21,7 @@ namespace osu.Game.Tournament.Screens { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, + Depth = float.MinValue, }); } diff --git a/osu.Game.Tournament/Screens/Drawings/Components/Group.cs b/osu.Game.Tournament/Screens/Drawings/Components/Group.cs index 549ff26018..ece1c431e2 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/Group.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/Group.cs @@ -8,8 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osuTK; using osuTK.Graphics; @@ -43,7 +41,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components Colour = new Color4(54, 54, 54, 255) }, // Group name - new OsuSpriteText + new TournamentSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -51,7 +49,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components Position = new Vector2(0, 7f), Text = $"GROUP {name.ToUpperInvariant()}", - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 8), + Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 8), Colour = new Color4(255, 204, 34, 255), }, teams = new FillFlowContainer @@ -117,53 +115,5 @@ namespace osu.Game.Tournament.Screens.Drawings.Components sb.AppendLine(gt.Team.FullName.Value); return sb.ToString(); } - - private class GroupTeam : DrawableTournamentTeam - { - private readonly FillFlowContainer innerContainer; - - public GroupTeam(TournamentTeam team) - : base(team) - { - Width = 36; - AutoSizeAxes = Axes.Y; - - Flag.Anchor = Anchor.TopCentre; - Flag.Origin = Anchor.TopCentre; - - AcronymText.Anchor = Anchor.TopCentre; - AcronymText.Origin = Anchor.TopCentre; - AcronymText.Text = team.Acronym.Value.ToUpperInvariant(); - AcronymText.Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 10); - - InternalChildren = new Drawable[] - { - innerContainer = new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5f), - - Children = new Drawable[] - { - Flag, - AcronymText - } - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - innerContainer.ScaleTo(1.5f); - innerContainer.ScaleTo(1f, 200); - } - } } } diff --git a/osu.Game.Tournament/Screens/Drawings/Components/GroupContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/GroupContainer.cs index 8a66ca7bf6..b9a19090df 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/GroupContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/GroupContainer.cs @@ -57,7 +57,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components groups.Add(g); nextGroupName++; - if (i < (int)Math.Ceiling(numGroups / 2f)) + if (i < (int)MathF.Ceiling(numGroups / 2f)) topGroups.Add(g); else bottomGroups.Add(g); diff --git a/osu.Game.Tournament/Screens/Drawings/Components/GroupTeam.cs b/osu.Game.Tournament/Screens/Drawings/Components/GroupTeam.cs new file mode 100644 index 0000000000..cd252392ba --- /dev/null +++ b/osu.Game.Tournament/Screens/Drawings/Components/GroupTeam.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Screens.Drawings.Components +{ + public class GroupTeam : DrawableTournamentTeam + { + private readonly FillFlowContainer innerContainer; + + public GroupTeam(TournamentTeam team) + : base(team) + { + Width = 36; + AutoSizeAxes = Axes.Y; + + Flag.Anchor = Anchor.TopCentre; + Flag.Origin = Anchor.TopCentre; + + AcronymText.Anchor = Anchor.TopCentre; + AcronymText.Origin = Anchor.TopCentre; + AcronymText.Text = team.Acronym.Value.ToUpperInvariant(); + AcronymText.Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 10); + Flag.Scale = new Vector2(0.48f); + + InternalChildren = new Drawable[] + { + innerContainer = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5f), + + Children = new Drawable[] + { + Flag, + AcronymText + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + innerContainer.ScaleTo(1.5f); + innerContainer.ScaleTo(1f, 200); + } + } +} diff --git a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs index b147d680f0..3ff4718b75 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/ScrollingTeamContainer.cs @@ -83,90 +83,81 @@ namespace osu.Game.Tournament.Screens.Drawings.Components }; } - private ScrollState _scrollState; + private ScrollState scrollState; - private ScrollState scrollState + private void setScrollState(ScrollState newstate) { - get => _scrollState; + if (scrollState == newstate) + return; - set + delayedStateChangeDelegate?.Cancel(); + + switch (scrollState = newstate) { - if (_scrollState == value) - return; + case ScrollState.Scrolling: + resetSelected(); - _scrollState = value; + OnScrollStarted?.Invoke(); - delayedStateChangeDelegate?.Cancel(); + speedTo(1000f, 200); + tracker.FadeOut(100); + break; - switch (value) - { - case ScrollState.Scrolling: - resetSelected(); + case ScrollState.Stopping: + speedTo(0f, 2000); + tracker.FadeIn(200); - OnScrollStarted?.Invoke(); + delayedStateChangeDelegate = Scheduler.AddDelayed(() => setScrollState(ScrollState.Stopped), 2300); + break; - speedTo(1000f, 200); - tracker.FadeOut(100); + case ScrollState.Stopped: + // Find closest to center + if (!Children.Any()) break; - case ScrollState.Stopping: - speedTo(0f, 2000); - tracker.FadeIn(200); + ScrollingTeam closest = null; - delayedStateChangeDelegate = Scheduler.AddDelayed(() => scrollState = ScrollState.Stopped, 2300); - break; + foreach (var c in Children) + { + if (!(c is ScrollingTeam stc)) + continue; - case ScrollState.Stopped: - // Find closest to center - if (!Children.Any()) - break; - - ScrollingTeam closest = null; - - foreach (var c in Children) + if (closest == null) { - var stc = c as ScrollingTeam; - - if (stc == null) - continue; - - if (closest == null) - { - closest = stc; - continue; - } - - float o = Math.Abs(c.Position.X + c.DrawWidth / 2f - DrawWidth / 2f); - float lastOffset = Math.Abs(closest.Position.X + closest.DrawWidth / 2f - DrawWidth / 2f); - - if (o < lastOffset) - closest = stc; + closest = stc; + continue; } - Trace.Assert(closest != null, "closest != null"); + float o = Math.Abs(c.Position.X + c.DrawWidth / 2f - DrawWidth / 2f); + float lastOffset = Math.Abs(closest.Position.X + closest.DrawWidth / 2f - DrawWidth / 2f); - // ReSharper disable once PossibleNullReferenceException - offset += DrawWidth / 2f - (closest.Position.X + closest.DrawWidth / 2f); + if (o < lastOffset) + closest = stc; + } - ScrollingTeam st = closest; + Trace.Assert(closest != null, "closest != null"); - availableTeams.RemoveAll(at => at == st.Team); + // ReSharper disable once PossibleNullReferenceException + offset += DrawWidth / 2f - (closest.Position.X + closest.DrawWidth / 2f); - st.Selected = true; - OnSelected?.Invoke(st.Team); + ScrollingTeam st = closest; - delayedStateChangeDelegate = Scheduler.AddDelayed(() => scrollState = ScrollState.Idle, 10000); - break; + availableTeams.RemoveAll(at => at == st.Team); - case ScrollState.Idle: - resetSelected(); + st.Selected = true; + OnSelected?.Invoke(st.Team); - OnScrollStarted?.Invoke(); + delayedStateChangeDelegate = Scheduler.AddDelayed(() => setScrollState(ScrollState.Idle), 10000); + break; - speedTo(40f, 200); - tracker.FadeOut(100); - break; - } + case ScrollState.Idle: + resetSelected(); + + OnScrollStarted?.Invoke(); + + speedTo(40f, 200); + tracker.FadeOut(100); + break; } } @@ -178,7 +169,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components availableTeams.Add(team); RemoveAll(c => c is ScrollingTeam); - scrollState = ScrollState.Idle; + setScrollState(ScrollState.Idle); } public void AddTeams(IEnumerable teams) @@ -194,7 +185,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components { availableTeams.Clear(); RemoveAll(c => c is ScrollingTeam); - scrollState = ScrollState.Idle; + setScrollState(ScrollState.Idle); } public void RemoveTeam(TournamentTeam team) @@ -203,15 +194,13 @@ namespace osu.Game.Tournament.Screens.Drawings.Components foreach (var c in Children) { - ScrollingTeam st = c as ScrollingTeam; - - if (st == null) - continue; - - if (st.Team == team) + if (c is ScrollingTeam st) { - st.FadeOut(200); - st.Expire(); + if (st.Team == team) + { + st.FadeOut(200); + st.Expire(); + } } } } @@ -221,7 +210,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components if (availableTeams.Count == 0) return; - scrollState = ScrollState.Scrolling; + setScrollState(ScrollState.Scrolling); } public void StopScrolling() @@ -236,13 +225,13 @@ namespace osu.Game.Tournament.Screens.Drawings.Components return; } - scrollState = ScrollState.Stopping; + setScrollState(ScrollState.Stopping); } protected override void LoadComplete() { base.LoadComplete(); - scrollState = ScrollState.Idle; + setScrollState(ScrollState.Idle); } protected override void UpdateAfterChildren() @@ -295,14 +284,13 @@ namespace osu.Game.Tournament.Screens.Drawings.Components { foreach (var c in Children) { - ScrollingTeam st = c as ScrollingTeam; - if (st == null) - continue; - - if (st.Selected) + if (c is ScrollingTeam st) { - st.Selected = false; - RemoveTeam(st.Team); + if (st.Selected) + { + st.Selected = false; + RemoveTeam(st.Team); + } } } } @@ -310,7 +298,7 @@ namespace osu.Game.Tournament.Screens.Drawings.Components private void speedTo(float value, double duration = 0, Easing easing = Easing.None) => this.TransformTo(nameof(speed), value, duration, easing); - private enum ScrollState + protected enum ScrollState { None, Idle, diff --git a/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs b/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs index 1cd942b987..f21f5c9460 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/VisualiserContainer.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; -using osu.Framework.MathUtils; +using osu.Framework.Utils; namespace osu.Game.Tournament.Screens.Drawings.Components { diff --git a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs index 3a14b6d9c2..4c3adeae76 100644 --- a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs +++ b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs @@ -14,8 +14,6 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Drawings.Components; @@ -24,13 +22,13 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Drawings { - public class DrawingsScreen : CompositeDrawable + public class DrawingsScreen : TournamentScreen { private const string results_filename = "drawings_results.txt"; private ScrollingTeamContainer teamsContainer; private GroupContainer groupsContainer; - private OsuSpriteText fullTeamNameText; + private TournamentSpriteText fullTeamNameText; private readonly List allTeams = new List(); @@ -49,8 +47,7 @@ namespace osu.Game.Tournament.Screens.Drawings this.storage = storage; - if (TeamList == null) - TeamList = new StorageBackedTeamList(storage); + TeamList ??= new StorageBackedTeamList(storage); if (!TeamList.Teams.Any()) { @@ -110,39 +107,39 @@ namespace osu.Game.Tournament.Screens.Drawings RelativeSizeAxes = Axes.X, }, // Scrolling team name - fullTeamNameText = new OsuSpriteText + fullTeamNameText = new TournamentSpriteText { Anchor = Anchor.Centre, Origin = Anchor.TopCentre, Position = new Vector2(0, 45f), - Colour = OsuColour.Gray(0.33f), + Colour = OsuColour.Gray(0.95f), Alpha = 0, - Font = OsuFont.GetFont(weight: FontWeight.Light, size: 42), + Font = OsuFont.Torus.With(weight: FontWeight.Light, size: 42), } } }, // Control panel container new ControlPanel { - new OsuButton + new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Begin random", Action = teamsContainer.StartScrolling, }, - new OsuButton + new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Stop random", Action = teamsContainer.StopScrolling, }, - new OsuButton + new TourneyButton { RelativeSizeAxes = Axes.X, @@ -150,7 +147,7 @@ namespace osu.Game.Tournament.Screens.Drawings Action = reloadTeams }, new ControlPanel.Spacer(), - new OsuButton + new TourneyButton { RelativeSizeAxes = Axes.X, @@ -195,7 +192,7 @@ namespace osu.Game.Tournament.Screens.Drawings } } - writeOp = writeOp?.ContinueWith(t => { writeAction(); }) ?? Task.Run((Action)writeAction); + writeOp = writeOp?.ContinueWith(t => { writeAction(); }) ?? Task.Run(writeAction); } private void reloadTeams() @@ -237,7 +234,7 @@ namespace osu.Game.Tournament.Screens.Drawings if (string.IsNullOrEmpty(line)) continue; - if (line.ToUpperInvariant().StartsWith("GROUP")) + if (line.ToUpperInvariant().StartsWith("GROUP", StringComparison.Ordinal)) continue; // ReSharper disable once AccessToModifiedClosure diff --git a/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs index ba63013886..efec4cffdd 100644 --- a/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/LadderEditorScreen.cs @@ -26,6 +26,8 @@ namespace osu.Game.Tournament.Screens.Editors [Cached] private LadderEditorInfo editorInfo = new LadderEditorInfo(); + private WarningBox rightClickMessage; + protected override bool DrawLoserPaths => true; [BackgroundDependencyLoader] @@ -37,6 +39,16 @@ namespace osu.Game.Tournament.Screens.Editors Origin = Anchor.TopRight, Margin = new MarginPadding(5) }); + + AddInternal(rightClickMessage = new WarningBox("Right click to place and link matches")); + + LadderInfo.Matches.CollectionChanged += (_, __) => updateMessage(); + updateMessage(); + } + + private void updateMessage() + { + rightClickMessage.Alpha = LadderInfo.Matches.Count > 0 ? 0 : 1; } public void BeginJoin(TournamentMatch match, bool losers) @@ -49,7 +61,7 @@ namespace osu.Game.Tournament.Screens.Editors get { if (editorInfo == null) - return new MenuItem[0]; + return Array.Empty(); return new MenuItem[] { diff --git a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs index b036350879..069ddfa4db 100644 --- a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs @@ -63,25 +63,25 @@ namespace osu.Game.Tournament.Screens.Editors { LabelText = "Name", Width = 0.33f, - Bindable = Model.Name + Current = Model.Name }, new SettingsTextBox { LabelText = "Description", Width = 0.33f, - Bindable = Model.Description + Current = Model.Description }, new DateTextBox { LabelText = "Start Time", Width = 0.33f, - Bindable = Model.StartDate + Current = Model.StartDate }, new SettingsSlider { LabelText = "Best of", Width = 0.33f, - Bindable = Model.BestOf + Current = Model.BestOf }, new SettingsButton { @@ -129,8 +129,6 @@ namespace osu.Game.Tournament.Screens.Editors RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint, ChildrenEnumerable = round.Beatmaps.Select(p => new RoundBeatmapRow(round, p)) }; } @@ -188,14 +186,14 @@ namespace osu.Game.Tournament.Screens.Editors LabelText = "Beatmap ID", RelativeSizeAxes = Axes.None, Width = 200, - Bindable = beatmapId, + Current = beatmapId, }, new SettingsTextBox { LabelText = "Mods", RelativeSizeAxes = Axes.None, Width = 200, - Bindable = mods, + Current = mods, }, drawableContainer = new Container { @@ -225,9 +223,7 @@ namespace osu.Game.Tournament.Screens.Editors beatmapId.Value = Model.ID.ToString(); beatmapId.BindValueChanged(idString => { - int parsed; - - int.TryParse(idString.NewValue, out parsed); + int.TryParse(idString.NewValue, out var parsed); Model.ID = parsed; @@ -266,12 +262,14 @@ namespace osu.Game.Tournament.Screens.Editors drawableContainer.Clear(); if (Model.BeatmapInfo != null) + { drawableContainer.Child = new TournamentBeatmapPanel(Model.BeatmapInfo, Model.Mods) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Width = 300 }; + } } } } diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs new file mode 100644 index 0000000000..7bd8d3f6a0 --- /dev/null +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -0,0 +1,290 @@ +// 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.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Screens.Editors +{ + public class SeedingEditorScreen : TournamentEditorScreen + { + private readonly TournamentTeam team; + + protected override BindableList Storage => team.SeedingResults; + + [Resolved(canBeNull: true)] + private TournamentSceneManager sceneManager { get; set; } + + public SeedingEditorScreen(TournamentTeam team, TournamentScreen parentScreen) + : base(parentScreen) + { + this.team = team; + } + + public class SeedingResultRow : CompositeDrawable, IModelBacked + { + public SeedingResult Model { get; } + + [Resolved] + private LadderInfo ladderInfo { get; set; } + + public SeedingResultRow(TournamentTeam team, SeedingResult round) + { + Model = round; + + Masking = true; + CornerRadius = 10; + + SeedingBeatmapEditor beatmapEditor = new SeedingBeatmapEditor(round) + { + Width = 0.95f + }; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.1f), + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Margin = new MarginPadding(5), + Padding = new MarginPadding { Right = 160 }, + Spacing = new Vector2(5), + Direction = FillDirection.Full, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new SettingsTextBox + { + LabelText = "Mod", + Width = 0.33f, + Current = Model.Mod + }, + new SettingsSlider + { + LabelText = "Seed", + Width = 0.33f, + Current = Model.Seed + }, + new SettingsButton + { + Width = 0.2f, + Margin = new MarginPadding(10), + Text = "Add beatmap", + Action = () => beatmapEditor.CreateNew() + }, + beatmapEditor + } + }, + new DangerousSettingsButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.None, + Width = 150, + Text = "Delete result", + Action = () => + { + Expire(); + team.SeedingResults.Remove(Model); + }, + } + }; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + public class SeedingBeatmapEditor : CompositeDrawable + { + private readonly SeedingResult round; + private readonly FillFlowContainer flow; + + public SeedingBeatmapEditor(SeedingResult round) + { + this.round = round; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + ChildrenEnumerable = round.Beatmaps.Select(p => new SeedingBeatmapRow(round, p)) + }; + } + + public void CreateNew() + { + var user = new SeedingBeatmap(); + round.Beatmaps.Add(user); + flow.Add(new SeedingBeatmapRow(round, user)); + } + + public class SeedingBeatmapRow : CompositeDrawable + { + private readonly SeedingResult result; + public SeedingBeatmap Model { get; } + + [Resolved] + protected IAPIProvider API { get; private set; } + + private readonly Bindable beatmapId = new Bindable(); + + private readonly Bindable score = new Bindable(); + + private readonly Container drawableContainer; + + public SeedingBeatmapRow(SeedingResult result, SeedingBeatmap beatmap) + { + this.result = result; + Model = beatmap; + + Margin = new MarginPadding(10); + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.2f), + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Margin = new MarginPadding(5), + Padding = new MarginPadding { Right = 160 }, + Spacing = new Vector2(5), + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new SettingsNumberBox + { + LabelText = "Beatmap ID", + RelativeSizeAxes = Axes.None, + Width = 200, + Current = beatmapId, + }, + new SettingsSlider + { + LabelText = "Seed", + RelativeSizeAxes = Axes.None, + Width = 200, + Current = beatmap.Seed + }, + new SettingsTextBox + { + LabelText = "Score", + RelativeSizeAxes = Axes.None, + Width = 200, + Current = score, + }, + drawableContainer = new Container + { + Size = new Vector2(100, 70), + }, + } + }, + new DangerousSettingsButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.None, + Width = 150, + Text = "Delete Beatmap", + Action = () => + { + Expire(); + result.Beatmaps.Remove(beatmap); + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + beatmapId.Value = Model.ID.ToString(); + beatmapId.BindValueChanged(idString => + { + int parsed; + + int.TryParse(idString.NewValue, out parsed); + + Model.ID = parsed; + + if (idString.NewValue != idString.OldValue) + Model.BeatmapInfo = null; + + if (Model.BeatmapInfo != null) + { + updatePanel(); + return; + } + + var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = Model.ID }); + + req.Success += res => + { + Model.BeatmapInfo = res.ToBeatmap(rulesets); + updatePanel(); + }; + + req.Failure += _ => + { + Model.BeatmapInfo = null; + updatePanel(); + }; + + API.Queue(req); + }, true); + + score.Value = Model.Score.ToString(); + score.BindValueChanged(str => long.TryParse(str.NewValue, out Model.Score)); + } + + private void updatePanel() + { + drawableContainer.Clear(); + + if (Model.BeatmapInfo != null) + { + drawableContainer.Child = new TournamentBeatmapPanel(Model.BeatmapInfo, result.Mod.Value) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Width = 300 + }; + } + } + } + } + } + + protected override SeedingResultRow CreateDrawable(SeedingResult model) => new SeedingResultRow(team, model); + } +} diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index a4479f3cfd..582f72429b 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -11,9 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Overlays.Settings; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; @@ -25,14 +23,14 @@ namespace osu.Game.Tournament.Screens.Editors public class TeamEditorScreen : TournamentEditorScreen { [Resolved] - private Framework.Game game { get; set; } + private TournamentGameBase game { get; set; } protected override BindableList Storage => LadderInfo.Teams; [BackgroundDependencyLoader] private void load() { - ControlPanel.Add(new OsuButton + ControlPanel.Add(new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Add all countries", @@ -40,7 +38,7 @@ namespace osu.Game.Tournament.Screens.Editors }); } - protected override TeamRow CreateDrawable(TournamentTeam model) => new TeamRow(model); + protected override TeamRow CreateDrawable(TournamentTeam model) => new TeamRow(model, this); private void addAllCountries() { @@ -59,10 +57,13 @@ namespace osu.Game.Tournament.Screens.Editors private readonly Container drawableContainer; + [Resolved(canBeNull: true)] + private TournamentSceneManager sceneManager { get; set; } + [Resolved] private LadderInfo ladderInfo { get; set; } - public TeamRow(TournamentTeam team) + public TeamRow(TournamentTeam team, TournamentScreen parent) { Model = team; @@ -101,19 +102,31 @@ namespace osu.Game.Tournament.Screens.Editors { LabelText = "Name", Width = 0.2f, - Bindable = Model.FullName + Current = Model.FullName }, new SettingsTextBox { LabelText = "Acronym", Width = 0.2f, - Bindable = Model.Acronym + Current = Model.Acronym }, new SettingsTextBox { LabelText = "Flag", Width = 0.2f, - Bindable = Model.FlagName + Current = Model.FlagName + }, + new SettingsTextBox + { + LabelText = "Seed", + Width = 0.2f, + Current = Model.Seed + }, + new SettingsSlider + { + LabelText = "Last Year Placement", + Width = 0.33f, + Current = Model.LastYearPlacing }, new SettingsButton { @@ -133,7 +146,17 @@ namespace osu.Game.Tournament.Screens.Editors ladderInfo.Teams.Remove(Model); }, }, - playerEditor + playerEditor, + new SettingsButton + { + Width = 0.2f, + Margin = new MarginPadding(10), + Text = "Edit seeding results", + Action = () => + { + sceneManager?.SetScreen(new SeedingEditorScreen(team, parent)); + } + }, } }, }; @@ -149,19 +172,6 @@ namespace osu.Game.Tournament.Screens.Editors drawableContainer.Child = new DrawableTeamFlag(Model); } - private class DrawableTeamFlag : DrawableTournamentTeam - { - public DrawableTeamFlag(TournamentTeam team) - : base(team) - { - InternalChild = Flag; - RelativeSizeAxes = Axes.Both; - - Flag.Anchor = Anchor.Centre; - Flag.Origin = Anchor.Centre; - } - } - public class PlayerEditor : CompositeDrawable { private readonly TournamentTeam team; @@ -179,8 +189,6 @@ namespace osu.Game.Tournament.Screens.Editors RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint, ChildrenEnumerable = team.Players.Select(p => new PlayerRow(team, p)) }; } @@ -199,6 +207,9 @@ namespace osu.Game.Tournament.Screens.Editors [Resolved] protected IAPIProvider API { get; private set; } + [Resolved] + private TournamentGameBase game { get; set; } + private readonly Bindable userId = new Bindable(); private readonly Container drawableContainer; @@ -236,7 +247,7 @@ namespace osu.Game.Tournament.Screens.Editors LabelText = "User ID", RelativeSizeAxes = Axes.None, Width = 200, - Bindable = userId, + Current = userId, }, drawableContainer = new Container { @@ -266,9 +277,7 @@ namespace osu.Game.Tournament.Screens.Editors userId.Value = user.Id.ToString(); userId.BindValueChanged(idString => { - long parsed; - - long.TryParse(idString.NewValue, out parsed); + int.TryParse(idString.NewValue, out var parsed); user.Id = parsed; @@ -281,31 +290,13 @@ namespace osu.Game.Tournament.Screens.Editors return; } - var req = new GetUserRequest(user.Id); - - req.Success += res => - { - // TODO: this should be done in a better way. - user.Username = res.Username; - user.Country = res.Country; - user.Cover = res.Cover; - - updatePanel(); - }; - - req.Failure += _ => - { - user.Id = 1; - updatePanel(); - }; - - API.Queue(req); + game.PopulateUser(user, updatePanel, updatePanel); }, true); } private void updatePanel() { - drawableContainer.Child = new UserPanel(user) { Width = 300 }; + drawableContainer.Child = new UserGridPanel(user) { Width = 300 }; } } } diff --git a/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs index 50d3207345..a5a2c5c15f 100644 --- a/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TournamentEditorScreen.cs @@ -1,15 +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.Specialized; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osu.Game.Tournament.Components; using osuTK; @@ -24,8 +26,19 @@ namespace osu.Game.Tournament.Screens.Editors private FillFlowContainer flow; + [Resolved(canBeNull: true)] + private TournamentSceneManager sceneManager { get; set; } + protected ControlPanel ControlPanel; + private readonly TournamentScreen parentScreen; + private BackButton backButton; + + protected TournamentEditorScreen(TournamentScreen parentScreen = null) + { + this.parentScreen = parentScreen; + } + [BackgroundDependencyLoader] private void load() { @@ -39,7 +52,6 @@ namespace osu.Game.Tournament.Screens.Editors new OsuScrollContainer { RelativeSizeAxes = Axes.Both, - Width = 0.9f, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Child = flow = new FillFlowContainer @@ -47,16 +59,14 @@ namespace osu.Game.Tournament.Screens.Editors Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint, - Spacing = new Vector2(20) + Spacing = new Vector2(20), }, }, ControlPanel = new ControlPanel { Children = new Drawable[] { - new OsuButton + new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Add new", @@ -72,8 +82,32 @@ namespace osu.Game.Tournament.Screens.Editors } }); - Storage.ItemsAdded += items => items.ForEach(i => flow.Add(CreateDrawable(i))); - Storage.ItemsRemoved += items => items.ForEach(i => flow.RemoveAll(d => d.Model == i)); + if (parentScreen != null) + { + AddInternal(backButton = new BackButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + State = { Value = Visibility.Visible }, + Action = () => sceneManager?.SetScreen(parentScreen.GetType()) + }); + + flow.Padding = new MarginPadding { Bottom = backButton.Height * 2 }; + } + + Storage.CollectionChanged += (_, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + args.NewItems.Cast().ForEach(i => flow.Add(CreateDrawable(i))); + break; + + case NotifyCollectionChangedAction.Remove: + args.OldItems.Cast().ForEach(i => flow.RemoveAll(d => d.Model == i)); + break; + } + }; foreach (var model in Storage) flow.Add(CreateDrawable(model)); diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs b/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs index 9e1888b44b..d790f4b754 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/MatchHeader.cs @@ -2,24 +2,54 @@ // 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.Events; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; -using osu.Game.Tournament.Screens.Showcase; using osuTK; -using osuTK.Graphics; -using osuTK.Input; namespace osu.Game.Tournament.Screens.Gameplay.Components { public class MatchHeader : Container { + private TeamScoreDisplay teamDisplay1; + private TeamScoreDisplay teamDisplay2; + private DrawableTournamentHeaderLogo logo; + + private bool showScores = true; + + public bool ShowScores + { + get => showScores; + set + { + if (value == showScores) + return; + + showScores = value; + + if (IsLoaded) + updateDisplay(); + } + } + + private bool showLogo = true; + + public bool ShowLogo + { + get => showLogo; + set + { + if (value == showLogo) + return; + + showLogo = value; + + if (IsLoaded) + updateDisplay(); + } + } + [BackgroundDependencyLoader] private void load() { @@ -27,194 +57,54 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components Height = 95; Children = new Drawable[] { - new TournamentLogo(), - new RoundDisplay + new FillFlowContainer { - Y = 10, - Anchor = Anchor.BottomCentre, - Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(20), + Spacing = new Vector2(5), + Children = new Drawable[] + { + logo = new DrawableTournamentHeaderLogo + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Alpha = showLogo ? 1 : 0 + }, + new DrawableTournamentHeaderText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new MatchRoundDisplay + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Scale = new Vector2(0.4f) + }, + } }, - new TeamScoreDisplay(TeamColour.Red) + teamDisplay1 = new TeamScoreDisplay(TeamColour.Red) { Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, }, - new TeamScoreDisplay(TeamColour.Blue) + teamDisplay2 = new TeamScoreDisplay(TeamColour.Blue) { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, }, }; + + updateDisplay(); } - private class TeamScoreDisplay : CompositeDrawable + private void updateDisplay() { - private readonly TeamColour teamColour; + teamDisplay1.ShowScore = showScores; + teamDisplay2.ShowScore = showScores; - private readonly Color4 red = new Color4(129, 68, 65, 255); - private readonly Color4 blue = new Color4(41, 91, 97, 255); - - private readonly Bindable currentMatch = new Bindable(); - private readonly Bindable currentTeam = new Bindable(); - private readonly Bindable currentTeamScore = new Bindable(); - - public TeamScoreDisplay(TeamColour teamColour) - { - this.teamColour = teamColour; - - RelativeSizeAxes = Axes.Y; - Width = 300; - } - - [BackgroundDependencyLoader] - private void load(LadderInfo ladder) - { - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(ladder.CurrentMatch); - } - - private void matchChanged(ValueChangedEvent match) - { - currentTeamScore.UnbindBindings(); - currentTeamScore.BindTo(teamColour == TeamColour.Red ? match.NewValue.Team1Score : match.NewValue.Team2Score); - - currentTeam.UnbindBindings(); - currentTeam.BindTo(teamColour == TeamColour.Red ? match.NewValue.Team1 : match.NewValue.Team2); - - // team may change to same team, which means score is not in a good state. - // thus we handle this manually. - teamChanged(currentTeam.Value); - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - switch (e.Button) - { - case MouseButton.Left: - if (currentTeamScore.Value < currentMatch.Value.PointsToWin) - currentTeamScore.Value++; - return true; - - case MouseButton.Right: - if (currentTeamScore.Value > 0) - currentTeamScore.Value--; - return true; - } - - return base.OnMouseDown(e); - } - - private void teamChanged(TournamentTeam team) - { - var colour = teamColour == TeamColour.Red ? red : blue; - var flip = teamColour != TeamColour.Red; - - InternalChildren = new Drawable[] - { - new TeamDisplay(team, colour, flip), - new TeamScore(currentTeamScore, flip, currentMatch.Value.PointsToWin) - { - Colour = colour - } - }; - } - } - - private class TeamScore : CompositeDrawable - { - private readonly Bindable currentTeamScore = new Bindable(); - private readonly StarCounter counter; - - public TeamScore(Bindable score, bool flip, int count) - { - var anchor = flip ? Anchor.CentreRight : Anchor.CentreLeft; - - Anchor = anchor; - Origin = anchor; - - InternalChild = counter = new StarCounter(count) - { - Anchor = anchor, - X = (flip ? -1 : 1) * 90, - Y = 5, - Scale = flip ? new Vector2(-1, 1) : Vector2.One, - }; - - currentTeamScore.BindValueChanged(scoreChanged); - currentTeamScore.BindTo(score); - } - - private void scoreChanged(ValueChangedEvent score) => counter.CountStars = score.NewValue ?? 0; - } - - private class TeamDisplay : DrawableTournamentTeam - { - public TeamDisplay(TournamentTeam team, Color4 colour, bool flip) - : base(team) - { - RelativeSizeAxes = Axes.Both; - - var anchor = flip ? Anchor.CentreRight : Anchor.CentreLeft; - - Anchor = Origin = anchor; - - Flag.Anchor = Flag.Origin = anchor; - Flag.RelativeSizeAxes = Axes.None; - Flag.Size = new Vector2(60, 40); - Flag.Margin = new MarginPadding(20); - - InternalChild = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - Flag, - new OsuSpriteText - { - Text = team?.FullName.Value.ToUpper() ?? "???", - X = (flip ? -1 : 1) * 90, - Y = -10, - Colour = colour, - Font = TournamentFont.GetFont(typeface: TournamentTypeface.Aquatico, weight: FontWeight.Regular, size: 20), - Origin = anchor, - Anchor = anchor, - }, - } - }; - } - } - - private class RoundDisplay : CompositeDrawable - { - private readonly Bindable currentMatch = new Bindable(); - - public RoundDisplay() - { - Width = 200; - Height = 20; - } - - [BackgroundDependencyLoader] - private void load(LadderInfo ladder) - { - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(ladder.CurrentMatch); - } - - private void matchChanged(ValueChangedEvent match) - { - InternalChildren = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = Color4.White, - Text = match.NewValue.Round.Value?.Name.Value ?? "Unknown Round", - Font = TournamentFont.GetFont(typeface: TournamentTypeface.Aquatico, weight: FontWeight.Regular, size: 18), - }, - }; - } + logo.Alpha = showLogo ? 1 : 0; } } } diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.cs new file mode 100644 index 0000000000..87793f7e1b --- /dev/null +++ b/osu.Game.Tournament/Screens/Gameplay/Components/MatchRoundDisplay.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.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Screens.Gameplay.Components +{ + public class MatchRoundDisplay : TournamentSpriteTextWithBackground + { + private readonly Bindable currentMatch = new Bindable(); + + [BackgroundDependencyLoader] + private void load(LadderInfo ladder) + { + currentMatch.BindValueChanged(matchChanged); + currentMatch.BindTo(ladder.CurrentMatch); + } + + private void matchChanged(ValueChangedEvent match) => + Text.Text = match.NewValue.Round.Value?.Name.Value ?? "Unknown Round"; + } +} diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs index 78455c8bb7..695c6d6f3e 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/MatchScoreDisplay.cs @@ -8,19 +8,17 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; -using osuTK.Graphics; +using osuTK; namespace osu.Game.Tournament.Screens.Gameplay.Components { public class MatchScoreDisplay : CompositeDrawable { - private readonly Color4 red = new Color4(186, 0, 18, 255); - private readonly Color4 blue = new Color4(17, 136, 170, 255); - - private const float bar_height = 20; + private const float bar_height = 18; private readonly BindableInt score1 = new BindableInt(); private readonly BindableInt score2 = new BindableInt(); @@ -28,45 +26,63 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components private readonly MatchScoreCounter score1Text; private readonly MatchScoreCounter score2Text; - private readonly Circle score1Bar; - private readonly Circle score2Bar; + private readonly Drawable score1Bar; + private readonly Drawable score2Bar; public MatchScoreDisplay() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - InternalChildren = new Drawable[] + InternalChildren = new[] { - score1Bar = new Circle + new Box + { + Name = "top bar red (static)", + RelativeSizeAxes = Axes.X, + Height = bar_height / 4, + Width = 0.5f, + Colour = TournamentGame.COLOUR_RED, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight + }, + new Box + { + Name = "top bar blue (static)", + RelativeSizeAxes = Axes.X, + Height = bar_height / 4, + Width = 0.5f, + Colour = TournamentGame.COLOUR_BLUE, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopLeft + }, + score1Bar = new Box { Name = "top bar red", RelativeSizeAxes = Axes.X, Height = bar_height, Width = 0, - Colour = red, + Colour = TournamentGame.COLOUR_RED, Anchor = Anchor.TopCentre, Origin = Anchor.TopRight }, score1Text = new MatchScoreCounter { - Colour = red, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre }, - score2Bar = new Circle + score2Bar = new Box { Name = "top bar blue", RelativeSizeAxes = Axes.X, Height = bar_height, Width = 0, - Colour = blue, + Colour = TournamentGame.COLOUR_BLUE, Anchor = Anchor.TopCentre, Origin = Anchor.TopLeft }, score2Text = new MatchScoreCounter { - Colour = blue, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre }, @@ -100,32 +116,41 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components var diff = Math.Max(score1.Value, score2.Value) - Math.Min(score1.Value, score2.Value); losingBar.ResizeWidthTo(0, 400, Easing.OutQuint); - winningBar.ResizeWidthTo(Math.Min(0.4f, (float)Math.Pow(diff / 1500000f, 0.5) / 2), 400, Easing.OutQuint); + winningBar.ResizeWidthTo(Math.Min(0.4f, MathF.Pow(diff / 1500000f, 0.5f) / 2), 400, Easing.OutQuint); } - protected override void Update() + protected override void UpdateAfterChildren() { - base.Update(); - + base.UpdateAfterChildren(); score1Text.X = -Math.Max(5 + score1Text.DrawWidth / 2, score1Bar.DrawWidth); score2Text.X = Math.Max(5 + score2Text.DrawWidth / 2, score2Bar.DrawWidth); } private class MatchScoreCounter : ScoreCounter { + private OsuSpriteText displayedSpriteText; + public MatchScoreCounter() { - Margin = new MarginPadding { Top = bar_height + 5, Horizontal = 10 }; - - Winning = false; + Margin = new MarginPadding { Top = bar_height, Horizontal = 10 }; } public bool Winning { - set => DisplayedCountSpriteText.Font = value - ? TournamentFont.GetFont(typeface: TournamentTypeface.Aquatico, weight: FontWeight.Regular, size: 60) - : TournamentFont.GetFont(typeface: TournamentTypeface.Aquatico, weight: FontWeight.Light, size: 40); + set => updateFont(value); } + + protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => + { + displayedSpriteText = s; + displayedSpriteText.Spacing = new Vector2(-6); + updateFont(false); + }); + + private void updateFont(bool winning) + => displayedSpriteText.Font = winning + ? OsuFont.Torus.With(weight: FontWeight.Bold, size: 50, fixedWidth: true) + : OsuFont.Torus.With(weight: FontWeight.Regular, size: 40, fixedWidth: true); } } } diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.cs new file mode 100644 index 0000000000..4ba86dcefc --- /dev/null +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamDisplay.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 osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; +using osuTK; + +namespace osu.Game.Tournament.Screens.Gameplay.Components +{ + public class TeamDisplay : DrawableTournamentTeam + { + private readonly TeamScore score; + + public bool ShowScore + { + set => score.FadeTo(value ? 1 : 0, 200); + } + + public TeamDisplay(TournamentTeam team, TeamColour colour, Bindable currentTeamScore, int pointsToWin) + : base(team) + { + AutoSizeAxes = Axes.Both; + + bool flip = colour == TeamColour.Red; + + var anchor = flip ? Anchor.TopLeft : Anchor.TopRight; + + Flag.RelativeSizeAxes = Axes.None; + Flag.Scale = new Vector2(0.8f); + Flag.Origin = anchor; + Flag.Anchor = anchor; + + Margin = new MarginPadding(20); + + InternalChild = new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + Flag, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Origin = anchor, + Anchor = anchor, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Origin = anchor, + Anchor = anchor, + Children = new Drawable[] + { + new DrawableTeamHeader(colour) + { + Scale = new Vector2(0.75f), + Origin = anchor, + Anchor = anchor, + }, + score = new TeamScore(currentTeamScore, colour, pointsToWin) + { + Origin = anchor, + Anchor = anchor, + } + } + }, + new TournamentSpriteTextWithBackground(team?.FullName.Value ?? "???") + { + Scale = new Vector2(0.5f), + Origin = anchor, + Anchor = anchor, + }, + } + }, + } + }, + } + }; + } + } +} diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScore.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScore.cs new file mode 100644 index 0000000000..36c78c5ac1 --- /dev/null +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScore.cs @@ -0,0 +1,103 @@ +// 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.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Tournament.Models; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tournament.Screens.Gameplay.Components +{ + public class TeamScore : CompositeDrawable + { + private readonly Bindable currentTeamScore = new Bindable(); + private readonly StarCounter counter; + + public TeamScore(Bindable score, TeamColour colour, int count) + { + bool flip = colour == TeamColour.Blue; + var anchor = flip ? Anchor.TopRight : Anchor.TopLeft; + + AutoSizeAxes = Axes.Both; + + InternalChild = counter = new TeamScoreStarCounter(count) + { + Anchor = anchor, + Scale = flip ? new Vector2(-1, 1) : Vector2.One, + }; + + currentTeamScore.BindValueChanged(scoreChanged); + currentTeamScore.BindTo(score); + } + + private void scoreChanged(ValueChangedEvent score) => counter.Current = score.NewValue ?? 0; + + public class TeamScoreStarCounter : StarCounter + { + public TeamScoreStarCounter(int count) + : base(count) + { + } + + public override Star CreateStar() => new LightSquare(); + + public class LightSquare : Star + { + private readonly Box box; + + public LightSquare() + { + Size = new Vector2(22.5f); + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + BorderColour = OsuColour.Gray(0.5f), + BorderThickness = 3, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Transparent, + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + }, + } + }, + box = new Box + { + Colour = Color4Extensions.FromHex("#FFE8AD"), + RelativeSizeAxes = Axes.Both, + }, + }; + + Masking = true; + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = Color4Extensions.FromHex("#FFE8AD").Opacity(0.1f), + Hollow = true, + Radius = 20, + Roundness = 10, + }; + } + + public override void DisplayAt(float scale) + { + box.FadeTo(scale, 500, Easing.OutQuint); + FadeEdgeEffectTo(0.2f * scale, 500, Easing.OutQuint); + } + } + } + } +} diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.cs new file mode 100644 index 0000000000..da55ba53ea --- /dev/null +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TeamScoreDisplay.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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Tournament.Models; +using osuTK.Input; + +namespace osu.Game.Tournament.Screens.Gameplay.Components +{ + public class TeamScoreDisplay : CompositeDrawable + { + private readonly TeamColour teamColour; + + private readonly Bindable currentMatch = new Bindable(); + private readonly Bindable currentTeam = new Bindable(); + private readonly Bindable currentTeamScore = new Bindable(); + + private TeamDisplay teamDisplay; + + public bool ShowScore + { + set => teamDisplay.ShowScore = value; + } + + public TeamScoreDisplay(TeamColour teamColour) + { + this.teamColour = teamColour; + + RelativeSizeAxes = Axes.Y; + AutoSizeAxes = Axes.X; + } + + [BackgroundDependencyLoader] + private void load(LadderInfo ladder) + { + currentMatch.BindTo(ladder.CurrentMatch); + currentMatch.BindValueChanged(matchChanged); + + updateMatch(); + } + + private void matchChanged(ValueChangedEvent match) + { + currentTeamScore.UnbindBindings(); + currentTeam.UnbindBindings(); + + Scheduler.AddOnce(updateMatch); + } + + private void updateMatch() + { + var match = currentMatch.Value; + + if (match != null) + { + match.StartMatch(); + + currentTeamScore.BindTo(teamColour == TeamColour.Red ? match.Team1Score : match.Team2Score); + currentTeam.BindTo(teamColour == TeamColour.Red ? match.Team1 : match.Team2); + } + + // team may change to same team, which means score is not in a good state. + // thus we handle this manually. + teamChanged(currentTeam.Value); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + switch (e.Button) + { + case MouseButton.Left: + if (currentTeamScore.Value < currentMatch.Value.PointsToWin) + currentTeamScore.Value++; + return true; + + case MouseButton.Right: + if (currentTeamScore.Value > 0) + currentTeamScore.Value--; + return true; + } + + return base.OnMouseDown(e); + } + + private void teamChanged(TournamentTeam team) + { + InternalChildren = new Drawable[] + { + teamDisplay = new TeamDisplay(team, teamColour, currentTeamScore, currentMatch.Value?.PointsToWin ?? 0), + }; + } + } +} diff --git a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs index b9a74bfe16..e4ec45c00e 100644 --- a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs +++ b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs @@ -6,20 +6,21 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Settings; using osu.Game.Tournament.Components; using osu.Game.Tournament.IPC; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Gameplay.Components; using osu.Game.Tournament.Screens.MapPool; using osu.Game.Tournament.Screens.TeamWin; -using osuTK; using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Gameplay { - public class GameplayScreen : BeatmapInfoScreen + public class GameplayScreen : BeatmapInfoScreen, IProvideVideo { private readonly BindableBool warmup = new BindableBool(); @@ -29,91 +30,97 @@ namespace osu.Game.Tournament.Screens.Gameplay private OsuButton warmupButton; private MatchIPCInfo ipc; - private readonly Color4 red = new Color4(186, 0, 18, 255); - private readonly Color4 blue = new Color4(17, 136, 170, 255); - [Resolved(canBeNull: true)] private TournamentSceneManager sceneManager { get; set; } [Resolved] private TournamentMatchChatDisplay chat { get; set; } + private Drawable chroma; + [BackgroundDependencyLoader] - private void load(LadderInfo ladder, MatchIPCInfo ipc) + private void load(LadderInfo ladder, MatchIPCInfo ipc, Storage storage) { this.ipc = ipc; AddRangeInternal(new Drawable[] { - new MatchHeader(), + new TourneyVideo("gameplay") + { + Loop = true, + RelativeSizeAxes = Axes.Both, + }, + header = new MatchHeader + { + ShowLogo = false + }, new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Y = 5, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] + Y = 110, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = new[] { - new Box + chroma = new Container { - // chroma key area for stable gameplay - Name = "chroma", - RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, Height = 512, - Colour = new Color4(0, 255, 0, 255), - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Y = -4, Children = new Drawable[] { - new Circle + new ChromaArea { - Name = "top bar red", - RelativeSizeAxes = Axes.X, - Height = 8, + Name = "Left chroma", + RelativeSizeAxes = Axes.Both, Width = 0.5f, - Colour = red, }, - new Circle + new ChromaArea { - Name = "top bar blue", - RelativeSizeAxes = Axes.X, - Height = 8, - Width = 0.5f, - Colour = blue, + Name = "Right chroma", + RelativeSizeAxes = Axes.Both, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - }, + Width = 0.5f, + } } }, } }, scoreDisplay = new MatchScoreDisplay { - Y = -60, - Scale = new Vector2(0.8f), + Y = -147, Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, + Origin = Anchor.TopCentre, }, new ControlPanel { Children = new Drawable[] { - warmupButton = new OsuButton + warmupButton = new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Toggle warmup", Action = () => warmup.Toggle() }, - new OsuButton + new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Toggle chat", Action = () => { State.Value = State.Value == TourneyState.Idle ? TourneyState.Playing : TourneyState.Idle; } + }, + new SettingsSlider + { + LabelText = "Chroma width", + Current = LadderInfo.ChromaKeyWidth, + KeyboardStep = 1, + }, + new SettingsSlider + { + LabelText = "Players per team", + Current = LadderInfo.PlayersPerTeam, + KeyboardStep = 1, } } } @@ -122,6 +129,8 @@ namespace osu.Game.Tournament.Screens.Gameplay State.BindTo(ipc.State); State.BindValueChanged(stateChanged, true); + ladder.ChromaKeyWidth.BindValueChanged(width => chroma.Width = width.NewValue, true); + currentMatch.BindValueChanged(m => { warmup.Value = m.NewValue.Team1Score.Value + m.NewValue.Team2Score.Value == 0; @@ -130,13 +139,18 @@ namespace osu.Game.Tournament.Screens.Gameplay currentMatch.BindTo(ladder.CurrentMatch); - warmup.BindValueChanged(w => warmupButton.Alpha = !w.NewValue ? 0.5f : 1, true); + warmup.BindValueChanged(w => + { + warmupButton.Alpha = !w.NewValue ? 0.5f : 1; + header.ShowScores = !w.NewValue; + }, true); } private ScheduledDelegate scheduledOperation; private MatchScoreDisplay scoreDisplay; private TourneyState lastState; + private MatchHeader header; private void stateChanged(ValueChangedEvent state) { @@ -156,7 +170,7 @@ namespace osu.Game.Tournament.Screens.Gameplay void expand() { - chat?.Expand(); + chat?.Contract(); using (BeginDelayedSequence(300, true)) { @@ -170,7 +184,7 @@ namespace osu.Game.Tournament.Screens.Gameplay SongBar.Expanded = false; scoreDisplay.FadeOut(100); using (chat?.BeginDelayedSequence(500)) - chat?.Contract(); + chat?.Expand(); } switch (state.NewValue) @@ -197,7 +211,7 @@ namespace osu.Game.Tournament.Screens.Gameplay break; default: - chat.Expand(); + chat.Contract(); expand(); break; } @@ -207,5 +221,54 @@ namespace osu.Game.Tournament.Screens.Gameplay lastState = state.NewValue; } } + + private class ChromaArea : CompositeDrawable + { + [Resolved] + private LadderInfo ladder { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + // chroma key area for stable gameplay + Colour = new Color4(0, 255, 0, 255); + + ladder.PlayersPerTeam.BindValueChanged(performLayout, true); + } + + private void performLayout(ValueChangedEvent playerCount) + { + switch (playerCount.NewValue) + { + case 3: + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Height = 0.5f, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Height = 0.5f, + }, + }; + break; + + default: + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + }; + break; + } + } + } } } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs index ded21730f3..bb1e4d2eff 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableMatchTeam.cs @@ -4,6 +4,7 @@ using System; 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; @@ -11,7 +12,6 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; @@ -26,23 +26,25 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { private readonly TournamentMatch match; private readonly bool losers; - private OsuSpriteText scoreText; + private TournamentSpriteText scoreText; private Box background; + private Box backgroundRight; private readonly Bindable score = new Bindable(); private readonly BindableBool completed = new BindableBool(); private Color4 colourWinner; - private Color4 colourNormal; private readonly Func isWinner; private LadderEditorScreen ladderEditor; - [Resolved] + [Resolved(canBeNull: true)] private LadderInfo ladderInfo { get; set; } private void setCurrent() { + if (ladderInfo == null) return; + //todo: tournamentgamebase? if (ladderInfo.CurrentMatch.Value != null) ladderInfo.CurrentMatch.Value.Current.Value = false; @@ -61,15 +63,12 @@ namespace osu.Game.Tournament.Screens.Ladder.Components this.losers = losers; Size = new Vector2(150, 40); - Masking = true; - CornerRadius = 5; - - Flag.Scale = new Vector2(0.9f); + Flag.Scale = new Vector2(0.54f); Flag.Anchor = Flag.Origin = Anchor.CentreLeft; AcronymText.Anchor = AcronymText.Origin = Anchor.CentreLeft; AcronymText.Padding = new MarginPadding { Left = 50 }; - AcronymText.Font = OsuFont.GetFont(size: 24); + AcronymText.Font = OsuFont.Torus.With(size: 22, weight: FontWeight.Bold); if (match != null) { @@ -86,8 +85,9 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { this.ladderEditor = ladderEditor; - colourWinner = losers ? colours.YellowDarker : colours.BlueDarker; - colourNormal = OsuColour.Gray(0.2f); + colourWinner = losers + ? Color4Extensions.FromHex("#8E7F48") + : Color4Extensions.FromHex("#1462AA"); InternalChildren = new Drawable[] { @@ -103,29 +103,28 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { AcronymText, Flag, - new Container + } + }, + new Container + { + Masking = true, + Width = 0.3f, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + backgroundRight = new Box { - Masking = true, - CornerRadius = 5, - Width = 0.3f, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, + Colour = OsuColour.Gray(0.1f), + Alpha = 0.8f, RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = OsuColour.Gray(0.1f), - Alpha = 0.8f, - RelativeSizeAxes = Axes.Both, - }, - scoreText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 20), - } - } + }, + scoreText = new TournamentSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 22), } } } @@ -182,9 +181,12 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { bool winner = completed.Value && isWinner?.Invoke() == true; - background.FadeColour(winner ? colourWinner : colourNormal, winner ? 500 : 0, Easing.OutQuint); + background.FadeColour(winner ? Color4.White : Color4Extensions.FromHex("#444"), winner ? 500 : 0, Easing.OutQuint); + backgroundRight.FadeColour(winner ? colourWinner : Color4Extensions.FromHex("#333"), winner ? 500 : 0, Easing.OutQuint); - scoreText.Font = AcronymText.Font = OsuFont.GetFont(weight: winner ? FontWeight.Bold : FontWeight.Regular); + AcronymText.Colour = winner ? Color4.Black : Color4.White; + + scoreText.Font = scoreText.Font.With(weight: winner ? FontWeight.Bold : FontWeight.Regular); } public MenuItem[] ContextMenuItems @@ -192,7 +194,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components get { if (editorInfo == null) - return new MenuItem[0]; + return Array.Empty(); return new MenuItem[] { diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs index dde280ccd8..655beb4bdd 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs @@ -23,7 +23,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components private readonly bool editor; protected readonly FillFlowContainer Flow; private readonly Drawable selectionBox; - private readonly Drawable currentMatchSelectionBox; + protected readonly Drawable CurrentMatchSelectionBox; private Bindable globalSelection; [Resolved(CanBeNull = true)] @@ -45,9 +45,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { selectionBox = new Container { - CornerRadius = 5, - Masking = true, - Scale = new Vector2(1.05f), + Scale = new Vector2(1.1f), RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -55,16 +53,14 @@ namespace osu.Game.Tournament.Screens.Ladder.Components Colour = Color4.YellowGreen, Child = new Box { RelativeSizeAxes = Axes.Both } }, - currentMatchSelectionBox = new Container + CurrentMatchSelectionBox = new Container { - CornerRadius = 5, - Masking = true, - Scale = new Vector2(1.05f), + Scale = new Vector2(1.05f, 1.1f), RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, Alpha = 0, - Colour = Color4.OrangeRed, + Colour = Color4.White, Child = new Box { RelativeSizeAxes = Axes.Both } }, Flow = new FillFlowContainer @@ -128,9 +124,9 @@ namespace osu.Game.Tournament.Screens.Ladder.Components private void updateCurrentMatch() { if (Match.Current.Value) - currentMatchSelectionBox.Show(); + CurrentMatchSelectionBox.Show(); else - currentMatchSelectionBox.Hide(); + CurrentMatchSelectionBox.Hide(); } private bool selected; @@ -289,16 +285,15 @@ namespace osu.Game.Tournament.Screens.Ladder.Components return true; } - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) { - if (base.OnDrag(e)) return true; + base.OnDrag(e); Selected = true; this.MoveToOffset(e.Delta); var pos = Position; Match.Position.Value = new Point((int)pos.X, (int)pos.Y); - return true; } public void Remove() diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs index dacd98d3b8..cad0b827c0 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentRound.cs @@ -6,9 +6,7 @@ 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.Tournament.Models; -using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Ladder.Components { @@ -22,8 +20,8 @@ namespace osu.Game.Tournament.Screens.Ladder.Components public DrawableTournamentRound(TournamentRound round, bool losers = false) { - OsuSpriteText textName; - OsuSpriteText textDescription; + TournamentSpriteText textName; + TournamentSpriteText textDescription; AutoSizeAxes = Axes.Both; InternalChild = new FillFlowContainer @@ -32,16 +30,16 @@ namespace osu.Game.Tournament.Screens.Ladder.Components AutoSizeAxes = Axes.Both, Children = new Drawable[] { - textDescription = new OsuSpriteText + textDescription = new TournamentSpriteText { - Colour = Color4.Black, + Colour = TournamentGame.TEXT_COLOUR, Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre }, - textName = new OsuSpriteText + textName = new TournamentSpriteText { - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Colour = Color4.Black, + Font = OsuFont.Torus.With(weight: FontWeight.Bold), + Colour = TournamentGame.TEXT_COLOUR, Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre }, diff --git a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs index 0864d25a2f..cf4466a2e3 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs @@ -2,13 +2,13 @@ // 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.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Input.Events; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Tournament.Components; @@ -20,8 +20,6 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { private const int padding = 10; - protected override string Title => @"ladder"; - private SettingsDropdown roundDropdown; private PlayerCheckbox losersCheckbox; private DateTextBox dateTimeBox; @@ -34,6 +32,11 @@ namespace osu.Game.Tournament.Screens.Ladder.Components [Resolved] private LadderInfo ladderInfo { get; set; } + public LadderEditorSettings() + : base("ladder") + { + } + [BackgroundDependencyLoader] private void load() { @@ -48,15 +51,15 @@ namespace osu.Game.Tournament.Screens.Ladder.Components editorInfo.Selected.ValueChanged += selection => { - roundDropdown.Bindable = selection.NewValue?.Round; + roundDropdown.Current = selection.NewValue?.Round; losersCheckbox.Current = selection.NewValue?.Losers; - dateTimeBox.Bindable = selection.NewValue?.Date; + dateTimeBox.Current = selection.NewValue?.Date; - team1Dropdown.Bindable = selection.NewValue?.Team1; - team2Dropdown.Bindable = selection.NewValue?.Team2; + team1Dropdown.Current = selection.NewValue?.Team1; + team2Dropdown.Current = selection.NewValue?.Team2; }; - roundDropdown.Bindable.ValueChanged += round => + roundDropdown.Current.ValueChanged += round => { if (editorInfo.Selected.Value?.Date.Value < round.NewValue?.StartDate.Value) { @@ -81,17 +84,28 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { } - private class SettingsRoundDropdown : LadderSettingsDropdown + private class SettingsRoundDropdown : SettingsDropdown { public SettingsRoundDropdown(BindableList rounds) { - Bindable = new Bindable(); + Current = new Bindable(); foreach (var r in rounds.Prepend(new TournamentRound())) add(r); - rounds.ItemsRemoved += items => items.ForEach(i => Control.RemoveDropdownItem(i)); - rounds.ItemsAdded += items => items.ForEach(add); + rounds.CollectionChanged += (_, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + args.NewItems.Cast().ForEach(add); + break; + + case NotifyCollectionChangedAction.Remove: + args.OldItems.Cast().ForEach(i => Control.RemoveDropdownItem(i)); + break; + } + }; } private readonly List refBindables = new List(); @@ -114,55 +128,5 @@ namespace osu.Game.Tournament.Screens.Ladder.Components }); } } - - private class SettingsTeamDropdown : LadderSettingsDropdown - { - public SettingsTeamDropdown(BindableList teams) - { - foreach (var t in teams.Prepend(new TournamentTeam())) - add(t); - - teams.ItemsRemoved += items => items.ForEach(i => Control.RemoveDropdownItem(i)); - teams.ItemsAdded += items => items.ForEach(add); - } - - private readonly List refBindables = new List(); - - private T boundReference(T obj) - where T : IBindable - { - obj = (T)obj.GetBoundCopy(); - refBindables.Add(obj); - return obj; - } - - private void add(TournamentTeam team) - { - Control.AddDropdownItem(team); - boundReference(team.FullName).BindValueChanged(_ => - { - Control.RemoveDropdownItem(team); - Control.AddDropdownItem(team); - }); - } - } - - private class LadderSettingsDropdown : SettingsDropdown - { - protected override OsuDropdown CreateDropdown() => new DropdownControl(); - - private new class DropdownControl : SettingsDropdown.DropdownControl - { - protected override DropdownMenu CreateMenu() => new Menu(); - - private new class Menu : OsuDropdownMenu - { - public Menu() - { - MaxHeight = 200; - } - } - } - } } } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs b/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs index 34e0dc770f..cb73985b11 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/ProgressionPath.cs @@ -10,8 +10,8 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { public class ProgressionPath : Path { - public DrawableTournamentMatch Source { get; private set; } - public DrawableTournamentMatch Destination { get; private set; } + public DrawableTournamentMatch Source { get; } + public DrawableTournamentMatch Destination { get; } public ProgressionPath(DrawableTournamentMatch source, DrawableTournamentMatch destination) { @@ -26,7 +26,7 @@ namespace osu.Game.Tournament.Screens.Ladder.Components { base.LoadComplete(); - Vector2 getCenteredVector(Vector2 top, Vector2 bottom) => new Vector2(top.X, top.Y + (bottom.Y - top.Y) / 2); + static Vector2 getCenteredVector(Vector2 top, Vector2 bottom) => new Vector2(top.X, top.Y + (bottom.Y - top.Y) / 2); var q1 = Source.ScreenSpaceDrawQuad; var q2 = Destination.ScreenSpaceDrawQuad; diff --git a/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs b/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs new file mode 100644 index 0000000000..6604e3a313 --- /dev/null +++ b/osu.Game.Tournament/Screens/Ladder/Components/SettingsTeamDropdown.cs @@ -0,0 +1,56 @@ +// 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.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Overlays.Settings; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Screens.Ladder.Components +{ + public class SettingsTeamDropdown : SettingsDropdown + { + public SettingsTeamDropdown(BindableList teams) + { + foreach (var t in teams.Prepend(new TournamentTeam())) + add(t); + + teams.CollectionChanged += (_, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + args.NewItems.Cast().ForEach(add); + break; + + case NotifyCollectionChangedAction.Remove: + args.OldItems.Cast().ForEach(i => Control.RemoveDropdownItem(i)); + break; + } + }; + } + + private readonly List refBindables = new List(); + + private T boundReference(T obj) + where T : IBindable + { + obj = (T)obj.GetBoundCopy(); + refBindables.Add(obj); + return obj; + } + + private void add(TournamentTeam team) + { + Control.AddDropdownItem(team); + boundReference(team.FullName).BindValueChanged(_ => + { + Control.RemoveDropdownItem(team); + Control.AddDropdownItem(team); + }); + } + } +} diff --git a/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs b/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs index f613ce5f46..fa03518c47 100644 --- a/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.cs +++ b/osu.Game.Tournament/Screens/Ladder/LadderDragContainer.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.Primitives; @@ -21,10 +22,11 @@ namespace osu.Game.Tournament.Screens.Ladder protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; - protected override bool OnDrag(DragEvent e) + public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) => false; + + protected override void OnDrag(DragEvent e) { this.MoveTo(target += e.Delta, 1000, Easing.OutQuint); - return true; } private const float min_scale = 0.6f; @@ -32,9 +34,9 @@ namespace osu.Game.Tournament.Screens.Ladder protected override bool OnScroll(ScrollEvent e) { - var newScale = MathHelper.Clamp(scale + e.ScrollDelta.Y / 15 * scale, min_scale, max_scale); + var newScale = Math.Clamp(scale + e.ScrollDelta.Y / 15 * scale, min_scale, max_scale); - this.MoveTo(target = target - e.MousePosition * (newScale - scale), 2000, Easing.OutQuint); + this.MoveTo(target -= e.MousePosition * (newScale - scale), 2000, Easing.OutQuint); this.ScaleTo(scale = newScale, 2000, Easing.OutQuint); return true; diff --git a/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs b/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs index 67531ce5d3..534c402f6c 100644 --- a/osu.Game.Tournament/Screens/Ladder/LadderScreen.cs +++ b/osu.Game.Tournament/Screens/Ladder/LadderScreen.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.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Caching; @@ -31,8 +32,8 @@ namespace osu.Game.Tournament.Screens.Ladder [BackgroundDependencyLoader] private void load(OsuColour colours, Storage storage) { - normalPathColour = colours.BlueDarker.Darken(2); - losersPathColour = colours.YellowDarker.Darken(2); + normalPathColour = Color4Extensions.FromHex("#66D1FF"); + losersPathColour = Color4Extensions.FromHex("#FFC700"); RelativeSizeAxes = Axes.Both; @@ -41,11 +42,17 @@ namespace osu.Game.Tournament.Screens.Ladder RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new TourneyVideo(storage.GetStream(@"BG Side Logo - OWC.m4v")) + new TourneyVideo("ladder") { RelativeSizeAxes = Axes.Both, Loop = true, }, + new DrawableTournamentHeaderText + { + Y = 100, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, ScrollContent = new LadderDragContainer { RelativeSizeAxes = Axes.Both, @@ -68,27 +75,31 @@ namespace osu.Game.Tournament.Screens.Ladder foreach (var match in LadderInfo.Matches) addMatch(match); - LadderInfo.Rounds.ItemsAdded += _ => layout.Invalidate(); - LadderInfo.Rounds.ItemsRemoved += _ => layout.Invalidate(); - - LadderInfo.Matches.ItemsAdded += matches => + LadderInfo.Rounds.CollectionChanged += (_, __) => layout.Invalidate(); + LadderInfo.Matches.CollectionChanged += (_, args) => { - foreach (var p in matches) - addMatch(p); - layout.Invalidate(); - }; + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var p in args.NewItems.Cast()) + addMatch(p); + break; - LadderInfo.Matches.ItemsRemoved += matches => - { - foreach (var p in matches) - foreach (var d in MatchesContainer.Where(d => d.Match == p)) - d.Expire(); + case NotifyCollectionChangedAction.Remove: + foreach (var p in args.OldItems.Cast()) + { + foreach (var d in MatchesContainer.Where(d => d.Match == p)) + d.Expire(); + } + + break; + } layout.Invalidate(); }; } - private Cached layout = new Cached(); + private readonly Cached layout = new Cached(); protected override void Update() { diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index d32c0d6156..2c4fed8d86 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Game.Beatmaps; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Tournament.Components; using osu.Game.Tournament.IPC; @@ -43,54 +42,60 @@ namespace osu.Game.Tournament.Screens.MapPool { InternalChildren = new Drawable[] { + new TourneyVideo("mappool") + { + Loop = true, + RelativeSizeAxes = Axes.Both, + }, new MatchHeader(), mapFlows = new FillFlowContainer> { - Y = 100, + Y = 160, Spacing = new Vector2(10, 10), - Padding = new MarginPadding(25), Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, }, new ControlPanel { Children = new Drawable[] { - new OsuSpriteText + new TournamentSpriteText { Text = "Current Mode" }, - buttonRedBan = new OsuButton + buttonRedBan = new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Red Ban", Action = () => setMode(TeamColour.Red, ChoiceType.Ban) }, - buttonBlueBan = new OsuButton + buttonBlueBan = new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Blue Ban", Action = () => setMode(TeamColour.Blue, ChoiceType.Ban) }, - buttonRedPick = new OsuButton + buttonRedPick = new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Red Pick", Action = () => setMode(TeamColour.Red, ChoiceType.Pick) }, - buttonBluePick = new OsuButton + buttonBluePick = new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Blue Pick", Action = () => setMode(TeamColour.Blue, ChoiceType.Pick) }, new ControlPanel.Spacer(), - new OsuButton + new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Reset", Action = reset }, + new ControlPanel.Spacer(), } } }; @@ -120,7 +125,7 @@ namespace osu.Game.Tournament.Screens.MapPool pickColour = colour; pickType = choiceType; - Color4 setColour(bool active) => active ? Color4.White : Color4.Gray; + static Color4 setColour(bool active) => active ? Color4.White : Color4.Gray; buttonRedBan.Colour = setColour(pickColour == TeamColour.Red && pickType == ChoiceType.Ban); buttonBlueBan.Colour = setColour(pickColour == TeamColour.Blue && pickType == ChoiceType.Ban); @@ -196,7 +201,7 @@ namespace osu.Game.Tournament.Screens.MapPool setNextMode(); - if (pickType == ChoiceType.Pick) + if (pickType == ChoiceType.Pick && currentMatch.Value.PicksBans.Any(i => i.Type == ChoiceType.Pick)) { scheduledChange?.Cancel(); scheduledChange = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(GameplayScreen)); }, 10000); @@ -207,11 +212,15 @@ namespace osu.Game.Tournament.Screens.MapPool { mapFlows.Clear(); + int totalRows = 0; + if (match.NewValue.Round.Value != null) { FillFlowContainer currentFlow = null; string currentMod = null; + int flowCount = 0; + foreach (var b in match.NewValue.Round.Value.Beatmaps) { if (currentFlow == null || currentMod != b.Mods) @@ -225,15 +234,31 @@ namespace osu.Game.Tournament.Screens.MapPool }); currentMod = b.Mods; + + totalRows++; + flowCount = 0; + } + + if (++flowCount > 2) + { + totalRows++; + flowCount = 1; } currentFlow.Add(new TournamentBeatmapPanel(b.BeatmapInfo, b.Mods) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, + Height = 42, }); } } + + mapFlows.Padding = new MarginPadding(5) + { + // remove horizontal padding to increase flow width to 3 panels + Horizontal = totalRows > 9 ? 0 : 100 + }; } } } diff --git a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs index 4b46264055..88289ad6bd 100644 --- a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs +++ b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Platform; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Ladder.Components; @@ -19,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Schedule { - public class ScheduleScreen : TournamentScreen, IProvideVideo + public class ScheduleScreen : TournamentScreen // IProvidesVideo { private readonly Bindable currentMatch = new Bindable(); private Container mainContainer; @@ -34,15 +33,68 @@ namespace osu.Game.Tournament.Screens.Schedule InternalChildren = new Drawable[] { - new TourneyVideo(storage.GetStream(@"BG Side Logo - OWC.m4v")) + new TourneyVideo("schedule") { RelativeSizeAxes = Axes.Both, Loop = true, }, - mainContainer = new Container + new Container { RelativeSizeAxes = Axes.Both, - } + Padding = new MarginPadding(100) { Bottom = 50 }, + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new DrawableTournamentHeaderText(), + new Container + { + Margin = new MarginPadding { Top = 40 }, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.White, + Size = new Vector2(50, 10), + }, + new TournamentSpriteTextWithBackground("Schedule") + { + X = 60, + Scale = new Vector2(0.8f) + } + } + }, + } + }, + }, + new Drawable[] + { + mainContainer = new Container + { + RelativeSizeAxes = Axes.Both, + } + } + } + } + } + }, }; currentMatch.BindValueChanged(matchChanged); @@ -63,7 +115,7 @@ namespace osu.Game.Tournament.Screens.Schedule .SelectMany(m => m.ConditionalMatches.Where(cp => m.Acronyms.TrueForAll(a => cp.Acronyms.Contains(a)))); upcoming = upcoming.Concat(conditionals); - upcoming = upcoming.OrderBy(p => p.Date.Value).Take(12); + upcoming = upcoming.OrderBy(p => p.Date.Value).Take(8); mainContainer.Child = new FillFlowContainer { @@ -74,7 +126,7 @@ namespace osu.Game.Tournament.Screens.Schedule new Container { RelativeSizeAxes = Axes.Both, - Height = 0.65f, + Height = 0.74f, Child = new FillFlowContainer { RelativeSizeAxes = Axes.Both, @@ -92,7 +144,7 @@ namespace osu.Game.Tournament.Screens.Schedule .Take(8) .Select(p => new ScheduleMatch(p)) }, - new ScheduleContainer("match overview") + new ScheduleContainer("upcoming matches") { RelativeSizeAxes = Axes.Both, Width = 0.6f, @@ -101,26 +153,57 @@ namespace osu.Game.Tournament.Screens.Schedule } } }, - new ScheduleContainer("current match") + new ScheduleContainer("coming up next") { RelativeSizeAxes = Axes.Both, Height = 0.25f, Children = new Drawable[] { - new OsuSpriteText + new FillFlowContainer { - Margin = new MarginPadding { Left = -10, Bottom = 10, Top = -5 }, - Spacing = new Vector2(10, 0), - Text = match.NewValue.Round.Value?.Name.Value, - Colour = Color4.Black, - Font = OsuFont.GetFont(size: 20) - }, - new ScheduleMatch(match.NewValue, false), - new OsuSpriteText - { - Text = "Start Time " + match.NewValue.Date.Value.ToUniversalTime().ToString("HH:mm UTC"), - Colour = Color4.Black, - Font = OsuFont.GetFont(size: 20) + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(30), + Children = new Drawable[] + { + new ScheduleMatch(match.NewValue, false) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new TournamentSpriteTextWithBackground(match.NewValue.Round.Value?.Name.Value) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.5f) + }, + new TournamentSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = match.NewValue.Team1.Value?.FullName + " vs " + match.NewValue.Team2.Value?.FullName, + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.SemiBold) + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + new TournamentSpriteText + { + Text = "Starting ", + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular) + }, + new DrawableDate(match.NewValue.Date.Value) + { + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular) + } + } + }, + } }, } } @@ -135,6 +218,10 @@ namespace osu.Game.Tournament.Screens.Schedule { Flow.Direction = FillDirection.Horizontal; + Scale = new Vector2(0.8f); + + CurrentMatchSelectionBox.Scale = new Vector2(1.02f, 1.15f); + bool conditional = match is ConditionalTournamentMatch; if (conditional) @@ -146,15 +233,16 @@ namespace osu.Game.Tournament.Screens.Schedule { Anchor = Anchor.TopRight, Origin = Anchor.TopLeft, - Colour = Color4.Black, + Colour = OsuColour.Gray(0.7f), Alpha = conditional ? 0.6f : 1, + Font = OsuFont.Torus, Margin = new MarginPadding { Horizontal = 10, Vertical = 5 }, }); - AddInternal(new OsuSpriteText + AddInternal(new TournamentSpriteText { Anchor = Anchor.BottomRight, Origin = Anchor.BottomLeft, - Colour = Color4.Black, + Colour = OsuColour.Gray(0.7f), Alpha = conditional ? 0.6f : 1, Margin = new MarginPadding { Horizontal = 10, Vertical = 5 }, Text = match.Date.Value.ToUniversalTime().ToString("HH:mm UTC") + (conditional ? " (conditional)" : "") @@ -171,29 +259,27 @@ namespace osu.Game.Tournament.Screens.Schedule public ScheduleContainer(string title) { - Padding = new MarginPadding { Left = 30, Top = 30 }; + Padding = new MarginPadding { Left = 60, Top = 10 }; InternalChildren = new Drawable[] { - new OsuSpriteText + new FillFlowContainer { - X = 30, - Text = title, - Colour = Color4.Black, - Spacing = new Vector2(10, 0), - Font = OsuFont.GetFont(size: 30) - }, - content = new FillFlowContainer - { - Direction = FillDirection.Vertical, RelativeSizeAxes = Axes.Both, - Margin = new MarginPadding(40) + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new TournamentSpriteTextWithBackground(title.ToUpperInvariant()) + { + Scale = new Vector2(0.5f) + }, + content = new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.Both, + Margin = new MarginPadding(10) + }, + } }, - new Circle - { - Colour = new Color4(233, 187, 79, 255), - Width = 5, - RelativeSizeAxes = Axes.Y, - } }; } } diff --git a/osu.Game.Tournament/Screens/SetupScreen.cs b/osu.Game.Tournament/Screens/SetupScreen.cs new file mode 100644 index 0000000000..98bc292901 --- /dev/null +++ b/osu.Game.Tournament/Screens/SetupScreen.cs @@ -0,0 +1,258 @@ +// 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.Drawing; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Rulesets; +using osu.Game.Tournament.IPC; +using osu.Game.Tournament.Models; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tournament.Screens +{ + public class SetupScreen : TournamentScreen, IProvideVideo + { + private FillFlowContainer fillFlow; + + private LoginOverlay loginOverlay; + private ResolutionSelector resolution; + + [Resolved] + private MatchIPCInfo ipc { get; set; } + + [Resolved] + private StableInfo stableInfo { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } + + [Resolved(canBeNull: true)] + private TournamentSceneManager sceneManager { get; set; } + + private Bindable windowSize; + + [BackgroundDependencyLoader] + private void load(FrameworkConfigManager frameworkConfig) + { + windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); + + InternalChild = fillFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(10), + Spacing = new Vector2(10), + }; + + api.LocalUser.BindValueChanged(_ => Schedule(reload)); + stableInfo.OnStableInfoSaved += () => Schedule(reload); + reload(); + } + + [Resolved] + private Framework.Game game { get; set; } + + private void reload() + { + var fileBasedIpc = ipc as FileBasedIPC; + fillFlow.Children = new Drawable[] + { + new ActionableInfo + { + Label = "Current IPC source", + ButtonText = "Change source", + Action = () => sceneManager?.SetScreen(new StablePathSelectScreen()), + Value = fileBasedIpc?.IPCStorage?.GetFullPath(string.Empty) ?? "Not found", + Failing = fileBasedIpc?.IPCStorage == null, + Description = "The osu!stable installation which is currently being used as a data source. If a source is not found, make sure you have created an empty ipc.txt in your stable cutting-edge installation." + }, + new ActionableInfo + { + Label = "Current User", + ButtonText = "Change Login", + Action = () => + { + api.Logout(); + + if (loginOverlay == null) + { + AddInternal(loginOverlay = new LoginOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }); + } + + loginOverlay.State.Value = Visibility.Visible; + }, + Value = api?.LocalUser.Value.Username, + Failing = api?.IsLoggedIn != true, + Description = "In order to access the API and display metadata, a login is required." + }, + new LabelledDropdown + { + Label = "Ruleset", + Description = "Decides what stats are displayed and which ranks are retrieved for players", + Items = rulesets.AvailableRulesets, + Current = LadderInfo.Ruleset, + }, + resolution = new ResolutionSelector + { + Label = "Stream area resolution", + ButtonText = "Set height", + Action = height => + { + windowSize.Value = new Size((int)(height * aspect_ratio / TournamentSceneManager.STREAM_AREA_WIDTH * TournamentSceneManager.REQUIRED_WIDTH), height); + } + }, + }; + } + + private const float aspect_ratio = 16f / 9f; + + protected override void Update() + { + base.Update(); + + resolution.Value = $"{ScreenSpaceDrawQuad.Width:N0}x{ScreenSpaceDrawQuad.Height:N0}"; + } + + public class LabelledDropdown : LabelledComponent, T> + { + public LabelledDropdown() + : base(true) + { + } + + public IEnumerable Items + { + get => Component.Items; + set => Component.Items = value; + } + + protected override OsuDropdown CreateComponent() => new OsuDropdown + { + RelativeSizeAxes = Axes.X, + Width = 0.5f, + }; + } + + private class ActionableInfo : LabelledDrawable + { + private OsuButton button; + + public ActionableInfo() + : base(true) + { + } + + public string ButtonText + { + set => button.Text = value; + } + + public string Value + { + set => valueText.Text = value; + } + + public bool Failing + { + set => valueText.Colour = value ? Color4.Red : Color4.White; + } + + public Action Action; + + private TournamentSpriteText valueText; + protected FillFlowContainer FlowContainer; + + protected override Drawable CreateComponent() => new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Children = new Drawable[] + { + valueText = new TournamentSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + FlowContainer = new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + button = new TriangleButton + { + Size = new Vector2(100, 40), + Action = () => Action?.Invoke() + } + } + } + } + }; + } + + private class ResolutionSelector : ActionableInfo + { + private const int minimum_window_height = 480; + private const int maximum_window_height = 2160; + + public new Action Action; + + private OsuNumberBox numberBox; + + protected override Drawable CreateComponent() + { + var drawable = base.CreateComponent(); + FlowContainer.Insert(-1, numberBox = new OsuNumberBox + { + Text = "1080", + Width = 100 + }); + + base.Action = () => + { + if (string.IsNullOrEmpty(numberBox.Text)) + return; + + // box contains text + if (!int.TryParse(numberBox.Text, out var number)) + { + // at this point, the only reason we can arrive here is if the input number was too big to parse into an int + // so clamp to max allowed value + number = maximum_window_height; + } + else + { + number = Math.Clamp(number, minimum_window_height, maximum_window_height); + } + + // in case number got clamped, reset number in numberBox + numberBox.Text = number.ToString(); + + Action?.Invoke(number); + }; + return drawable; + } + } + } +} diff --git a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs index 20928499bf..9785b7e647 100644 --- a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs +++ b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs @@ -2,15 +2,42 @@ // 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.Game.Tournament.Components; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Showcase { - public class ShowcaseScreen : BeatmapInfoScreen + public class ShowcaseScreen : BeatmapInfoScreen // IProvideVideo { [BackgroundDependencyLoader] private void load() { - AddInternal(new TournamentLogo(false)); + AddRangeInternal(new Drawable[] + { + new TournamentLogo(), + new TourneyVideo("showcase") + { + Loop = true, + RelativeSizeAxes = Axes.Both, + }, + new Container + { + Padding = new MarginPadding { Bottom = SongBar.HEIGHT }, + RelativeSizeAxes = Axes.Both, + Child = new Box + { + // chroma key area for stable gameplay + Name = "chroma", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Colour = new Color4(0, 255, 0, 255), + } + } + }); } } } diff --git a/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs b/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs index 1fee2b29e8..bd5aa2f5d9 100644 --- a/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs +++ b/osu.Game.Tournament/Screens/Showcase/TournamentLogo.cs @@ -11,20 +11,12 @@ namespace osu.Game.Tournament.Screens.Showcase { public class TournamentLogo : CompositeDrawable { - public TournamentLogo(bool includeRoundBackground = true) + public TournamentLogo() { RelativeSizeAxes = Axes.X; Margin = new MarginPadding { Vertical = 5 }; - if (includeRoundBackground) - { - AutoSizeAxes = Axes.Y; - } - else - { - Masking = true; - Height = 100; - } + Height = 100; } [BackgroundDependencyLoader] @@ -32,9 +24,11 @@ namespace osu.Game.Tournament.Screens.Showcase { InternalChild = new Sprite { - Texture = textures.Get("game-screen-logo"), Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, + FillMode = FillMode.Fit, + RelativeSizeAxes = Axes.Both, + Texture = textures.Get("header-logo"), }; } } diff --git a/osu.Game.Tournament/Screens/StablePathSelectScreen.cs b/osu.Game.Tournament/Screens/StablePathSelectScreen.cs new file mode 100644 index 0000000000..717b43f704 --- /dev/null +++ b/osu.Game.Tournament/Screens/StablePathSelectScreen.cs @@ -0,0 +1,164 @@ +// 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.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osu.Game.Tournament.IPC; +using osu.Game.Tournament.Components; +using osuTK; + +namespace osu.Game.Tournament.Screens +{ + public class StablePathSelectScreen : TournamentScreen + { + [Resolved] + private GameHost host { get; set; } + + [Resolved(canBeNull: true)] + private TournamentSceneManager sceneManager { get; set; } + + [Resolved] + private MatchIPCInfo ipc { get; set; } + + private DirectorySelector directorySelector; + private DialogOverlay overlay; + + [BackgroundDependencyLoader(true)] + private void load(Storage storage, OsuColour colours) + { + var initialStorage = (ipc as FileBasedIPC)?.IPCStorage ?? storage; + var initialPath = new DirectoryInfo(initialStorage.GetFullPath(string.Empty)).Parent?.FullName; + + AddRangeInternal(new Drawable[] + { + 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 FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new TriangleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, + Text = "Select stable path", + Action = ChangePath + }, + new TriangleButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, + Text = "Auto detect", + Action = AutoDetect + }, + } + } + } + } + } + }, + }, + new BackButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + State = { Value = Visibility.Visible }, + Action = () => sceneManager?.SetScreen(typeof(SetupScreen)) + } + }); + } + + protected virtual void ChangePath() + { + var target = directorySelector.CurrentPath.Value.FullName; + var fileBasedIpc = ipc as FileBasedIPC; + Logger.Log($"Changing Stable CE location to {target}"); + + if (!fileBasedIpc?.SetIPCLocation(target) ?? true) + { + overlay = new DialogOverlay(); + overlay.Push(new IPCErrorDialog("This is an invalid IPC Directory", "Select a directory that contains an osu! stable cutting edge installation and make sure it has an empty ipc.txt file in it.")); + AddInternal(overlay); + Logger.Log("Folder is not an osu! stable CE directory"); + return; + } + + sceneManager?.SetScreen(typeof(SetupScreen)); + } + + protected virtual void AutoDetect() + { + var fileBasedIpc = ipc as FileBasedIPC; + + if (!fileBasedIpc?.AutoDetectIPCLocation() ?? true) + { + overlay = new DialogOverlay(); + overlay.Push(new IPCErrorDialog("Failed to auto detect", "An osu! stable cutting-edge installation could not be auto detected.\nPlease try and manually point to the directory.")); + AddInternal(overlay); + } + else + { + sceneManager?.SetScreen(typeof(SetupScreen)); + } + } + } +} diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs new file mode 100644 index 0000000000..55fc80dba2 --- /dev/null +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.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 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.Graphics.Textures; +using osu.Framework.Platform; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Tournament.Components; +using osu.Game.Tournament.Models; +using osu.Game.Tournament.Screens.Ladder.Components; +using osuTK; + +namespace osu.Game.Tournament.Screens.TeamIntro +{ + public class SeedingScreen : TournamentScreen, IProvideVideo + { + private Container mainContainer; + + private readonly Bindable currentMatch = new Bindable(); + + private readonly Bindable currentTeam = new Bindable(); + + [BackgroundDependencyLoader] + private void load(Storage storage) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new TourneyVideo("seeding") + { + RelativeSizeAxes = Axes.Both, + Loop = true, + }, + mainContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }, + new ControlPanel + { + Children = new Drawable[] + { + new TourneyButton + { + RelativeSizeAxes = Axes.X, + Text = "Show first team", + Action = () => currentTeam.Value = currentMatch.Value.Team1.Value, + }, + new TourneyButton + { + RelativeSizeAxes = Axes.X, + Text = "Show second team", + Action = () => currentTeam.Value = currentMatch.Value.Team2.Value, + }, + new SettingsTeamDropdown(LadderInfo.Teams) + { + LabelText = "Show specific team", + Current = currentTeam, + } + } + } + }; + + currentMatch.BindValueChanged(matchChanged); + currentMatch.BindTo(LadderInfo.CurrentMatch); + + currentTeam.BindValueChanged(teamChanged, true); + } + + private void teamChanged(ValueChangedEvent team) + { + if (team.NewValue == null) + { + mainContainer.Clear(); + return; + } + + showTeam(team.NewValue); + } + + private void matchChanged(ValueChangedEvent match) => + currentTeam.Value = currentMatch.Value.Team1.Value; + + private void showTeam(TournamentTeam team) + { + mainContainer.Children = new Drawable[] + { + new LeftInfo(team) { Position = new Vector2(55, 150), }, + new RightInfo(team) { Position = new Vector2(500, 150), }, + }; + } + + private class RightInfo : CompositeDrawable + { + public RightInfo(TournamentTeam team) + { + FillFlowContainer fill; + + Width = 400; + + InternalChildren = new Drawable[] + { + fill = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }, + }; + + foreach (var seeding in team.SeedingResults) + { + fill.Add(new ModRow(seeding.Mod.Value, seeding.Seed.Value)); + foreach (var beatmap in seeding.Beatmaps) + fill.Add(new BeatmapScoreRow(beatmap)); + } + } + + private class BeatmapScoreRow : CompositeDrawable + { + public BeatmapScoreRow(SeedingBeatmap beatmap) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new TournamentSpriteText { Text = beatmap.BeatmapInfo.Metadata.Title, Colour = TournamentGame.TEXT_COLOUR, }, + new TournamentSpriteText { Text = "by", Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) }, + new TournamentSpriteText { Text = beatmap.BeatmapInfo.Metadata.Artist, Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(40), + Children = new Drawable[] + { + new TournamentSpriteText { Text = beatmap.Score.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Width = 80 }, + new TournamentSpriteText { Text = "#" + beatmap.Seed.Value.ToString("#,0"), Colour = TournamentGame.TEXT_COLOUR, Font = OsuFont.Torus.With(weight: FontWeight.Regular) }, + } + }, + }; + } + } + + private class ModRow : CompositeDrawable + { + private readonly string mods; + private readonly int seeding; + + public ModRow(string mods, int seeding) + { + this.mods = mods; + this.seeding = seeding; + + Padding = new MarginPadding { Vertical = 10 }; + + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new Sprite + { + Texture = textures.Get($"mods/{mods.ToLower()}"), + Scale = new Vector2(0.5f) + }, + new Container + { + Size = new Vector2(50, 16), + CornerRadius = 10, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = TournamentGame.ELEMENT_BACKGROUND_COLOUR, + }, + new TournamentSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = seeding.ToString("#,0"), + Colour = TournamentGame.ELEMENT_FOREGROUND_COLOUR + }, + } + }, + } + }, + }; + } + } + } + + private class LeftInfo : CompositeDrawable + { + public LeftInfo(TournamentTeam team) + { + FillFlowContainer fill; + + Width = 200; + + if (team == null) return; + + InternalChildren = new Drawable[] + { + fill = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new TeamDisplay(team) { Margin = new MarginPadding { Bottom = 30 } }, + new RowDisplay("Average Rank:", $"#{team.AverageRank:#,0}"), + new RowDisplay("Seed:", team.Seed.Value), + new RowDisplay("Last year's placing:", team.LastYearPlacing.Value > 0 ? $"#{team.LastYearPlacing:#,0}" : "0"), + new Container { Margin = new MarginPadding { Bottom = 30 } }, + } + }, + }; + + foreach (var p in team.Players) + fill.Add(new RowDisplay(p.Username, p.Statistics?.Ranks.Global?.ToString("\\##,0") ?? "-")); + } + + internal class RowDisplay : CompositeDrawable + { + public RowDisplay(string left, string right) + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + + InternalChildren = new Drawable[] + { + new TournamentSpriteText + { + Text = left, + Colour = TournamentGame.TEXT_COLOUR, + Font = OsuFont.Torus.With(size: 22, weight: FontWeight.SemiBold), + }, + new TournamentSpriteText + { + Text = right, + Colour = TournamentGame.TEXT_COLOUR, + Anchor = Anchor.TopRight, + Origin = Anchor.TopLeft, + Font = OsuFont.Torus.With(size: 22, weight: FontWeight.Regular), + }, + }; + } + } + + private class TeamDisplay : DrawableTournamentTeam + { + public TeamDisplay(TournamentTeam team) + : base(team) + { + AutoSizeAxes = Axes.Both; + + Flag.RelativeSizeAxes = Axes.None; + Flag.Scale = new Vector2(1.2f); + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + Flag, + new OsuSpriteText + { + Text = team?.FullName.Value ?? "???", + Font = OsuFont.Torus.With(size: 32, weight: FontWeight.SemiBold), + Colour = TournamentGame.TEXT_COLOUR, + }, + } + }; + } + } + } + } +} diff --git a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs index c901a5c7ef..6c2848897b 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs @@ -6,13 +6,9 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Platform; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; -using osu.Game.Tournament.Screens.Showcase; using osuTK; -using osuTK.Graphics; namespace osu.Game.Tournament.Screens.TeamIntro { @@ -29,12 +25,11 @@ namespace osu.Game.Tournament.Screens.TeamIntro InternalChildren = new Drawable[] { - new TourneyVideo(storage.GetStream(@"BG Team - Both OWC.m4v")) + new TourneyVideo("teamintro") { RelativeSizeAxes = Axes.Both, Loop = true, }, - new TournamentLogo(false), mainContainer = new Container { RelativeSizeAxes = Axes.Both, @@ -53,169 +48,33 @@ namespace osu.Game.Tournament.Screens.TeamIntro return; } + const float y_flag_offset = 292; + + const float y_offset = 460; + mainContainer.Children = new Drawable[] { - new TeamWithPlayers(match.NewValue.Team1.Value, true) - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Height = 0.6f, - Anchor = Anchor.Centre, - Origin = Anchor.CentreRight - }, - new TeamWithPlayers(match.NewValue.Team2.Value) - { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Height = 0.6f, - Anchor = Anchor.Centre, - Origin = Anchor.CentreLeft - }, new RoundDisplay(match.NewValue) { - RelativeSizeAxes = Axes.Both, - Height = 0.25f, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - } + Position = new Vector2(100, 100) + }, + new DrawableTeamFlag(match.NewValue.Team1.Value) + { + Position = new Vector2(165, y_flag_offset), + }, + new DrawableTeamWithPlayers(match.NewValue.Team1.Value, TeamColour.Red) + { + Position = new Vector2(165, y_offset), + }, + new DrawableTeamFlag(match.NewValue.Team2.Value) + { + Position = new Vector2(740, y_flag_offset), + }, + new DrawableTeamWithPlayers(match.NewValue.Team2.Value, TeamColour.Blue) + { + Position = new Vector2(740, y_offset), + }, }; } - - private class RoundDisplay : CompositeDrawable - { - public RoundDisplay(TournamentMatch match) - { - var col = OsuColour.Gray(0.33f); - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10), - Children = new Drawable[] - { - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = "COMING UP NEXT", - Spacing = new Vector2(2, 0), - Font = OsuFont.GetFont(size: 15, weight: FontWeight.Black) - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = match.Round.Value?.Name.Value ?? "Unknown Round", - Spacing = new Vector2(10, 0), - Font = OsuFont.GetFont(size: 50, weight: FontWeight.Light) - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = match.Date.Value.ToUniversalTime().ToString("dd MMMM HH:mm UTC"), - Font = OsuFont.GetFont(size: 20) - }, - } - } - }; - } - } - - private class TeamWithPlayers : CompositeDrawable - { - private readonly Color4 red = new Color4(129, 68, 65, 255); - private readonly Color4 blue = new Color4(41, 91, 97, 255); - - public TeamWithPlayers(TournamentTeam team, bool left = false) - { - FillFlowContainer players; - var colour = left ? red : blue; - InternalChildren = new Drawable[] - { - new TeamDisplay(team, left ? "Team Red" : "Team Blue", colour) - { - Anchor = left ? Anchor.CentreRight : Anchor.CentreLeft, - Origin = Anchor.Centre, - RelativePositionAxes = Axes.Both, - X = (left ? -1 : 1) * 0.36f, - }, - players = new FillFlowContainer - { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(0, 5), - Padding = new MarginPadding(20), - Anchor = left ? Anchor.CentreRight : Anchor.CentreLeft, - Origin = left ? Anchor.CentreRight : Anchor.CentreLeft, - RelativePositionAxes = Axes.Both, - X = (left ? -1 : 1) * 0.66f, - }, - }; - - if (team != null) - { - foreach (var p in team.Players) - players.Add(new OsuSpriteText - { - Text = p.Username, - Font = OsuFont.GetFont(size: 24), - Colour = colour, - Anchor = left ? Anchor.CentreRight : Anchor.CentreLeft, - Origin = left ? Anchor.CentreRight : Anchor.CentreLeft, - }); - } - } - - private class TeamDisplay : DrawableTournamentTeam - { - public TeamDisplay(TournamentTeam team, string teamName, Color4 colour) - : base(team) - { - AutoSizeAxes = Axes.Both; - - Flag.Anchor = Flag.Origin = Anchor.TopCentre; - Flag.RelativeSizeAxes = Axes.None; - Flag.Size = new Vector2(300, 200); - Flag.Scale = new Vector2(0.4f); - Flag.Margin = new MarginPadding { Bottom = 20 }; - - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), - Children = new Drawable[] - { - Flag, - new OsuSpriteText - { - Text = team?.FullName.Value.ToUpper() ?? "???", - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 40, FontWeight.Light), - Colour = Color4.Black, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - }, - new OsuSpriteText - { - Text = teamName.ToUpper(), - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 20, FontWeight.Regular), - Colour = colour, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - } - } - }; - } - } - } } } diff --git a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs index a0216c5db3..7ca262a2e8 100644 --- a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs +++ b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs @@ -7,12 +7,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; -using osu.Game.Tournament.Screens.Showcase; using osuTK; -using osuTK.Graphics; namespace osu.Game.Tournament.Screens.TeamWin { @@ -33,22 +30,18 @@ namespace osu.Game.Tournament.Screens.TeamWin InternalChildren = new Drawable[] { - blueWinVideo = new TourneyVideo(storage.GetStream(@"BG Team - Win Blue.m4v")) + blueWinVideo = new TourneyVideo("teamwin-blue") { Alpha = 1, RelativeSizeAxes = Axes.Both, Loop = true, }, - redWinVideo = new TourneyVideo(storage.GetStream(@"BG Team - Win Red.m4v")) + redWinVideo = new TourneyVideo("teamwin-red") { Alpha = 0, RelativeSizeAxes = Axes.Both, Loop = true, }, - new TournamentLogo(false) - { - Y = 40, - }, mainContainer = new Container { RelativeSizeAxes = Axes.Both, @@ -69,7 +62,9 @@ namespace osu.Game.Tournament.Screens.TeamWin update(); } - private void update() + private bool firstDisplay = true; + + private void update() => Schedule(() => { var match = currentMatch.Value; @@ -79,147 +74,52 @@ namespace osu.Game.Tournament.Screens.TeamWin return; } - bool redWin = match.Winner == match.Team1.Value; - redWinVideo.Alpha = redWin ? 1 : 0; - blueWinVideo.Alpha = redWin ? 0 : 1; + redWinVideo.Alpha = match.WinnerColour == TeamColour.Red ? 1 : 0; + blueWinVideo.Alpha = match.WinnerColour == TeamColour.Blue ? 1 : 0; + + if (firstDisplay) + { + if (match.WinnerColour == TeamColour.Red) + redWinVideo.Reset(); + else + blueWinVideo.Reset(); + firstDisplay = false; + } mainContainer.Children = new Drawable[] { - new TeamWithPlayers(match.Winner, redWin) + new DrawableTeamFlag(match.Winner) { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Height = 0.6f, Anchor = Anchor.Centre, - Origin = Anchor.Centre + Origin = Anchor.Centre, + Position = new Vector2(-300, 10), + Scale = new Vector2(2f) }, - new RoundDisplay(match) + new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - Height = 0.25f, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - } - }; - } - - private class RoundDisplay : CompositeDrawable - { - public RoundDisplay(TournamentMatch match) - { - var col = OsuColour.Gray(0.33f); - - InternalChildren = new Drawable[] - { - new FillFlowContainer + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = 260, + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10), - Children = new Drawable[] + new RoundDisplay(match) { - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = "WINNER", - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 15, FontWeight.Regular), - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = match.Round.Value?.Name.Value ?? "Unknown Round", - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 50, FontWeight.Light), - Spacing = new Vector2(10, 0), - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = col, - Text = match.Date.Value.ToUniversalTime().ToString("dd MMMM HH:mm UTC"), - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 20, FontWeight.Light), - }, - } + Margin = new MarginPadding { Bottom = 30 }, + }, + new TournamentSpriteText + { + Text = "WINNER", + Font = OsuFont.Torus.With(size: 100, weight: FontWeight.Bold), + Margin = new MarginPadding { Bottom = 50 }, + }, + new DrawableTeamWithPlayers(match.Winner, match.WinnerColour) } - }; - } - } - - private class TeamWithPlayers : CompositeDrawable - { - private readonly Color4 red = new Color4(129, 68, 65, 255); - private readonly Color4 blue = new Color4(41, 91, 97, 255); - - public TeamWithPlayers(TournamentTeam team, bool left = false) - { - var colour = left ? red : blue; - InternalChildren = new Drawable[] - { - new TeamDisplay(team, left ? "Team Red" : "Team Blue", colour) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new FillFlowContainer - { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(0, 5), - Padding = new MarginPadding(20), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativePositionAxes = Axes.Both, - }, - }; - } - - private class TeamDisplay : DrawableTournamentTeam - { - public TeamDisplay(TournamentTeam team, string teamName, Color4 colour) - : base(team) - { - AutoSizeAxes = Axes.Both; - - Flag.Anchor = Flag.Origin = Anchor.TopCentre; - Flag.RelativeSizeAxes = Axes.None; - Flag.Size = new Vector2(300, 200); - Flag.Scale = new Vector2(0.4f); - Flag.Margin = new MarginPadding { Bottom = 20 }; - - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), - Children = new Drawable[] - { - Flag, - new OsuSpriteText - { - Text = team?.FullName.Value.ToUpper() ?? "???", - Font = TournamentFont.GetFont(TournamentTypeface.Aquatico, 40, FontWeight.Light), - Colour = Color4.Black, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - }, - new OsuSpriteText - { - Text = teamName.ToUpper(), - Font = OsuFont.GetFont(size: 20), - Colour = colour, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - } - } - }; - } - } - } + }, + }; + mainContainer.FadeOut(); + mainContainer.Delay(2000).FadeIn(1600, Easing.OutQuint); + }); } } diff --git a/osu.Game.Tournament/Screens/TournamentScreen.cs b/osu.Game.Tournament/Screens/TournamentScreen.cs index 9d58ca2240..5da7c7a5d2 100644 --- a/osu.Game.Tournament/Screens/TournamentScreen.cs +++ b/osu.Game.Tournament/Screens/TournamentScreen.cs @@ -10,22 +10,21 @@ namespace osu.Game.Tournament.Screens { public abstract class TournamentScreen : CompositeDrawable { + public const double FADE_DELAY = 200; + [Resolved] protected LadderInfo LadderInfo { get; private set; } protected TournamentScreen() { RelativeSizeAxes = Axes.Both; + + FillMode = FillMode.Fit; + FillAspectRatio = 16 / 9f; } - public override void Hide() - { - this.FadeOut(200); - } + public override void Hide() => this.FadeOut(FADE_DELAY); - public override void Show() - { - this.FadeIn(200); - } + public override void Show() => this.FadeIn(FADE_DELAY); } } diff --git a/osu.Game.Tournament/TournamentFont.cs b/osu.Game.Tournament/TournamentFont.cs deleted file mode 100644 index f9e60ff2bc..0000000000 --- a/osu.Game.Tournament/TournamentFont.cs +++ /dev/null @@ -1,75 +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.Sprites; -using osu.Game.Graphics; - -namespace osu.Game.Tournament -{ - public static class TournamentFont - { - /// - /// The default font size. - /// - public const float DEFAULT_FONT_SIZE = 16; - - /// - /// Retrieves a . - /// - /// The font typeface. - /// The size of the text in local space. For a value of 16, a single line will have a height of 16px. - /// The font weight. - /// Whether the font is italic. - /// Whether all characters should be spaced the same distance apart. - /// The . - public static FontUsage GetFont(TournamentTypeface typeface = TournamentTypeface.Aquatico, float size = DEFAULT_FONT_SIZE, FontWeight weight = FontWeight.Medium, bool italics = false, bool fixedWidth = false) - => new FontUsage(GetFamilyString(typeface), size, GetWeightString(typeface, weight), italics, fixedWidth); - - /// - /// Retrieves the string representation of a . - /// - /// The . - /// The string representation. - public static string GetFamilyString(TournamentTypeface typeface) - { - switch (typeface) - { - case TournamentTypeface.Aquatico: - return "Aquatico"; - } - - return null; - } - - /// - /// Retrieves the string representation of a . - /// - /// The . - /// The . - /// The string representation of in the specified . - public static string GetWeightString(TournamentTypeface typeface, FontWeight weight) - => GetWeightString(GetFamilyString(typeface), weight); - - /// - /// Retrieves the string representation of a . - /// - /// The family string. - /// The . - /// The string representation of in the specified . - public static string GetWeightString(string family, FontWeight weight) - { - string weightString = weight.ToString(); - - // Only exo has an explicit "regular" weight, other fonts do not - if (weight == FontWeight.Regular && family != GetFamilyString(TournamentTypeface.Aquatico) && family != GetFamilyString(TournamentTypeface.Aquatico)) - weightString = string.Empty; - - return weightString; - } - } - - public enum TournamentTypeface - { - Aquatico - } -} diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index 7dbcf37af6..bbe4a53d8f 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -1,25 +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.Drawing; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Configuration; using osu.Framework.Graphics; +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.Game.Graphics; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Tournament { public class TournamentGame : TournamentGameBase { - protected override void LoadComplete() + public static ColourInfo GetTeamColour(TeamColour teamColour) => teamColour == TeamColour.Red ? COLOUR_RED : COLOUR_BLUE; + + public static readonly Color4 COLOUR_RED = Color4Extensions.FromHex("#AA1414"); + public static readonly Color4 COLOUR_BLUE = Color4Extensions.FromHex("#1462AA"); + + public static readonly Color4 ELEMENT_BACKGROUND_COLOUR = Color4Extensions.FromHex("#fff"); + public static readonly Color4 ELEMENT_FOREGROUND_COLOUR = Color4Extensions.FromHex("#000"); + + public static readonly Color4 TEXT_COLOUR = Color4Extensions.FromHex("#fff"); + private Drawable heightWarning; + private Bindable windowSize; + private Bindable windowMode; + + [BackgroundDependencyLoader] + private void load(FrameworkConfigManager frameworkConfig) { - base.LoadComplete(); - - Add(new OsuContextMenuContainer + windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); + windowSize.BindValueChanged(size => ScheduleAfterChildren(() => { - RelativeSizeAxes = Axes.Both, - Child = new TournamentSceneManager() - }); + var minWidth = (int)(size.NewValue.Height / 768f * TournamentSceneManager.REQUIRED_WIDTH) - 1; - // we don't want to show the menu cursor as it would appear on stream output. - MenuCursorContainer.Cursor.Alpha = 0; + heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0; + }), true); + + windowMode = frameworkConfig.GetBindable(FrameworkSetting.WindowMode); + windowMode.BindValueChanged(mode => ScheduleAfterChildren(() => + { + windowMode.Value = WindowMode.Windowed; + }), true); + + AddRange(new[] + { + new Container + { + CornerRadius = 10, + Depth = float.MinValue, + Position = new Vector2(5), + Masking = true, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Children = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.2f), + RelativeSizeAxes = Axes.Both, + }, + new TourneyButton + { + Text = "Save Changes", + Width = 140, + Height = 50, + Padding = new MarginPadding + { + Top = 10, + Left = 10, + }, + Margin = new MarginPadding + { + Right = 10, + Bottom = 10, + }, + Action = SaveChanges, + }, + } + }, + heightWarning = new WarningBox("Please make the window wider"), + new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new TournamentSceneManager() + } + }); } } } diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 06fb52da77..dbda6aa023 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -1,38 +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.Drawing; +using System; using System.IO; using System.Linq; using Newtonsoft.Json; using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Configuration; -using osu.Framework.Graphics; using osu.Framework.Graphics.Textures; using osu.Framework.Input; -using osu.Framework.IO.Stores; using osu.Framework.Platform; +using osu.Framework.IO.Stores; using osu.Game.Beatmaps; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests; using osu.Game.Tournament.IPC; +using osu.Game.Tournament.IO; using osu.Game.Tournament.Models; +using osu.Game.Users; using osuTK.Input; namespace osu.Game.Tournament { - public abstract class TournamentGameBase : OsuGameBase + [Cached(typeof(TournamentGameBase))] + public class TournamentGameBase : OsuGameBase { private const string bracket_filename = "bracket.json"; - private LadderInfo ladder; - - private Storage storage; - + private TournamentStorage storage; private DependencyContainer dependencies; - - private Bindable windowSize; private FileBasedIPC ipc; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -41,37 +35,23 @@ namespace osu.Game.Tournament } [BackgroundDependencyLoader] - private void load(Storage storage, FrameworkConfigManager frameworkConfig) + private void load(Storage baseStorage) { - Resources.AddStore(new DllResourceStore(@"osu.Game.Tournament.dll")); + Resources.AddStore(new DllResourceStore(typeof(TournamentGameBase).Assembly)); - Fonts.AddStore(new GlyphStore(Resources, @"Resources/Fonts/Aquatico-Regular")); - Fonts.AddStore(new GlyphStore(Resources, @"Resources/Fonts/Aquatico-Light")); + dependencies.CacheAs(storage = new TournamentStorage(baseStorage)); + dependencies.Cache(new TournamentVideoResourceStore(storage)); - Textures.AddStore(new TextureLoaderStore(new ResourceStore(new StorageBackedResourceStore(storage)))); - - this.storage = storage; - - windowSize = frameworkConfig.GetBindable(FrameworkSetting.WindowedSize); + Textures.AddStore(new TextureLoaderStore(new StorageBackedResourceStore(storage))); readBracket(); ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value); + dependencies.CacheAs(new StableInfo(storage)); + dependencies.CacheAs(ipc = new FileBasedIPC()); Add(ipc); - - Add(new OsuButton - { - Text = "Save Changes", - Width = 140, - Height = 50, - Depth = float.MinValue, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Padding = new MarginPadding(10), - Action = SaveChanges, - }); } private void readBracket() @@ -82,10 +62,11 @@ namespace osu.Game.Tournament using (var sr = new StreamReader(stream)) ladder = JsonConvert.DeserializeObject(sr.ReadToEnd()); } - else - { - ladder = new LadderInfo(); - } + + ladder ??= new LadderInfo(); + ladder.Ruleset.Value ??= RulesetStore.AvailableRulesets.First(); + + Ruleset.BindTo(ladder.Ruleset); dependencies.Cache(ladder); @@ -125,15 +106,17 @@ namespace osu.Game.Tournament // link matches to rounds foreach (var round in ladder.Rounds) - foreach (var id in round.Matches) { - var found = ladder.Matches.FirstOrDefault(p => p.ID == id); - - if (found != null) + foreach (var id in round.Matches) { - found.Round.Value = round; - if (round.StartDate.Value > found.Date.Value) - found.Date.Value = round.StartDate.Value; + var found = ladder.Matches.FirstOrDefault(p => p.ID == id); + + if (found != null) + { + found.Round.Value = round; + if (round.StartDate.Value > found.Date.Value) + found.Date.Value = round.StartDate.Value; + } } } @@ -153,15 +136,16 @@ namespace osu.Game.Tournament bool addedInfo = false; foreach (var t in ladder.Teams) - foreach (var p in t.Players) - if (string.IsNullOrEmpty(p.Username)) + { + foreach (var p in t.Players) { - var req = new GetUserRequest(p.Id); - req.Perform(API); - p.Username = req.Result.Username; - - addedInfo = true; + if (string.IsNullOrEmpty(p.Username) || p.Statistics == null) + { + PopulateUser(p); + addedInfo = true; + } } + } return addedInfo; } @@ -174,39 +158,81 @@ namespace osu.Game.Tournament bool addedInfo = false; foreach (var r in ladder.Rounds) - foreach (var b in r.Beatmaps) - if (b.BeatmapInfo == null) + { + foreach (var b in r.Beatmaps.ToList()) { - var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = b.ID }); - req.Perform(API); - b.BeatmapInfo = req.Result?.ToBeatmap(RulesetStore); + if (b.BeatmapInfo != null) + continue; - addedInfo = true; + if (b.ID > 0) + { + var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = b.ID }); + API.Perform(req); + b.BeatmapInfo = req.Result?.ToBeatmap(RulesetStore); + + addedInfo = true; + } + + if (b.BeatmapInfo == null) + // if online population couldn't be performed, ensure we don't leave a null value behind + r.Beatmaps.Remove(b); } + } + + foreach (var t in ladder.Teams) + { + foreach (var s in t.SeedingResults) + { + foreach (var b in s.Beatmaps) + { + if (b.BeatmapInfo == null && b.ID > 0) + { + var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = b.ID }); + req.Perform(API); + b.BeatmapInfo = req.Result?.ToBeatmap(RulesetStore); + + addedInfo = true; + } + } + } + } return addedInfo; } + public void PopulateUser(User user, Action success = null, Action failure = null) + { + var req = new GetUserRequest(user.Id, Ruleset.Value); + + req.Success += res => + { + user.Username = res.Username; + user.Statistics = res.Statistics; + user.Country = res.Country; + user.Cover = res.Cover; + + success?.Invoke(); + }; + + req.Failure += _ => + { + user.Id = 1; + failure?.Invoke(); + }; + + API.Queue(req); + } + protected override void LoadComplete() { MenuCursorContainer.Cursor.AlwaysPresent = true; // required for tooltip display + + // we don't want to show the menu cursor as it would appear on stream output. MenuCursorContainer.Cursor.Alpha = 0; base.LoadComplete(); } - protected override void Update() - { - base.Update(); - var minWidth = (int)(windowSize.Value.Height / 9f * 16 + 400); - - if (windowSize.Value.Width < minWidth) - { - // todo: can be removed after ppy/osu-framework#1975 - windowSize.Value = Host.Window.ClientSize = new Size(minWidth, windowSize.Value.Height); - } - } - protected virtual void SaveChanges() { foreach (var r in ladder.Rounds) @@ -233,7 +259,7 @@ namespace osu.Game.Tournament private class TournamentInputManager : UserInputManager { - protected override MouseButtonEventManager CreateButtonManagerFor(MouseButton button) + protected override MouseButtonEventManager CreateButtonEventManagerFor(MouseButton button) { switch (button) { @@ -241,7 +267,7 @@ namespace osu.Game.Tournament return new RightMouseManager(button); } - return base.CreateButtonManagerFor(button); + return base.CreateButtonEventManagerFor(button); } private class RightMouseManager : MouseButtonEventManager diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs index 4c255be463..2c539cdd43 100644 --- a/osu.Game.Tournament/TournamentSceneManager.cs +++ b/osu.Game.Tournament/TournamentSceneManager.cs @@ -8,7 +8,8 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Platform; -using osu.Game.Graphics.UserInterface; +using osu.Framework.Threading; +using osu.Game.Graphics; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens; @@ -32,10 +33,17 @@ namespace osu.Game.Tournament private Container screens; private TourneyVideo video; + public const float CONTROL_AREA_WIDTH = 160; + + public const float STREAM_AREA_WIDTH = 1366; + + public const double REQUIRED_WIDTH = CONTROL_AREA_WIDTH * 2 + STREAM_AREA_WIDTH; + [Cached] private TournamentMatchChatDisplay chat = new TournamentMatchChatDisplay(); private Container chatContainer; + private FillFlowContainer buttons; public TournamentSceneManager() { @@ -49,17 +57,17 @@ namespace osu.Game.Tournament { new Container { - RelativeSizeAxes = Axes.Both, - X = 200, + RelativeSizeAxes = Axes.Y, + X = CONTROL_AREA_WIDTH, FillMode = FillMode.Fit, FillAspectRatio = 16 / 9f, Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, - Size = new Vector2(0.8f, 1), + Width = STREAM_AREA_WIDTH, //Masking = true, Children = new Drawable[] { - video = new TourneyVideo(storage.GetStream("BG Logoless - OWC.m4v")) + video = new TourneyVideo("main", true) { Loop = true, RelativeSizeAxes = Axes.Both, @@ -69,6 +77,7 @@ namespace osu.Game.Tournament RelativeSizeAxes = Axes.Both, Children = new Drawable[] { + new SetupScreen(), new ScheduleScreen(), new LadderScreen(), new LadderEditorScreen(), @@ -77,6 +86,7 @@ namespace osu.Game.Tournament new ShowcaseScreen(), new MapPoolScreen(), new TeamIntroScreen(), + new SeedingScreen(), new DrawingsScreen(), new GameplayScreen(), new TeamWinScreen() @@ -92,7 +102,7 @@ namespace osu.Game.Tournament new Container { RelativeSizeAxes = Axes.Y, - Width = 200, + Width = CONTROL_AREA_WIDTH, Children = new Drawable[] { new Box @@ -100,66 +110,154 @@ namespace osu.Game.Tournament Colour = Color4.Black, RelativeSizeAxes = Axes.Both, }, - new FillFlowContainer + buttons = new FillFlowContainer { RelativeSizeAxes = Axes.Both, Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(5), Children = new Drawable[] { - new OsuButton { RelativeSizeAxes = Axes.X, Text = "Team Editor", Action = () => SetScreen(typeof(TeamEditorScreen)) }, - new OsuButton { RelativeSizeAxes = Axes.X, Text = "Rounds Editor", Action = () => SetScreen(typeof(RoundEditorScreen)) }, - new OsuButton { RelativeSizeAxes = Axes.X, Text = "Bracket Editor", Action = () => SetScreen(typeof(LadderEditorScreen)) }, - new Container { RelativeSizeAxes = Axes.X, Height = 50 }, - new OsuButton { RelativeSizeAxes = Axes.X, Text = "Drawings", Action = () => SetScreen(typeof(DrawingsScreen)) }, - new OsuButton { RelativeSizeAxes = Axes.X, Text = "Showcase", Action = () => SetScreen(typeof(ShowcaseScreen)) }, - new Container { RelativeSizeAxes = Axes.X, Height = 50 }, - new OsuButton { RelativeSizeAxes = Axes.X, Text = "Schedule", Action = () => SetScreen(typeof(ScheduleScreen)) }, - new OsuButton { RelativeSizeAxes = Axes.X, Text = "Bracket", Action = () => SetScreen(typeof(LadderScreen)) }, - new Container { RelativeSizeAxes = Axes.X, Height = 50 }, - new OsuButton { RelativeSizeAxes = Axes.X, Text = "TeamIntro", Action = () => SetScreen(typeof(TeamIntroScreen)) }, - new OsuButton { RelativeSizeAxes = Axes.X, Text = "MapPool", Action = () => SetScreen(typeof(MapPoolScreen)) }, - new OsuButton { RelativeSizeAxes = Axes.X, Text = "Gameplay", Action = () => SetScreen(typeof(GameplayScreen)) }, - new Container { RelativeSizeAxes = Axes.X, Height = 50 }, - new OsuButton { RelativeSizeAxes = Axes.X, Text = "Win", Action = () => SetScreen(typeof(TeamWinScreen)) }, + new ScreenButton(typeof(SetupScreen)) { Text = "Setup", RequestSelection = SetScreen }, + new Separator(), + new ScreenButton(typeof(TeamEditorScreen)) { Text = "Team Editor", RequestSelection = SetScreen }, + new ScreenButton(typeof(RoundEditorScreen)) { Text = "Rounds Editor", RequestSelection = SetScreen }, + new ScreenButton(typeof(LadderEditorScreen)) { Text = "Bracket Editor", RequestSelection = SetScreen }, + new Separator(), + new ScreenButton(typeof(ScheduleScreen)) { Text = "Schedule", RequestSelection = SetScreen }, + new ScreenButton(typeof(LadderScreen)) { Text = "Bracket", RequestSelection = SetScreen }, + new Separator(), + new ScreenButton(typeof(TeamIntroScreen)) { Text = "TeamIntro", RequestSelection = SetScreen }, + new ScreenButton(typeof(SeedingScreen)) { Text = "Seeding", RequestSelection = SetScreen }, + new Separator(), + new ScreenButton(typeof(MapPoolScreen)) { Text = "MapPool", RequestSelection = SetScreen }, + new ScreenButton(typeof(GameplayScreen)) { Text = "Gameplay", RequestSelection = SetScreen }, + new Separator(), + new ScreenButton(typeof(TeamWinScreen)) { Text = "Win", RequestSelection = SetScreen }, + new Separator(), + new ScreenButton(typeof(DrawingsScreen)) { Text = "Drawings", RequestSelection = SetScreen }, + new ScreenButton(typeof(ShowcaseScreen)) { Text = "Showcase", RequestSelection = SetScreen }, } }, }, }, }; - SetScreen(typeof(ScheduleScreen)); + foreach (var drawable in screens) + drawable.Hide(); + + SetScreen(typeof(SetupScreen)); + } + + private float depth; + + private Drawable currentScreen; + private ScheduledDelegate scheduledHide; + + private Drawable temporaryScreen; + + public void SetScreen(Drawable screen) + { + currentScreen?.Hide(); + currentScreen = null; + + screens.Add(temporaryScreen = screen); } public void SetScreen(Type screenType) { - var screen = screens.FirstOrDefault(s => s.GetType() == screenType); - if (screen == null) return; + temporaryScreen?.Expire(); - foreach (var s in screens.Children) + var target = screens.FirstOrDefault(s => s.GetType() == screenType); + + if (target == null || currentScreen == target) return; + + if (scheduledHide?.Completed == false) { - if (s == screen) - { - s.Show(); - if (s is IProvideVideo) - video.FadeOut(200); - else - video.Show(); - } - else - s.Hide(); + scheduledHide.RunTask(); + scheduledHide.Cancel(); // see https://github.com/ppy/osu-framework/issues/2967 + scheduledHide = null; } - switch (screen) + var lastScreen = currentScreen; + currentScreen = target; + + if (currentScreen is IProvideVideo) + { + video.FadeOut(200); + + // delay the hide to avoid a double-fade transition. + scheduledHide = Scheduler.AddDelayed(() => lastScreen?.Hide(), TournamentScreen.FADE_DELAY); + } + else + { + lastScreen?.Hide(); + video.Show(); + } + + screens.ChangeChildDepth(currentScreen, depth--); + currentScreen.Show(); + + switch (currentScreen) { - case GameplayScreen _: case MapPoolScreen _: - chatContainer.FadeIn(100); + chatContainer.FadeIn(TournamentScreen.FADE_DELAY); + chatContainer.ResizeWidthTo(1, 500, Easing.OutQuint); + break; + + case GameplayScreen _: + chatContainer.FadeIn(TournamentScreen.FADE_DELAY); + chatContainer.ResizeWidthTo(0.5f, 500, Easing.OutQuint); break; default: - chatContainer.FadeOut(100); + chatContainer.FadeOut(TournamentScreen.FADE_DELAY); break; } + + foreach (var s in buttons.OfType()) + s.IsSelected = screenType == s.Type; + } + + private class Separator : CompositeDrawable + { + public Separator() + { + RelativeSizeAxes = Axes.X; + Height = 20; + } + } + + private class ScreenButton : TourneyButton + { + public readonly Type Type; + + public ScreenButton(Type type) + { + Type = type; + BackgroundColour = OsuColour.Gray(0.2f); + Action = () => RequestSelection(type); + + RelativeSizeAxes = Axes.X; + } + + private bool isSelected; + + public Action RequestSelection; + + public bool IsSelected + { + get => isSelected; + set + { + if (value == isSelected) + return; + + isSelected = value; + BackgroundColour = isSelected ? Color4.SkyBlue : OsuColour.Gray(0.2f); + SpriteText.Colour = isSelected ? Color4.Black : Color4.White; + } + } } } } diff --git a/osu.Game.Tournament/TournamentSpriteText.cs b/osu.Game.Tournament/TournamentSpriteText.cs new file mode 100644 index 0000000000..e550dfbfae --- /dev/null +++ b/osu.Game.Tournament/TournamentSpriteText.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 osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Tournament +{ + public class TournamentSpriteText : OsuSpriteText + { + public TournamentSpriteText() + { + Font = OsuFont.Torus; + } + } +} diff --git a/osu.Game.Tournament/TourneyButton.cs b/osu.Game.Tournament/TourneyButton.cs new file mode 100644 index 0000000000..12872d3197 --- /dev/null +++ b/osu.Game.Tournament/TourneyButton.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.Graphics.UserInterface; + +namespace osu.Game.Tournament +{ + public class TourneyButton : OsuButton + { + public TourneyButton() + : base(null) + { + } + } +} diff --git a/osu.Game.Tournament/WarningBox.cs b/osu.Game.Tournament/WarningBox.cs new file mode 100644 index 0000000000..814482aea4 --- /dev/null +++ b/osu.Game.Tournament/WarningBox.cs @@ -0,0 +1,40 @@ +// 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.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Tournament +{ + internal class WarningBox : Container + { + public WarningBox(string text) + { + Masking = true; + CornerRadius = 5; + Depth = float.MinValue; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + AutoSizeAxes = Axes.Both; + + Children = new Drawable[] + { + new Box + { + Colour = Color4.Red, + RelativeSizeAxes = Axes.Both, + }, + new TournamentSpriteText + { + Text = text, + Font = OsuFont.Torus.With(weight: FontWeight.Bold), + Colour = Color4.White, + Padding = new MarginPadding(20) + } + }; + } + } +} diff --git a/osu.Game.Tournament/osu.Game.Tournament.csproj b/osu.Game.Tournament/osu.Game.Tournament.csproj index 4790fcbcde..9cce40c9d3 100644 --- a/osu.Game.Tournament/osu.Game.Tournament.csproj +++ b/osu.Game.Tournament/osu.Game.Tournament.csproj @@ -1,9 +1,7 @@  - - netstandard2.0 + netstandard2.1 Library - AnyCPU true tools for tournaments. @@ -11,6 +9,6 @@ - + \ No newline at end of file diff --git a/osu.Game.props b/osu.Game.props deleted file mode 100644 index 1a3c0aec3e..0000000000 --- a/osu.Game.props +++ /dev/null @@ -1,24 +0,0 @@ - - - - 7.2 - - - ..\app.manifest - - - - osu.licenseheader - - - - - - - ppy Pty Ltd - Copyright (c) 2019 ppy Pty Ltd - - NU1701 - - \ No newline at end of file diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index 23a74d3fa6..8efaeb3795 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.cs @@ -18,9 +18,9 @@ namespace osu.Game.Audio public const string HIT_CLAP = @"hitclap"; /// - /// An optional ruleset namespace. + /// All valid sample addition constants. /// - public string Namespace; + public static IEnumerable AllAdditions => new[] { HIT_WHISTLE, HIT_CLAP, HIT_FINISH }; /// /// The bank to load the sample from. @@ -49,19 +49,10 @@ namespace osu.Game.Audio { get { - if (!string.IsNullOrEmpty(Namespace)) - { - if (!string.IsNullOrEmpty(Suffix)) - yield return $"{Namespace}/{Bank}-{Name}{Suffix}"; - - yield return $"{Namespace}/{Bank}-{Name}"; - } - - // check non-namespace as a fallback even when we have a namespace if (!string.IsNullOrEmpty(Suffix)) - yield return $"{Bank}-{Name}{Suffix}"; + yield return $"Gameplay/{Bank}-{Name}{Suffix}"; - yield return $"{Bank}-{Name}"; + yield return $"Gameplay/{Bank}-{Name}"; } } diff --git a/osu.Game/Audio/PreviewTrack.cs b/osu.Game/Audio/PreviewTrack.cs index 22ce7d4711..5df656e1e0 100644 --- a/osu.Game/Audio/PreviewTrack.cs +++ b/osu.Game/Audio/PreviewTrack.cs @@ -9,48 +9,52 @@ using osu.Framework.Threading; namespace osu.Game.Audio { + [LongRunningLoad] public abstract class PreviewTrack : Component { /// /// Invoked when this has stopped playing. + /// Not invoked in a thread-safe context. /// public event Action Stopped; /// /// Invoked when this has started playing. + /// Not invoked in a thread-safe context. /// public event Action Started; - private Track track; + protected Track Track { get; private set; } + private bool hasStarted; [BackgroundDependencyLoader] private void load() { - track = GetTrack(); - if (track != null) - track.Completed += () => Schedule(Stop); + Track = GetTrack(); + if (Track != null) + Track.Completed += Stop; } /// /// Length of the track. /// - public double Length => track?.Length ?? 0; + public double Length => Track?.Length ?? 0; /// /// The current track time. /// - public double CurrentTime => track?.CurrentTime ?? 0; + public double CurrentTime => Track?.CurrentTime ?? 0; /// /// Whether the track is loaded. /// - public bool TrackLoaded => track?.IsLoaded ?? false; + public bool TrackLoaded => Track?.IsLoaded ?? false; /// /// Whether the track is playing. /// - public bool IsRunning => track?.IsRunning ?? false; + public bool IsRunning => Track?.IsRunning ?? false; private ScheduledDelegate startDelegate; @@ -60,7 +64,7 @@ namespace osu.Game.Audio /// Whether the track is started or already playing. public bool Start() { - if (track == null) + if (Track == null) return false; startDelegate = Schedule(() => @@ -70,7 +74,7 @@ namespace osu.Game.Audio hasStarted = true; - track.Restart(); + Track.Restart(); Started?.Invoke(); }); @@ -84,7 +88,7 @@ namespace osu.Game.Audio { startDelegate?.Cancel(); - if (track == null) + if (Track == null) return; if (!hasStarted) @@ -92,7 +96,8 @@ namespace osu.Game.Audio hasStarted = false; - track.Stop(); + Track.Stop(); + Stopped?.Invoke(); } diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index e12c46ef16..862be41c1a 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -19,13 +19,15 @@ namespace osu.Game.Audio { private readonly BindableDouble muteBindable = new BindableDouble(); - private AudioManager audio; + [Resolved] + private AudioManager audio { get; set; } + private PreviewTrackStore trackStore; - private TrackManagerPreviewTrack current; + protected TrackManagerPreviewTrack CurrentTrack; [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load() { // this is a temporary solution to get around muting ourselves. // todo: update this once we have a BackgroundTrackManager or similar. @@ -33,8 +35,6 @@ namespace osu.Game.Audio audio.AddItem(trackStore); trackStore.AddAdjustment(AdjustableProperty.Volume, audio.VolumeTrack); - - this.audio = audio; } /// @@ -46,18 +46,21 @@ namespace osu.Game.Audio { var track = CreatePreviewTrack(beatmapSetInfo, trackStore); - track.Started += () => + track.Started += () => Schedule(() => { - current?.Stop(); - current = track; + CurrentTrack?.Stop(); + CurrentTrack = track; audio.Tracks.AddAdjustment(AdjustableProperty.Volume, muteBindable); - }; + }); - track.Stopped += () => + track.Stopped += () => Schedule(() => { - current = null; + if (CurrentTrack != track) + return; + + CurrentTrack = null; audio.Tracks.RemoveAdjustment(AdjustableProperty.Volume, muteBindable); - }; + }); return track; } @@ -73,11 +76,11 @@ namespace osu.Game.Audio /// The which may be the owner of the . public void StopAnyPlaying(IPreviewTrackOwner source) { - if (current == null || current.Owner != source) + if (CurrentTrack == null || CurrentTrack.Owner != source) return; - current.Stop(); - current = null; + CurrentTrack.Stop(); + // CurrentTrack should not be set to null here as it will result in incorrect handling in the track.Stopped callback above. } /// @@ -85,8 +88,9 @@ namespace osu.Game.Audio /// protected virtual TrackManagerPreviewTrack CreatePreviewTrack(BeatmapSetInfo beatmapSetInfo, ITrackStore trackStore) => new TrackManagerPreviewTrack(beatmapSetInfo, trackStore); - protected class TrackManagerPreviewTrack : PreviewTrack + public class TrackManagerPreviewTrack : PreviewTrack { + [Resolved] public IPreviewTrackOwner Owner { get; private set; } private readonly BeatmapSetInfo beatmapSetInfo; @@ -98,12 +102,6 @@ namespace osu.Game.Audio this.trackManager = trackManager; } - [BackgroundDependencyLoader] - private void load(IPreviewTrackOwner owner) - { - Owner = owner; - } - protected override Track GetTrack() => trackManager.Get($"https://b.ppy.sh/preview/{beatmapSetInfo?.OnlineBeatmapSetID}.mp3"); } diff --git a/osu.Game/Audio/SampleInfo.cs b/osu.Game/Audio/SampleInfo.cs index 66c07209f3..240d70c418 100644 --- a/osu.Game/Audio/SampleInfo.cs +++ b/osu.Game/Audio/SampleInfo.cs @@ -10,15 +10,15 @@ namespace osu.Game.Audio /// public class SampleInfo : ISampleInfo { - private readonly string sampleName; + private readonly string[] sampleNames; - public SampleInfo(string sampleName) + public SampleInfo(params string[] sampleNames) { - this.sampleName = sampleName; + this.sampleNames = sampleNames; } - public IEnumerable LookupNames => new[] { sampleName }; + public IEnumerable LookupNames => sampleNames; - public int Volume { get; set; } = 100; + public int Volume { get; } = 100; } } diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs index 4ebeee40bf..5435e86dfd 100644 --- a/osu.Game/Beatmaps/Beatmap.cs +++ b/osu.Game/Beatmaps/Beatmap.cs @@ -14,7 +14,7 @@ namespace osu.Game.Beatmaps /// /// A Beatmap containing converted HitObjects. /// - public class Beatmap : IBeatmap + public class Beatmap : IBeatmap where T : HitObject { public BeatmapInfo BeatmapInfo { get; set; } = new BeatmapInfo @@ -36,17 +36,13 @@ namespace osu.Game.Beatmaps public List Breaks { get; set; } = new List(); - /// - /// Total amount of break time in the beatmap. - /// [JsonIgnore] public double TotalBreakTime => Breaks.Sum(b => b.Duration); - /// - /// The HitObjects this Beatmap contains. - /// [JsonConverter(typeof(TypedListConverter))] - public List HitObjects = new List(); + public List HitObjects { get; set; } = new List(); + + IReadOnlyList IBeatmap.HitObjects => HitObjects; IReadOnlyList IBeatmap.HitObjects => HitObjects; @@ -60,5 +56,7 @@ namespace osu.Game.Beatmaps public class Beatmap : Beatmap { public new Beatmap Clone() => (Beatmap)base.Clone(); + + public override string ToString() => BeatmapInfo?.ToString() ?? base.ToString(); } } diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index 7922843626..cb0b3a8d09 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; namespace osu.Game.Beatmaps @@ -25,7 +27,9 @@ namespace osu.Game.Beatmaps public IBeatmap Beatmap { get; } - protected BeatmapConverter(IBeatmap beatmap) + private CancellationToken cancellationToken; + + protected BeatmapConverter(IBeatmap beatmap, Ruleset ruleset) { Beatmap = beatmap; } @@ -33,16 +37,27 @@ namespace osu.Game.Beatmaps /// /// Whether can be converted by this . /// - public bool CanConvert => !Beatmap.HitObjects.Any() || ValidConversionTypes.All(t => Beatmap.HitObjects.Any(t.IsInstanceOfType)); + public abstract bool CanConvert(); + + 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); + } /// - /// Converts . + /// Performs the conversion of a Beatmap using this Beatmap Converter. /// + /// The un-converted Beatmap. + /// The cancellation token. /// The converted Beatmap. - public IBeatmap Convert() + protected virtual Beatmap ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken) { - // We always operate on a clone of the original beatmap, to not modify it game-wide - return ConvertBeatmap(Beatmap.Clone()); +#pragma warning disable 618 + return ConvertBeatmap(original); +#pragma warning restore 618 } /// @@ -50,19 +65,20 @@ namespace osu.Game.Beatmaps /// /// 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; beatmap.ControlPointInfo = original.ControlPointInfo; - beatmap.HitObjects = convertHitObjects(original.HitObjects, original); + beatmap.HitObjects = convertHitObjects(original.HitObjects, original, cancellationToken).OrderBy(s => s.StartTime).ToList(); beatmap.Breaks = original.Breaks; return beatmap; } - private List convertHitObjects(IReadOnlyList hitObjects, IBeatmap beatmap) + private List convertHitObjects(IReadOnlyList hitObjects, IBeatmap beatmap, CancellationToken cancellationToken) { var result = new List(hitObjects.Count); @@ -74,7 +90,7 @@ namespace osu.Game.Beatmaps continue; } - var converted = ConvertHitObject(obj, beatmap); + var converted = ConvertHitObject(obj, beatmap, cancellationToken); if (ObjectConverted != null) { @@ -92,11 +108,6 @@ namespace osu.Game.Beatmaps return result; } - /// - /// The types of HitObjects that can be converted to be used for this Beatmap. - /// - protected abstract IEnumerable ValidConversionTypes { get; } - /// /// Creates the that will be returned by this . /// @@ -108,7 +119,23 @@ namespace osu.Game.Beatmaps /// /// The hit object to convert. /// The un-converted Beatmap. + /// The cancellation token. /// The converted hit object. - protected abstract IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap); + 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(); } } diff --git a/osu.Game/Beatmaps/BeatmapDifficulty.cs b/osu.Game/Beatmaps/BeatmapDifficulty.cs index 8727431e0e..c56fec67aa 100644 --- a/osu.Game/Beatmaps/BeatmapDifficulty.cs +++ b/osu.Game/Beatmaps/BeatmapDifficulty.cs @@ -56,10 +56,22 @@ namespace osu.Game.Beatmaps /// Maps a difficulty value [0, 10] to a two-piece linear range of values. ///
/// The difficulty value to be mapped. - /// The values that define the two linear ranges. - /// Minimum of the resulting range which will be achieved by a difficulty value of 0. - /// Midpoint of the resulting range which will be achieved by a difficulty value of 5. - /// Maximum of the resulting range which will be achieved by a difficulty value of 10. + /// The values that define the two linear ranges. + /// + /// + /// od0 + /// Minimum of the resulting range which will be achieved by a difficulty value of 0. + /// + /// + /// od5 + /// Midpoint of the resulting range which will be achieved by a difficulty value of 5. + /// + /// + /// od10 + /// Maximum of the resulting range which will be achieved by a difficulty value of 10. + /// + /// + /// /// Value to which the difficulty value maps in the specified range. public static double DifficultyRange(double difficulty, (double od0, double od5, double od10) range) => DifficultyRange(difficulty, range.od0, range.od5, range.od10); diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs new file mode 100644 index 0000000000..3b58062add --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -0,0 +1,335 @@ +// 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; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Lists; +using osu.Framework.Logging; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Database; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Beatmaps +{ + /// + /// A component which performs and acts as a central cache for difficulty calculations of beatmap/ruleset/mod combinations. + /// Currently not persisted between game sessions. + /// + public class BeatmapDifficultyCache : MemoryCachingComponent + { + // Too many simultaneous updates can lead to stutters. One thread seems to work fine for song select display purposes. + private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(1, nameof(BeatmapDifficultyCache)); + + /// + /// All bindables that should be updated along with the current ruleset + mods. + /// + private readonly WeakList trackedBindables = new WeakList(); + + /// + /// Cancellation sources used by tracked bindables. + /// + private readonly List linkedCancellationSources = new List(); + + /// + /// Lock to be held when operating on or . + /// + private readonly object bindableUpdateLock = new object(); + + private CancellationTokenSource trackedUpdateCancellationSource; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + [Resolved] + private Bindable currentRuleset { get; set; } + + [Resolved] + private Bindable> currentMods { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentRuleset.BindValueChanged(_ => updateTrackedBindables()); + currentMods.BindValueChanged(_ => updateTrackedBindables(), true); + } + + /// + /// Retrieves a bindable containing the star difficulty of a that follows the currently-selected ruleset and mods. + /// + /// 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) + { + var bindable = createBindable(beatmapInfo, currentRuleset.Value, currentMods.Value, cancellationToken); + + lock (bindableUpdateLock) + trackedBindables.Add(bindable); + + return bindable; + } + + /// + /// Retrieves a bindable containing the star difficulty of a with a given and combination. + /// + /// + /// The bindable will not update to follow the currently-selected ruleset and mods. + /// + /// The to get the difficulty of. + /// 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) + => createBindable(beatmapInfo, rulesetInfo, mods, cancellationToken); + + /// + /// Retrieves the difficulty of a . + /// + /// The to get the difficulty of. + /// The to get the difficulty with. + /// 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) + { + // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. + rulesetInfo ??= beatmapInfo.Ruleset; + + // Difficulty can only be computed if the beatmap and ruleset are locally available. + if (beatmapInfo.ID == 0 || rulesetInfo.ID == null) + { + // If not, fall back to the existing star difficulty (e.g. from an online source). + return Task.FromResult(new StarDifficulty(beatmapInfo.StarDifficulty, beatmapInfo.MaxCombo ?? 0)); + } + + return GetAsync(new DifficultyCacheLookup(beatmapInfo, rulesetInfo, mods), cancellationToken); + } + + protected override Task ComputeValueAsync(DifficultyCacheLookup lookup, CancellationToken token = default) + { + return Task.Factory.StartNew(() => + { + if (CheckExists(lookup, out var existing)) + return existing; + + return computeDifficulty(lookup); + }, token, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + } + + /// + /// Retrieves the that describes a star rating. + /// + /// + /// For more information, see: https://osu.ppy.sh/help/wiki/Difficulties + /// + /// The star rating. + /// The that best describes . + public static DifficultyRating GetDifficultyRating(double starRating) + { + if (Precision.AlmostBigger(starRating, 6.5, 0.005)) + return DifficultyRating.ExpertPlus; + + if (Precision.AlmostBigger(starRating, 5.3, 0.005)) + return DifficultyRating.Expert; + + if (Precision.AlmostBigger(starRating, 4.0, 0.005)) + return DifficultyRating.Insane; + + if (Precision.AlmostBigger(starRating, 2.7, 0.005)) + return DifficultyRating.Hard; + + if (Precision.AlmostBigger(starRating, 2.0, 0.005)) + return DifficultyRating.Normal; + + return DifficultyRating.Easy; + } + + /// + /// Updates all tracked using the current ruleset and mods. + /// + private void updateTrackedBindables() + { + lock (bindableUpdateLock) + { + cancelTrackedBindableUpdate(); + trackedUpdateCancellationSource = new CancellationTokenSource(); + + foreach (var b in trackedBindables) + { + var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(trackedUpdateCancellationSource.Token, b.CancellationToken); + linkedCancellationSources.Add(linkedSource); + + updateBindable(b, currentRuleset.Value, currentMods.Value, linkedSource.Token); + } + } + } + + /// + /// Cancels the existing update of all tracked via . + /// + private void cancelTrackedBindableUpdate() + { + lock (bindableUpdateLock) + { + trackedUpdateCancellationSource?.Cancel(); + trackedUpdateCancellationSource = null; + + if (linkedCancellationSources != null) + { + foreach (var c in linkedCancellationSources) + c.Dispose(); + + linkedCancellationSources.Clear(); + } + } + } + + /// + /// Creates a new and triggers an initial value update. + /// + /// The that star difficulty should correspond to. + /// The initial to get the difficulty with. + /// The initial s to get the difficulty with. + /// An optional which stops updating the star difficulty for the given . + /// The . + private BindableStarDifficulty createBindable([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo initialRulesetInfo, [CanBeNull] IEnumerable initialMods, + CancellationToken cancellationToken) + { + var bindable = new BindableStarDifficulty(beatmapInfo, cancellationToken); + updateBindable(bindable, initialRulesetInfo, initialMods, cancellationToken); + return bindable; + } + + /// + /// Updates the value of a with a given ruleset + mods. + /// + /// The to update. + /// The to update with. + /// The s to update with. + /// A token that may be used to cancel this update. + private void updateBindable([NotNull] BindableStarDifficulty bindable, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, CancellationToken cancellationToken = default) + { + // GetDifficultyAsync will fall back to existing data from BeatmapInfo if not locally available + // (contrary to GetAsync) + GetDifficultyAsync(bindable.Beatmap, rulesetInfo, mods, cancellationToken) + .ContinueWith(t => + { + // We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events. + Schedule(() => + { + if (!cancellationToken.IsCancellationRequested) + bindable.Value = t.Result; + }); + }, cancellationToken); + } + + /// + /// Computes the difficulty defined by a key, and stores it to the timed cache. + /// + /// The that defines the computation parameters. + /// The . + private StarDifficulty computeDifficulty(in DifficultyCacheLookup key) + { + // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. + var beatmapInfo = key.Beatmap; + var rulesetInfo = key.Ruleset; + + try + { + var ruleset = rulesetInfo.CreateInstance(); + Debug.Assert(ruleset != null); + + var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(key.Beatmap)); + var attributes = calculator.Calculate(key.OrderedMods); + + return new StarDifficulty(attributes); + } + catch (BeatmapInvalidForRulesetException e) + { + // Conversion has failed for the given ruleset, so return the difficulty in the beatmap's default ruleset. + + // Ensure the beatmap's default ruleset isn't the one already being converted to. + // This shouldn't happen as it means something went seriously wrong, but if it does an endless loop should be avoided. + if (rulesetInfo.Equals(beatmapInfo.Ruleset)) + { + Logger.Error(e, $"Failed to convert {beatmapInfo.OnlineBeatmapID} to the beatmap's default ruleset ({beatmapInfo.Ruleset})."); + return new StarDifficulty(); + } + + return GetAsync(new DifficultyCacheLookup(key.Beatmap, key.Beatmap.Ruleset, key.OrderedMods)).Result; + } + catch + { + return new StarDifficulty(); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + cancelTrackedBindableUpdate(); + updateScheduler?.Dispose(); + } + + public readonly struct DifficultyCacheLookup : IEquatable + { + public readonly BeatmapInfo Beatmap; + public readonly RulesetInfo Ruleset; + + public readonly Mod[] OrderedMods; + + public DifficultyCacheLookup([NotNull] BeatmapInfo beatmap, [CanBeNull] RulesetInfo ruleset, IEnumerable mods) + { + Beatmap = beatmap; + // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. + Ruleset = ruleset ?? Beatmap.Ruleset; + OrderedMods = mods?.OrderBy(m => m.Acronym).ToArray() ?? Array.Empty(); + } + + public bool Equals(DifficultyCacheLookup other) + => Beatmap.ID == other.Beatmap.ID + && Ruleset.ID == other.Ruleset.ID + && OrderedMods.Select(m => m.Acronym).SequenceEqual(other.OrderedMods.Select(m => m.Acronym)); + + public override int GetHashCode() + { + var hashCode = new HashCode(); + + hashCode.Add(Beatmap.ID); + hashCode.Add(Ruleset.ID); + + foreach (var mod in OrderedMods) + hashCode.Add(mod.Acronym); + + return hashCode.ToHashCode(); + } + } + + private class BindableStarDifficulty : Bindable + { + public readonly BeatmapInfo Beatmap; + public readonly CancellationToken CancellationToken; + + public BindableStarDifficulty(BeatmapInfo beatmap, CancellationToken cancellationToken) + { + Beatmap = beatmap; + CancellationToken = cancellationToken; + } + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 8042f6b4b9..a898e10e4f 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.Testing; using osu.Game.Database; using osu.Game.IO.Serialization; using osu.Game.Rulesets; @@ -14,6 +15,7 @@ using osu.Game.Scoring; namespace osu.Game.Beatmaps { + [ExcludeFromDynamicCompile] [Serializable] public class BeatmapInfo : IEquatable, IJsonSerializable, IHasPrimaryKey { @@ -51,6 +53,9 @@ namespace osu.Game.Beatmaps [NotMapped] public BeatmapOnlineInfo OnlineInfo { get; set; } + [NotMapped] + public int? MaxCombo { get; set; } + /// /// The playable length in milliseconds of this beatmap. /// @@ -76,7 +81,7 @@ namespace osu.Game.Beatmaps public string MD5Hash { get; set; } // General - public int AudioLeadIn { get; set; } + public double AudioLeadIn { get; set; } public bool Countdown { get; set; } = true; public float StackLeniency { get; set; } = 0.7f; public bool SpecialStyle { get; set; } @@ -87,18 +92,19 @@ namespace osu.Game.Beatmaps public bool LetterboxInBreaks { get; set; } public bool WidescreenStoryboard { get; set; } + public bool EpilepsyWarning { get; set; } // Editor // This bookmarks stuff is necessary because DB doesn't know how to store int[] [JsonIgnore] public string StoredBookmarks { - get => string.Join(",", Bookmarks); + get => string.Join(',', Bookmarks); set { if (string.IsNullOrEmpty(value)) { - Bookmarks = new int[0]; + Bookmarks = Array.Empty(); return; } @@ -111,7 +117,7 @@ namespace osu.Game.Beatmaps } [NotMapped] - public int[] Bookmarks { get; set; } = new int[0]; + public int[] Bookmarks { get; set; } = Array.Empty(); public double DistanceSpacing { get; set; } public int BeatDivisor { get; set; } @@ -129,7 +135,20 @@ namespace osu.Game.Beatmaps ///
public List Scores { get; set; } - public override string ToString() => $"{Metadata} [{Version}]".Trim(); + [JsonIgnore] + public DifficultyRating DifficultyRating => BeatmapDifficultyCache.GetDifficultyRating(StarDifficulty); + + public string[] SearchableTerms => new[] + { + Version + }.Concat(Metadata?.SearchableTerms ?? Enumerable.Empty()).Where(s => !string.IsNullOrEmpty(s)).ToArray(); + + public override string ToString() + { + string version = string.IsNullOrEmpty(Version) ? string.Empty : $"[{Version}]"; + + return $"{Metadata} {version}".Trim(); + } public bool Equals(BeatmapInfo other) { diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 166ba5111c..33e024fa28 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -6,78 +6,94 @@ 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 JetBrains.Annotations; using Microsoft.EntityFrameworkCore; using osu.Framework.Audio; using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics.Textures; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Platform; -using osu.Framework.Threading; +using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; using osu.Game.Database; +using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; -using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Objects; +using osu.Game.Users; +using osu.Game.Skinning; +using Decoder = osu.Game.Beatmaps.Formats.Decoder; namespace osu.Game.Beatmaps { /// /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps. /// - public partial class BeatmapManager : DownloadableArchiveModelManager + [ExcludeFromDynamicCompile] + public partial class BeatmapManager : DownloadableArchiveModelManager, IDisposable { /// /// Fired when a single difficulty has been hidden. /// - public event Action BeatmapHidden; + public IBindable> BeatmapHidden => beatmapHidden; + + private readonly Bindable> beatmapHidden = new Bindable>(); /// /// Fired when a single difficulty has been restored. /// - public event Action BeatmapRestored; + public IBindable> BeatmapRestored => beatmapRestored; + + private readonly Bindable> beatmapRestored = new Bindable>(); /// /// A default representation of a WorkingBeatmap to use when no beatmap is available. /// public readonly WorkingBeatmap DefaultBeatmap; - public override string[] HandledExtensions => new[] { ".osz" }; + public override IEnumerable HandledExtensions => new[] { ".osz" }; protected override string[] HashableFileTypes => new[] { ".osu" }; protected override string ImportFromStablePath => "Songs"; private readonly RulesetStore rulesets; - private readonly BeatmapStore beatmaps; - private readonly AudioManager audioManager; + private readonly TextureStore textureStore; + private readonly ITrackStore trackStore; - private readonly GameHost host; + [CanBeNull] + private readonly BeatmapOnlineLookupQueue onlineLookupQueue; - private readonly BeatmapUpdateQueue updateQueue; - - public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, AudioManager audioManager, GameHost host = null, - WorkingBeatmap defaultBeatmap = null) + public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, GameHost host = null, + WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false) : base(storage, contextFactory, api, new BeatmapStore(contextFactory), host) { this.rulesets = rulesets; this.audioManager = audioManager; - this.host = host; DefaultBeatmap = defaultBeatmap; beatmaps = (BeatmapStore)ModelStore; - beatmaps.BeatmapHidden += b => BeatmapHidden?.Invoke(b); - beatmaps.BeatmapRestored += b => BeatmapRestored?.Invoke(b); + beatmaps.BeatmapHidden += b => beatmapHidden.Value = new WeakReference(b); + beatmaps.BeatmapRestored += b => beatmapRestored.Value = new WeakReference(b); + beatmaps.ItemRemoved += removeWorkingCache; + beatmaps.ItemUpdated += removeWorkingCache; - updateQueue = new BeatmapUpdateQueue(api); + if (performOnlineLookups) + onlineLookupQueue = new BeatmapOnlineLookupQueue(api, storage); + + textureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(Files.Store)); + trackStore = audioManager.GetTrackStore(Files.Store); } protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) => @@ -85,10 +101,38 @@ namespace osu.Game.Beatmaps protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz"; - protected override Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default) + public WorkingBeatmap CreateNew(RulesetInfo ruleset, User user) + { + var metadata = new BeatmapMetadata + { + Artist = "artist", + Title = "title", + Author = user, + }; + + var set = new BeatmapSetInfo + { + Metadata = metadata, + Beatmaps = new List + { + new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty(), + Ruleset = ruleset, + Metadata = metadata, + Version = "difficulty" + } + } + }; + + var working = Import(set).Result; + return GetWorkingBeatmap(working.Beatmaps.First()); + } + + protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default) { if (archive != null) - beatmapSet.Beatmaps = createBeatmapDifficulties(archive); + beatmapSet.Beatmaps = createBeatmapDifficulties(beatmapSet.Files); foreach (BeatmapInfo b in beatmapSet.Beatmaps) { @@ -101,7 +145,20 @@ namespace osu.Game.Beatmaps validateOnlineIds(beatmapSet); - return updateQueue.UpdateAsync(beatmapSet, cancellationToken); + bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0); + + if (onlineLookupQueue != null) + await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken); + + // 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)) + { + if (beatmapSet.OnlineBeatmapSetID != null) + { + beatmapSet.OnlineBeatmapSetID = null; + LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs"); + } + } } protected override void PreImport(BeatmapSetInfo beatmapSet) @@ -127,9 +184,12 @@ namespace osu.Game.Beatmaps { var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineBeatmapID.HasValue).Select(b => b.OnlineBeatmapID).ToList(); + LogForModel(beatmapSet, $"Validating online IDs for {beatmapSet.Beatmaps.Count} beatmaps..."); + // ensure all IDs are unique if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1)) { + LogForModel(beatmapSet, "Found non-unique IDs, resetting..."); resetIds(); return; } @@ -142,14 +202,20 @@ namespace osu.Game.Beatmaps // reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set. // we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted. var existing = CheckForExisting(beatmapSet); + if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b))) + { + LogForModel(beatmapSet, "Found existing import with IDs already, resetting..."); resetIds(); + } } void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineBeatmapID = null); } - protected override bool CheckLocalAvailability(BeatmapSetInfo model, IQueryable items) => items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID); + protected override bool CheckLocalAvailability(BeatmapSetInfo model, IQueryable items) + => base.CheckLocalAvailability(model, items) + || (model.OnlineBeatmapSetID != null && items.Any(b => b.OnlineBeatmapSetID == model.OnlineBeatmapSetID)); /// /// Delete a beatmap difficulty. @@ -163,7 +229,47 @@ namespace osu.Game.Beatmaps /// The beatmap difficulty to restore. public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap); - private readonly WeakList workingCache = new WeakList(); + /// + /// Saves an file against a given . + /// + /// 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) + { + var setInfo = info.BeatmapSet; + + using (var stream = new MemoryStream()) + { + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(beatmapContent, beatmapSkin).Encode(sw); + + stream.Seek(0, SeekOrigin.Begin); + + using (ContextFactory.GetForWrite()) + { + var beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == info.ID); + var metadata = beatmapInfo.Metadata ?? setInfo.Metadata; + + // grab the original file (or create a new one if not found). + var fileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)) ?? new BeatmapSetFileInfo(); + + // metadata may have changed; update the path with the standard format. + beatmapInfo.Path = $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.Version}].osu"; + beatmapInfo.MD5Hash = stream.ComputeMD5Hash(); + + // update existing or populate new file's filename. + fileInfo.Filename = beatmapInfo.Path; + + stream.Seek(0, SeekOrigin.Begin); + ReplaceFile(setInfo, fileInfo, stream); + } + } + + removeWorkingCache(info); + } + + private readonly WeakList workingCache = new WeakList(); /// /// Retrieve a instance for the provided @@ -179,20 +285,25 @@ namespace osu.Game.Beatmaps if (beatmapInfo?.BeatmapSet == null || beatmapInfo == DefaultBeatmap?.BeatmapInfo) return DefaultBeatmap; + if (beatmapInfo.BeatmapSet.Files == null) + { + var info = beatmapInfo; + beatmapInfo = QueryBeatmap(b => b.ID == info.ID); + } + + if (beatmapInfo == null) + return DefaultBeatmap; + lock (workingCache) { var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID); + if (working != null) + return working; - if (working == null) - { - if (beatmapInfo.Metadata == null) - beatmapInfo.Metadata = beatmapInfo.BeatmapSet.Metadata; + beatmapInfo.Metadata ??= beatmapInfo.BeatmapSet.Metadata; - workingCache.Add(working = new BeatmapManagerWorkingBeatmap(Files.Store, - new LargeTextureStore(host?.CreateTextureLoaderStore(Files.Store)), beatmapInfo, audioManager)); - } + workingCache.Add(working = new BeatmapManagerWorkingBeatmap(Files.Store, textureStore, trackStore, beatmapInfo, audioManager)); - previous?.TransferTo(working); return working; } } @@ -204,9 +315,9 @@ namespace osu.Game.Beatmaps /// The first result for the provided query, or null if no results were found. public BeatmapSetInfo QueryBeatmapSet(Expression> query) => beatmaps.ConsumableItems.AsNoTracking().FirstOrDefault(query); - protected override bool CanUndelete(BeatmapSetInfo existing, BeatmapSetInfo import) + protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import) { - if (!base.CanUndelete(existing, import)) + if (!base.CanReuseExisting(existing, import)) return false; var existingIds = existing.Beatmaps.Select(b => b.OnlineBeatmapID).OrderBy(i => i); @@ -220,13 +331,39 @@ namespace osu.Game.Beatmaps /// Returns a list of all usable s. /// /// A list of available . - public List GetAllUsableBeatmapSets() => GetAllUsableBeatmapSetsEnumerable().ToList(); + public List GetAllUsableBeatmapSets(IncludedDetails includes = IncludedDetails.All, bool includeProtected = false) => + GetAllUsableBeatmapSetsEnumerable(includes, includeProtected).ToList(); /// - /// Returns a list of all usable s. + /// Returns a list of all usable s. Note that files are not populated. /// + /// The level of detail to include in the returned objects. + /// Whether to include protected (system) beatmaps. These should not be included for gameplay playable use cases. /// A list of available . - public IQueryable GetAllUsableBeatmapSetsEnumerable() => beatmaps.ConsumableItems.Where(s => !s.DeletePending && !s.Protected); + public IEnumerable GetAllUsableBeatmapSetsEnumerable(IncludedDetails includes, bool includeProtected = false) + { + IQueryable queryable; + + switch (includes) + { + case IncludedDetails.Minimal: + queryable = beatmaps.BeatmapSetsOverview; + break; + + case IncludedDetails.AllButFiles: + queryable = beatmaps.BeatmapSetsWithoutFiles; + break; + + default: + queryable = beatmaps.ConsumableItems; + break; + } + + // AsEnumerable used here to avoid applying the WHERE in sql. When done so, ef core 2.x uses an incorrect ORDER BY + // clause which causes queries to take 5-10x longer. + // TODO: remove if upgrading to EF core 3.x. + return queryable.AsEnumerable().Where(s => !s.DeletePending && (includeProtected || !s.Protected)); + } /// /// Perform a lookup query on available s. @@ -254,7 +391,7 @@ namespace osu.Game.Beatmaps protected override BeatmapSetInfo CreateModel(ArchiveReader reader) { // let's make sure there are actually .osu files to import. - string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu")); + string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)); if (string.IsNullOrEmpty(mapName)) { @@ -263,7 +400,7 @@ namespace osu.Game.Beatmaps } Beatmap beatmap; - using (var stream = new StreamReader(reader.GetStream(mapName))) + using (var stream = new LineBufferedReader(reader.GetStream(mapName))) beatmap = Decoder.GetDecoder(stream).Decode(stream); return new BeatmapSetInfo @@ -278,15 +415,15 @@ namespace osu.Game.Beatmaps /// /// Create all required s for the provided archive. /// - private List createBeatmapDifficulties(ArchiveReader reader) + private List createBeatmapDifficulties(List files) { var beatmapInfos = new List(); - foreach (var name in reader.Filenames.Where(f => f.EndsWith(".osu"))) + foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase))) { - using (var raw = reader.GetStream(name)) - using (var ms = new MemoryStream()) //we need a memory stream so we can seek - using (var sr = new StreamReader(ms)) + using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath)) + using (var ms = new MemoryStream()) // we need a memory stream so we can seek + using (var sr = new LineBufferedReader(ms)) { raw.CopyTo(ms); ms.Position = 0; @@ -294,12 +431,18 @@ namespace osu.Game.Beatmaps var decoder = Decoder.GetDecoder(sr); IBeatmap beatmap = decoder.Decode(sr); - beatmap.BeatmapInfo.Path = name; - beatmap.BeatmapInfo.Hash = ms.ComputeSHA2Hash(); + string hash = ms.ComputeSHA2Hash(); + + if (beatmapInfos.Any(b => b.Hash == hash)) + continue; + + beatmap.BeatmapInfo.Path = file.Filename; + beatmap.BeatmapInfo.Hash = hash; beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash(); var ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID); beatmap.BeatmapInfo.Ruleset = ruleset; + // TODO: this should be done in a better place once we actually need to dynamically update it. beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0; beatmap.BeatmapInfo.Length = calculateLength(beatmap); @@ -319,12 +462,36 @@ namespace osu.Game.Beatmaps var lastObject = b.HitObjects.Last(); - double endTime = (lastObject as IHasEndTime)?.EndTime ?? lastObject.StartTime; + //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). + double endTime = lastObject.GetEndTime(); double startTime = b.HitObjects.First().StartTime; return endTime - startTime; } + private void removeWorkingCache(BeatmapSetInfo info) + { + if (info.Beatmaps == null) return; + + foreach (var b in info.Beatmaps) + removeWorkingCache(b); + } + + private void removeWorkingCache(BeatmapInfo info) + { + lock (workingCache) + { + var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID); + if (working != null) + workingCache.Remove(working); + } + } + + public void Dispose() + { + onlineLookupQueue?.Dispose(); + } + /// /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation. /// @@ -340,57 +507,28 @@ namespace osu.Game.Beatmaps protected override IBeatmap GetBeatmap() => beatmap; protected override Texture GetBackground() => null; - protected override Track GetTrack() => null; - } - - private class BeatmapUpdateQueue - { - private readonly IAPIProvider api; - - private const int update_queue_request_concurrency = 4; - - private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapUpdateQueue)); - - public BeatmapUpdateQueue(IAPIProvider api) - { - this.api = api; - } - - public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) - { - if (api?.State != APIState.Online) - return Task.CompletedTask; - - LogForModel(beatmapSet, "Performing online lookups..."); - return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); - } - - // todo: expose this when we need to do individual difficulty lookups. - protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken) - => Task.Factory.StartNew(() => update(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler, updateScheduler); - - private void update(BeatmapSetInfo set, BeatmapInfo beatmap) - { - if (api?.State != APIState.Online) - return; - - var req = new GetBeatmapRequest(beatmap); - - req.Success += res => - { - LogForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}."); - - beatmap.Status = res.Status; - beatmap.BeatmapSet.Status = res.BeatmapSet.Status; - beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID; - beatmap.OnlineBeatmapID = res.OnlineBeatmapID; - }; - - req.Failure += e => { LogForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})"); }; - - // intentionally blocking to limit web request concurrency - req.Perform(api); - } + protected override Track GetBeatmapTrack() => null; } } + + /// + /// The level of detail to include in database results. + /// + public enum IncludedDetails + { + /// + /// Only include beatmap difficulties and set level metadata. + /// + Minimal, + + /// + /// Include all difficulties, rulesets, difficulty metadata but no files. + /// + AllButFiles, + + /// + /// Include everything. + /// + All + } } diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs new file mode 100644 index 0000000000..c4563d5844 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs @@ -0,0 +1,200 @@ +// 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.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Microsoft.Data.Sqlite; +using osu.Framework.Development; +using osu.Framework.IO.Network; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Framework.Threading; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using SharpCompress.Compressors; +using SharpCompress.Compressors.BZip2; + +namespace osu.Game.Beatmaps +{ + public partial class BeatmapManager + { + [ExcludeFromDynamicCompile] + private class BeatmapOnlineLookupQueue : IDisposable + { + private readonly IAPIProvider api; + private readonly Storage storage; + + private const int update_queue_request_concurrency = 4; + + private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(update_queue_request_concurrency, nameof(BeatmapOnlineLookupQueue)); + + private FileWebRequest cacheDownloadRequest; + + private const string cache_database_name = "online.db"; + + public BeatmapOnlineLookupQueue(IAPIProvider api, Storage storage) + { + this.api = api; + this.storage = storage; + + // avoid downloading / using cache for unit tests. + if (!DebugUtils.IsNUnitRunning && !storage.Exists(cache_database_name)) + prepareLocalCache(); + } + + public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) + { + LogForModel(beatmapSet, "Performing online lookups..."); + return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); + } + + // todo: expose this when we need to do individual difficulty lookups. + protected Task UpdateAsync(BeatmapSetInfo beatmapSet, BeatmapInfo beatmap, CancellationToken cancellationToken) + => Task.Factory.StartNew(() => lookup(beatmapSet, beatmap), cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); + + private void lookup(BeatmapSetInfo set, BeatmapInfo beatmap) + { + if (checkLocalCache(set, beatmap)) + return; + + if (api?.State.Value != APIState.Online) + return; + + var req = new GetBeatmapRequest(beatmap); + + req.Failure += fail; + + try + { + // intentionally blocking to limit web request concurrency + api.Perform(req); + + var res = req.Result; + + if (res != null) + { + beatmap.Status = res.Status; + beatmap.BeatmapSet.Status = res.BeatmapSet.Status; + beatmap.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID; + beatmap.OnlineBeatmapID = res.OnlineBeatmapID; + + LogForModel(set, $"Online retrieval mapped {beatmap} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}."); + } + } + catch (Exception e) + { + fail(e); + } + + void fail(Exception e) + { + beatmap.OnlineBeatmapID = null; + LogForModel(set, $"Online retrieval failed for {beatmap} ({e.Message})"); + } + } + + private void prepareLocalCache() + { + string cacheFilePath = storage.GetFullPath(cache_database_name); + string compressedCacheFilePath = $"{cacheFilePath}.bz2"; + + cacheDownloadRequest = new FileWebRequest(compressedCacheFilePath, $"https://assets.ppy.sh/client-resources/{cache_database_name}.bz2"); + + cacheDownloadRequest.Failed += ex => + { + File.Delete(compressedCacheFilePath); + File.Delete(cacheFilePath); + + Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache download failed: {ex}", LoggingTarget.Database); + }; + + cacheDownloadRequest.Finished += () => + { + try + { + using (var stream = File.OpenRead(cacheDownloadRequest.Filename)) + using (var outStream = File.OpenWrite(cacheFilePath)) + using (var bz2 = new BZip2Stream(stream, CompressionMode.Decompress, false)) + bz2.CopyTo(outStream); + + // set to null on completion to allow lookups to begin using the new source + cacheDownloadRequest = null; + } + catch (Exception ex) + { + Logger.Log($"{nameof(BeatmapOnlineLookupQueue)}'s online cache extraction failed: {ex}", LoggingTarget.Database); + File.Delete(cacheFilePath); + } + finally + { + File.Delete(compressedCacheFilePath); + } + }; + + cacheDownloadRequest.PerformAsync(); + } + + private bool checkLocalCache(BeatmapSetInfo set, BeatmapInfo beatmap) + { + // download is in progress (or was, and failed). + if (cacheDownloadRequest != null) + return false; + + // database is unavailable. + if (!storage.Exists(cache_database_name)) + return false; + + try + { + using (var db = new SqliteConnection(storage.GetDatabaseConnectionString("online"))) + { + var found = db.QuerySingleOrDefault( + "SELECT * FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path", beatmap); + + if (found != null) + { + var status = (BeatmapSetOnlineStatus)found.approved; + + beatmap.Status = status; + beatmap.BeatmapSet.Status = status; + beatmap.BeatmapSet.OnlineBeatmapSetID = found.beatmapset_id; + beatmap.OnlineBeatmapID = found.beatmap_id; + + LogForModel(set, $"Cached local retrieval for {beatmap}."); + return true; + } + } + } + catch (Exception ex) + { + LogForModel(set, $"Cached local retrieval for {beatmap} failed with {ex}."); + } + + return false; + } + + public void Dispose() + { + 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 5657b8fb8a..f5c0d97c1f 100644 --- a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs @@ -2,14 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.IO; using System.Linq; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Logging; +using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; +using osu.Game.IO; using osu.Game.Skinning; using osu.Game.Storyboards; @@ -17,35 +18,39 @@ namespace osu.Game.Beatmaps { public partial class BeatmapManager { - protected class BeatmapManagerWorkingBeatmap : WorkingBeatmap + [ExcludeFromDynamicCompile] + private class BeatmapManagerWorkingBeatmap : WorkingBeatmap { private readonly IResourceStore store; + private readonly TextureStore textureStore; + private readonly ITrackStore trackStore; - public BeatmapManagerWorkingBeatmap(IResourceStore store, TextureStore textureStore, BeatmapInfo beatmapInfo, AudioManager audioManager) + public BeatmapManagerWorkingBeatmap(IResourceStore store, TextureStore textureStore, ITrackStore trackStore, BeatmapInfo beatmapInfo, AudioManager audioManager) : base(beatmapInfo, audioManager) { this.store = store; this.textureStore = textureStore; + this.trackStore = trackStore; } protected override IBeatmap GetBeatmap() { + if (BeatmapInfo.Path == null) + return new Beatmap { BeatmapInfo = BeatmapInfo }; + try { - using (var stream = new StreamReader(store.GetStream(getPathForFile(BeatmapInfo.Path)))) + using (var stream = new LineBufferedReader(store.GetStream(getPathForFile(BeatmapInfo.Path)))) return Decoder.GetDecoder(stream).Decode(stream); } - catch + catch (Exception e) { + Logger.Error(e, "Beatmap failed to load"); return null; } } - private string getPathForFile(string filename) => BeatmapSetInfo.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.InvariantCultureIgnoreCase))?.FileInfo.StoragePath; - - private TextureStore textureStore; - - private ITrackStore trackStore; + 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. @@ -58,49 +63,42 @@ namespace osu.Game.Beatmaps { return textureStore.Get(getPathForFile(Metadata.BackgroundFile)); } - catch + catch (Exception e) { + Logger.Error(e, "Background failed to load"); return null; } } - protected override Track GetTrack() + protected override Track GetBeatmapTrack() { + if (Metadata?.AudioFile == null) + return null; + try { - return (trackStore ?? (trackStore = AudioManager.GetTrackStore(store))).Get(getPathForFile(Metadata.AudioFile)); + return trackStore.Get(getPathForFile(Metadata.AudioFile)); } - catch + catch (Exception e) { + Logger.Error(e, "Track failed to load"); return null; } } - public override void RecycleTrack() - { - base.RecycleTrack(); - - trackStore?.Dispose(); - trackStore = null; - } - - public override void TransferTo(WorkingBeatmap other) - { - base.TransferTo(other); - - if (other is BeatmapManagerWorkingBeatmap owb && textureStore != null && BeatmapInfo.BackgroundEquals(other.BeatmapInfo)) - owb.textureStore = textureStore; - } - protected override Waveform GetWaveform() { + if (Metadata?.AudioFile == null) + return null; + try { var trackData = store.GetStream(getPathForFile(Metadata.AudioFile)); return trackData == null ? null : new Waveform(trackData); } - catch + catch (Exception e) { + Logger.Error(e, "Waveform failed to load"); return null; } } @@ -111,7 +109,7 @@ namespace osu.Game.Beatmaps try { - using (var stream = new StreamReader(store.GetStream(getPathForFile(BeatmapInfo.Path)))) + using (var stream = new LineBufferedReader(store.GetStream(getPathForFile(BeatmapInfo.Path)))) { var decoder = Decoder.GetDecoder(stream); @@ -120,7 +118,7 @@ namespace osu.Game.Beatmaps storyboard = decoder.Decode(stream); else { - using (var secondaryStream = new StreamReader(store.GetStream(getPathForFile(BeatmapSetInfo.StoryboardFile)))) + using (var secondaryStream = new LineBufferedReader(store.GetStream(getPathForFile(BeatmapSetInfo.StoryboardFile)))) storyboard = decoder.Decode(stream, secondaryStream); } } @@ -136,21 +134,17 @@ namespace osu.Game.Beatmaps return storyboard; } - protected override Skin GetSkin() + protected override ISkin GetSkin() { - Skin skin; - try { - skin = new LegacyBeatmapSkin(BeatmapInfo, store, AudioManager); + return new LegacyBeatmapSkin(BeatmapInfo, store, AudioManager); } catch (Exception e) { Logger.Error(e, "Skin failed to load"); - skin = new DefaultSkin(); + return null; } - - return skin; } } } diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index 001f319307..39b3c23ddd 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -6,11 +6,13 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using Newtonsoft.Json; +using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Users; namespace osu.Game.Beatmaps { + [ExcludeFromDynamicCompile] [Serializable] public class BeatmapMetadata : IEquatable, IHasPrimaryKey { @@ -53,7 +55,11 @@ namespace osu.Game.Beatmaps public string AudioFile { get; set; } public string BackgroundFile { get; set; } - public override string ToString() => $"{Artist} - {Title} ({Author})"; + public override string ToString() + { + string author = Author == null ? string.Empty : $"({Author})"; + return $"{Artist} - {Title} {author}".Trim(); + } [JsonIgnore] public string[] SearchableTerms => new[] diff --git a/osu.Game/Beatmaps/BeatmapProcessor.cs b/osu.Game/Beatmaps/BeatmapProcessor.cs index 7a612893c9..b7b5adc52e 100644 --- a/osu.Game/Beatmaps/BeatmapProcessor.cs +++ b/osu.Game/Beatmaps/BeatmapProcessor.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Beatmaps @@ -23,8 +22,18 @@ namespace osu.Game.Beatmaps { IHasComboInformation lastObj = null; + bool isFirst = true; + foreach (var obj in Beatmap.HitObjects.OfType()) { + if (isFirst) + { + obj.NewCombo = true; + + // first hitobject should always be marked as a new combo for sanity. + isFirst = false; + } + if (obj.NewCombo) { obj.IndexInCurrentCombo = 0; @@ -45,25 +54,6 @@ namespace osu.Game.Beatmaps public virtual void PostProcess() { - void updateNestedCombo(HitObject obj, int comboIndex, int indexInCurrentCombo) - { - if (obj is IHasComboInformation objectComboInfo) - { - objectComboInfo.ComboIndex = comboIndex; - objectComboInfo.IndexInCurrentCombo = indexInCurrentCombo; - foreach (var nestedObject in obj.NestedHitObjects) - updateNestedCombo(nestedObject, comboIndex, indexInCurrentCombo); - } - } - - foreach (var hitObject in Beatmap.HitObjects) - { - if (hitObject is IHasComboInformation objectComboInfo) - { - foreach (var nested in hitObject.NestedHitObjects) - updateNestedCombo(nested, objectComboInfo.ComboIndex, objectComboInfo.IndexInCurrentCombo); - } - } } } } diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 03bc7c7312..7bc1c8c7b9 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -5,10 +5,12 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; +using osu.Framework.Testing; using osu.Game.Database; namespace osu.Game.Beatmaps { + [ExcludeFromDynamicCompile] public class BeatmapSetInfo : IHasPrimaryKey, IHasFiles, ISoftDelete, IEquatable { public int ID { get; set; } @@ -55,7 +57,7 @@ namespace osu.Game.Beatmaps public string Hash { get; set; } - public string StoryboardFile => Files?.Find(f => f.Filename.EndsWith(".osb"))?.Filename; + public string StoryboardFile => Files?.Find(f => f.Filename.EndsWith(".osb", StringComparison.OrdinalIgnoreCase))?.Filename; public List Files { get; set; } @@ -63,6 +65,21 @@ namespace osu.Game.Beatmaps public bool Protected { get; set; } - public bool Equals(BeatmapSetInfo other) => OnlineBeatmapSetID == other?.OnlineBeatmapSetID; + public bool Equals(BeatmapSetInfo other) + { + if (other == null) + return false; + + if (ID != 0 && other.ID != 0) + return ID == other.ID; + + if (OnlineBeatmapSetID.HasValue && other.OnlineBeatmapSetID.HasValue) + return OnlineBeatmapSetID == other.OnlineBeatmapSetID; + + if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash)) + return Hash == other.Hash; + + return ReferenceEquals(this, other); + } } } diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs b/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs index df3a45d1cc..06dee4d3f5 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs @@ -75,6 +75,28 @@ namespace osu.Game.Beatmaps /// The availability of this beatmap set. /// public BeatmapSetOnlineAvailability Availability { get; set; } + + /// + /// The song genre of this beatmap set. + /// + public BeatmapSetOnlineGenre Genre { get; set; } + + /// + /// The song language of this beatmap set. + /// + public BeatmapSetOnlineLanguage Language { get; set; } + } + + public class BeatmapSetOnlineGenre + { + public int Id { get; set; } + public string Name { get; set; } + } + + public class BeatmapSetOnlineLanguage + { + public int Id { get; set; } + public string Name { get; set; } } public class BeatmapSetOnlineCovers diff --git a/osu.Game/Beatmaps/BeatmapStatistic.cs b/osu.Game/Beatmaps/BeatmapStatistic.cs index 0745ec5222..9d87a20d60 100644 --- a/osu.Game/Beatmaps/BeatmapStatistic.cs +++ b/osu.Game/Beatmaps/BeatmapStatistic.cs @@ -1,14 +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 osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osuTK; namespace osu.Game.Beatmaps { public class BeatmapStatistic { - public IconUsage Icon; + [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. + /// + public Func CreateIcon; + 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/BeatmapStatisticIcon.cs b/osu.Game/Beatmaps/BeatmapStatisticIcon.cs new file mode 100644 index 0000000000..181fb540df --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapStatisticIcon.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 Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Beatmaps +{ + /// + /// A default implementation of an icon used to represent beatmap statistics. + /// + public class BeatmapStatisticIcon : Sprite + { + private readonly BeatmapStatisticsIconType iconType; + + public BeatmapStatisticIcon(BeatmapStatisticsIconType iconType) + { + this.iconType = iconType; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Texture = textures.Get($"Icons/BeatmapDetails/{iconType.ToString().Kebaberize()}"); + } + } + + public enum BeatmapStatisticsIconType + { + Accuracy, + ApproachRate, + Bpm, + Circles, + HpDrain, + Length, + OverallDifficulty, + Size, + Sliders, + Spinners, + } +} diff --git a/osu.Game/Beatmaps/BeatmapStore.cs b/osu.Game/Beatmaps/BeatmapStore.cs index a2279fdb14..642bafd2ac 100644 --- a/osu.Game/Beatmaps/BeatmapStore.cs +++ b/osu.Game/Beatmaps/BeatmapStore.cs @@ -87,6 +87,18 @@ namespace osu.Game.Beatmaps base.Purge(items, context); } + public IQueryable BeatmapSetsOverview => ContextFactory.Get().BeatmapSetInfo + .Include(s => s.Metadata) + .Include(s => s.Beatmaps) + .AsNoTracking(); + + public IQueryable BeatmapSetsWithoutFiles => ContextFactory.Get().BeatmapSetInfo + .Include(s => s.Metadata) + .Include(s => s.Beatmaps).ThenInclude(s => s.Ruleset) + .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) + .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) + .AsNoTracking(); + public IQueryable Beatmaps => ContextFactory.Get().BeatmapInfo .Include(b => b.BeatmapSet).ThenInclude(s => s.Metadata) diff --git a/osu.Game/Beatmaps/BindableBeatmap.cs b/osu.Game/Beatmaps/BindableBeatmap.cs deleted file mode 100644 index af627cc6a9..0000000000 --- a/osu.Game/Beatmaps/BindableBeatmap.cs +++ /dev/null @@ -1,42 +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.Diagnostics; -using osu.Framework.Bindables; - -namespace osu.Game.Beatmaps -{ - /// - /// A for the beatmap. - /// This should be used sparingly in-favour of . - /// - public abstract class BindableBeatmap : NonNullableBindable - { - private WorkingBeatmap lastBeatmap; - - protected BindableBeatmap(WorkingBeatmap defaultValue) - : base(defaultValue) - { - BindValueChanged(b => updateAudioTrack(b.NewValue), true); - } - - private void updateAudioTrack(WorkingBeatmap beatmap) - { - var trackLoaded = lastBeatmap?.TrackLoaded ?? false; - - // compare to last beatmap as sometimes the two may share a track representation (optimisation, see WorkingBeatmap.TransferTo) - if (!trackLoaded || lastBeatmap?.Track != beatmap.Track) - { - if (trackLoaded) - { - Debug.Assert(lastBeatmap != null); - Debug.Assert(lastBeatmap.Track != null); - - lastBeatmap.RecycleTrack(); - } - } - - lastBeatmap = beatmap; - } - } -} diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index abe7e5e803..c6649f6af1 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -1,25 +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 System; +using osu.Game.Graphics; +using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public class ControlPoint : IComparable, IEquatable + public abstract class ControlPoint : IComparable { /// /// The time at which the control point takes effect. /// - public double Time; + public double Time => controlPointGroup?.Time ?? 0; - /// - /// Whether this timing point was generated internally, as opposed to parsed from the underlying beatmap. - /// - internal bool AutoGenerated; + private ControlPointGroup controlPointGroup; + + public void AttachGroup(ControlPointGroup pointGroup) => controlPointGroup = pointGroup; public int CompareTo(ControlPoint other) => Time.CompareTo(other.Time); - public bool Equals(ControlPoint other) - => Time.Equals(other?.Time); + public virtual Color4 GetRepresentingColour(OsuColour colours) => colours.Yellow; + + /// + /// Determines whether this results in a meaningful change when placed alongside another. + /// + /// An existing control point to compare with. + /// Whether this is redundant when placed alongside . + public abstract bool IsRedundant(ControlPoint existing); } } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs new file mode 100644 index 0000000000..cb73ce884e --- /dev/null +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointGroup.cs @@ -0,0 +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; +using System.Linq; +using osu.Framework.Bindables; + +namespace osu.Game.Beatmaps.ControlPoints +{ + public class ControlPointGroup : IComparable + { + public event Action ItemAdded; + public event Action ItemRemoved; + + /// + /// The time at which the control point takes effect. + /// + public double Time { get; } + + public IBindableList ControlPoints => controlPoints; + + private readonly BindableList controlPoints = new BindableList(); + + public ControlPointGroup(double time) + { + Time = time; + } + + public int CompareTo(ControlPointGroup other) => Time.CompareTo(other.Time); + + public void Add(ControlPoint point) + { + var existing = controlPoints.FirstOrDefault(p => p.GetType() == point.GetType()); + + if (existing != null) + Remove(existing); + + point.AttachGroup(this); + + controlPoints.Add(point); + ItemAdded?.Invoke(point); + } + + public void Remove(ControlPoint point) + { + controlPoints.Remove(point); + ItemRemoved?.Invoke(point); + } + } +} diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index 855084ad02..b843aad950 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; +using osu.Framework.Bindables; using osu.Framework.Lists; namespace osu.Game.Beatmaps.ControlPoints @@ -12,100 +13,194 @@ namespace osu.Game.Beatmaps.ControlPoints [Serializable] public class ControlPointInfo { + /// + /// All control points grouped by time. + /// + [JsonProperty] + public IBindableList Groups => groups; + + private readonly BindableList groups = new BindableList(); + /// /// All timing points. /// [JsonProperty] - public SortedList TimingPoints { get; private set; } = new SortedList(Comparer.Default); + public IReadOnlyList TimingPoints => timingPoints; + + private readonly SortedList timingPoints = new SortedList(Comparer.Default); /// /// All difficulty points. /// [JsonProperty] - public SortedList DifficultyPoints { get; private set; } = new SortedList(Comparer.Default); + public IReadOnlyList DifficultyPoints => difficultyPoints; + + private readonly SortedList difficultyPoints = new SortedList(Comparer.Default); /// /// All sound points. /// [JsonProperty] - public SortedList SamplePoints { get; private set; } = new SortedList(Comparer.Default); + public IBindableList SamplePoints => samplePoints; + + private readonly BindableList samplePoints = new BindableList(); /// /// All effect points. /// [JsonProperty] - public SortedList EffectPoints { get; private set; } = new SortedList(Comparer.Default); + public IReadOnlyList EffectPoints => effectPoints; + + private readonly SortedList effectPoints = new SortedList(Comparer.Default); + + /// + /// All control points, of all types. + /// + [JsonIgnore] + public IEnumerable AllControlPoints => Groups.SelectMany(g => g.ControlPoints).ToArray(); /// /// Finds the difficulty control point that is active at . /// /// The time to find the difficulty control point at. /// The difficulty control point. - public DifficultyControlPoint DifficultyPointAt(double time) => binarySearch(DifficultyPoints, time); + public DifficultyControlPoint DifficultyPointAt(double time) => binarySearchWithFallback(DifficultyPoints, time, DifficultyControlPoint.DEFAULT); /// /// Finds the effect control point that is active at . /// /// The time to find the effect control point at. /// The effect control point. - public EffectControlPoint EffectPointAt(double time) => binarySearch(EffectPoints, time); + public EffectControlPoint EffectPointAt(double time) => binarySearchWithFallback(EffectPoints, time, EffectControlPoint.DEFAULT); /// /// Finds the sound control point that is active at . /// /// The time to find the sound control point at. /// The sound control point. - public SampleControlPoint SamplePointAt(double time) => binarySearch(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : null); + public SampleControlPoint SamplePointAt(double time) => binarySearchWithFallback(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : SampleControlPoint.DEFAULT); /// /// Finds the timing control point that is active at . /// /// The time to find the timing control point at. /// The timing control point. - public TimingControlPoint TimingPointAt(double time) => binarySearch(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : null); + public TimingControlPoint TimingPointAt(double time) => binarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : TimingControlPoint.DEFAULT); /// /// Finds the maximum BPM represented by any timing control point. /// [JsonIgnore] public double BPMMaximum => - 60000 / (TimingPoints.OrderBy(c => c.BeatLength).FirstOrDefault() ?? new TimingControlPoint()).BeatLength; + 60000 / (TimingPoints.OrderBy(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Finds the minimum BPM represented by any timing control point. /// [JsonIgnore] public double BPMMinimum => - 60000 / (TimingPoints.OrderByDescending(c => c.BeatLength).FirstOrDefault() ?? new TimingControlPoint()).BeatLength; + 60000 / (TimingPoints.OrderByDescending(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; /// /// Finds the mode BPM (most common BPM) represented by the control points. /// [JsonIgnore] public double BPMMode => - 60000 / (TimingPoints.GroupBy(c => c.BeatLength).OrderByDescending(grp => grp.Count()).FirstOrDefault()?.FirstOrDefault() ?? new TimingControlPoint()).BeatLength; + 60000 / (TimingPoints.GroupBy(c => c.BeatLength).OrderByDescending(grp => grp.Count()).FirstOrDefault()?.FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength; + + /// + /// Remove all s and return to a pristine state. + /// + public void Clear() + { + groups.Clear(); + timingPoints.Clear(); + difficultyPoints.Clear(); + samplePoints.Clear(); + effectPoints.Clear(); + } + + /// + /// Add a new . Note that the provided control point may not be added if the correct state is already present at the provided time. + /// + /// The time at which the control point should be added. + /// The control point to add. + /// Whether the control point was added. + public bool Add(double time, ControlPoint controlPoint) + { + if (checkAlreadyExisting(time, controlPoint)) + return false; + + GroupAt(time, true).Add(controlPoint); + return true; + } + + public ControlPointGroup GroupAt(double time, bool addIfNotExisting = false) + { + var newGroup = new ControlPointGroup(time); + + int i = groups.BinarySearch(newGroup); + + if (i >= 0) + return groups[i]; + + if (addIfNotExisting) + { + newGroup.ItemAdded += groupItemAdded; + newGroup.ItemRemoved += groupItemRemoved; + + groups.Insert(~i, newGroup); + return newGroup; + } + + return null; + } + + public void RemoveGroup(ControlPointGroup group) + { + foreach (var item in group.ControlPoints.ToArray()) + group.Remove(item); + + group.ItemAdded -= groupItemAdded; + group.ItemRemoved -= groupItemRemoved; + + groups.Remove(group); + } + + /// + /// 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. + /// + /// The list to search. + /// The time to find the control point at. + /// The control point to use when is before any control points. + /// The active control point at , or a fallback if none found. + private T binarySearchWithFallback(IReadOnlyList list, double time, T fallback) + where T : ControlPoint + { + return binarySearch(list, time) ?? fallback; + } /// /// Binary searches one of the control point lists to find the active control point at . /// /// The list to search. /// The time to find the control point at. - /// The control point to use when is before any control points. If null, a new control point will be constructed. /// The active control point at . - private T binarySearch(SortedList list, double time, T prePoint = null) - where T : ControlPoint, new() + private T binarySearch(IReadOnlyList list, double time) + where T : ControlPoint { if (list == null) throw new ArgumentNullException(nameof(list)); if (list.Count == 0) - return new T(); + return null; if (time < list[0].Time) - return prePoint ?? new T(); + return null; - if (time >= list[list.Count - 1].Time) - return list[list.Count - 1]; + if (time >= list[^1].Time) + return list[^1]; int l = 0; int r = list.Count - 2; @@ -125,5 +220,82 @@ namespace osu.Game.Beatmaps.ControlPoints // l will be the first control point with Time > time, but we want the one before it return list[l - 1]; } + + /// + /// Check whether should be added. + /// + /// The time to find the timing control point at. + /// A point to be added. + /// Whether the new point should be added. + private bool checkAlreadyExisting(double time, ControlPoint newPoint) + { + ControlPoint existing = null; + + switch (newPoint) + { + case TimingControlPoint _: + // Timing points are a special case and need to be added regardless of fallback availability. + existing = binarySearch(TimingPoints, time); + break; + + case EffectControlPoint _: + existing = EffectPointAt(time); + break; + + case SampleControlPoint _: + existing = binarySearch(SamplePoints, time); + break; + + case DifficultyControlPoint _: + existing = DifficultyPointAt(time); + break; + } + + return newPoint?.IsRedundant(existing) == true; + } + + private void groupItemAdded(ControlPoint controlPoint) + { + switch (controlPoint) + { + case TimingControlPoint typed: + timingPoints.Add(typed); + break; + + case EffectControlPoint typed: + effectPoints.Add(typed); + break; + + case SampleControlPoint typed: + samplePoints.Add(typed); + break; + + case DifficultyControlPoint typed: + difficultyPoints.Add(typed); + break; + } + } + + private void groupItemRemoved(ControlPoint controlPoint) + { + switch (controlPoint) + { + case TimingControlPoint typed: + timingPoints.Remove(typed); + break; + + case EffectControlPoint typed: + effectPoints.Remove(typed); + break; + + case SampleControlPoint typed: + samplePoints.Remove(typed); + break; + + case DifficultyControlPoint typed: + difficultyPoints.Remove(typed); + break; + } + } } } diff --git a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs index a3e3121575..283bf76572 100644 --- a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs @@ -1,26 +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 System; -using osuTK; +using osu.Framework.Bindables; +using osu.Game.Graphics; +using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public class DifficultyControlPoint : ControlPoint, IEquatable + public class DifficultyControlPoint : ControlPoint { + public static readonly DifficultyControlPoint DEFAULT = new DifficultyControlPoint + { + SpeedMultiplierBindable = { Disabled = true }, + }; + + /// + /// The speed multiplier at this control point. + /// + public readonly BindableDouble SpeedMultiplierBindable = new BindableDouble(1) + { + Precision = 0.1, + Default = 1, + MinValue = 0.1, + MaxValue = 10 + }; + + public override Color4 GetRepresentingColour(OsuColour colours) => colours.GreenDark; + /// /// The speed multiplier at this control point. /// public double SpeedMultiplier { - get => speedMultiplier; - set => speedMultiplier = MathHelper.Clamp(value, 0.1, 10); + get => SpeedMultiplierBindable.Value; + set => SpeedMultiplierBindable.Value = value; } - private double speedMultiplier = 1; - - public bool Equals(DifficultyControlPoint other) - => base.Equals(other) - && SpeedMultiplier.Equals(other?.SpeedMultiplier); + public override bool IsRedundant(ControlPoint existing) + => existing is DifficultyControlPoint existingDifficulty + && SpeedMultiplier == existingDifficulty.SpeedMultiplier; } } diff --git a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs index 354d86dc13..ea28fca170 100644 --- a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs @@ -1,24 +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 System; +using osu.Framework.Bindables; +using osu.Game.Graphics; +using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public class EffectControlPoint : ControlPoint, IEquatable + public class EffectControlPoint : ControlPoint { - /// - /// Whether this control point enables Kiai mode. - /// - public bool KiaiMode; + public static readonly EffectControlPoint DEFAULT = new EffectControlPoint + { + KiaiModeBindable = { Disabled = true }, + OmitFirstBarLineBindable = { Disabled = true } + }; /// /// Whether the first bar line of this control point is ignored. /// - public bool OmitFirstBarLine; + public readonly BindableBool OmitFirstBarLineBindable = new BindableBool(); - public bool Equals(EffectControlPoint other) - => base.Equals(other) - && KiaiMode == other?.KiaiMode && OmitFirstBarLine == other.OmitFirstBarLine; + public override Color4 GetRepresentingColour(OsuColour colours) => colours.Purple; + + /// + /// Whether the first bar line of this control point is ignored. + /// + public bool OmitFirstBarLine + { + get => OmitFirstBarLineBindable.Value; + set => OmitFirstBarLineBindable.Value = value; + } + + /// + /// Whether this control point enables Kiai mode. + /// + public readonly BindableBool KiaiModeBindable = new BindableBool(); + + /// + /// Whether this control point enables Kiai mode. + /// + public bool KiaiMode + { + get => KiaiModeBindable.Value; + set => KiaiModeBindable.Value = value; + } + + public override bool IsRedundant(ControlPoint existing) + => !OmitFirstBarLine + && existing is EffectControlPoint existingEffect + && KiaiMode == existingEffect.KiaiMode + && OmitFirstBarLine == existingEffect.OmitFirstBarLine; } } diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index 7bc7a9056d..f57ecfb9e3 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -1,24 +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.Game.Audio; +using osu.Game.Graphics; +using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public class SampleControlPoint : ControlPoint, IEquatable + public class SampleControlPoint : ControlPoint { public const string DEFAULT_BANK = "normal"; + public static readonly SampleControlPoint DEFAULT = new SampleControlPoint + { + SampleBankBindable = { Disabled = true }, + SampleVolumeBindable = { Disabled = true } + }; + + public override Color4 GetRepresentingColour(OsuColour colours) => colours.Pink; + /// /// The default sample bank at this control point. /// - public string SampleBank = DEFAULT_BANK; + public readonly Bindable SampleBankBindable = new Bindable(DEFAULT_BANK) { Default = DEFAULT_BANK }; + + /// + /// The speed multiplier at this control point. + /// + public string SampleBank + { + get => SampleBankBindable.Value; + set => SampleBankBindable.Value = value; + } + + /// + /// The default sample bank at this control point. + /// + public readonly BindableInt SampleVolumeBindable = new BindableInt(100) + { + MinValue = 0, + MaxValue = 100, + Default = 100 + }; /// /// The default sample volume at this control point. /// - public int SampleVolume = 100; + public int SampleVolume + { + get => SampleVolumeBindable.Value; + set => SampleVolumeBindable.Value = value; + } /// /// Create a SampleInfo based on the sample settings in this control point. @@ -45,8 +78,9 @@ namespace osu.Game.Beatmaps.ControlPoints return newSampleInfo; } - public bool Equals(SampleControlPoint other) - => base.Equals(other) - && string.Equals(SampleBank, other?.SampleBank) && SampleVolume == other?.SampleVolume; + public override bool IsRedundant(ControlPoint existing) + => existing is SampleControlPoint existingSample + && SampleBank == existingSample.SampleBank + && SampleVolume == existingSample.SampleVolume; } } diff --git a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs index e5815a3f3b..d9378bca4a 100644 --- a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs @@ -1,32 +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 osuTK; +using osu.Framework.Bindables; using osu.Game.Beatmaps.Timing; +using osu.Game.Graphics; +using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public class TimingControlPoint : ControlPoint, IEquatable + public class TimingControlPoint : ControlPoint { /// /// The time signature at this control point. /// - public TimeSignatures TimeSignature = TimeSignatures.SimpleQuadruple; + public readonly Bindable TimeSignatureBindable = new Bindable(TimeSignatures.SimpleQuadruple) { Default = TimeSignatures.SimpleQuadruple }; + + /// + /// Default length of a beat in milliseconds. Used whenever there is no beatmap or track playing. + /// + private const double default_beat_length = 60000.0 / 60.0; + + public override Color4 GetRepresentingColour(OsuColour colours) => colours.YellowDark; + + public static readonly TimingControlPoint DEFAULT = new TimingControlPoint + { + BeatLengthBindable = + { + Value = default_beat_length, + Disabled = true + }, + TimeSignatureBindable = { Disabled = true } + }; + + /// + /// The time signature at this control point. + /// + public TimeSignatures TimeSignature + { + get => TimeSignatureBindable.Value; + set => TimeSignatureBindable.Value = value; + } + + public const double DEFAULT_BEAT_LENGTH = 1000; /// /// The beat length at this control point. /// - public virtual double BeatLength + public readonly BindableDouble BeatLengthBindable = new BindableDouble(DEFAULT_BEAT_LENGTH) { - get => beatLength; - set => beatLength = MathHelper.Clamp(value, 6, 60000); + Default = DEFAULT_BEAT_LENGTH, + MinValue = 6, + MaxValue = 60000 + }; + + /// + /// The beat length at this control point. + /// + public double BeatLength + { + get => BeatLengthBindable.Value; + set => BeatLengthBindable.Value = value; } - private double beatLength = 1000; + /// + /// The BPM at this control point. + /// + public double BPM => 60000 / BeatLength; - public bool Equals(TimingControlPoint other) - => base.Equals(other) - && TimeSignature == other?.TimeSignature && beatLength.Equals(other.beatLength); + // Timing points are never redundant as they can change the time signature. + public override bool IsRedundant(ControlPoint existing) => false; } } diff --git a/osu.Game/Beatmaps/DifficultyRating.cs b/osu.Game/Beatmaps/DifficultyRating.cs new file mode 100644 index 0000000000..f0ee0ad705 --- /dev/null +++ b/osu.Game/Beatmaps/DifficultyRating.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.Beatmaps +{ + public enum DifficultyRating + { + Easy, + Normal, + Hard, + Insane, + Expert, + ExpertPlus + } +} diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetCover.cs index d0db7765c2..5245bc319d 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetCover.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Beatmaps.Drawables { + [LongRunningLoad] public class BeatmapSetCover : Sprite { private readonly BeatmapSetInfo set; diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index 351e5df17a..f6e03d40ff 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -13,6 +13,7 @@ namespace osu.Game.Beatmaps.Drawables public class BeatmapSetOnlineStatusPill : CircularContainer { private readonly OsuSpriteText statusText; + private readonly Box background; private BeatmapSetOnlineStatus status; @@ -43,6 +44,12 @@ namespace osu.Game.Beatmaps.Drawables set => statusText.Padding = value; } + public Color4 BackgroundColour + { + get => background.Colour; + set => background.Colour = value; + } + public BeatmapSetOnlineStatusPill() { AutoSizeAxes = Axes.Both; @@ -50,7 +57,7 @@ namespace osu.Game.Beatmaps.Drawables Children = new Drawable[] { - new Box + background = new Box { RelativeSizeAxes = Axes.Both, Colour = Color4.Black, diff --git a/osu.Game/Beatmaps/Drawables/DifficultyColouredContainer.cs b/osu.Game/Beatmaps/Drawables/DifficultyColouredContainer.cs deleted file mode 100644 index 26ffcca1ec..0000000000 --- a/osu.Game/Beatmaps/Drawables/DifficultyColouredContainer.cs +++ /dev/null @@ -1,85 +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.Allocation; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osuTK.Graphics; - -namespace osu.Game.Beatmaps.Drawables -{ - public abstract class DifficultyColouredContainer : Container, IHasAccentColour - { - public Color4 AccentColour { get; set; } - - private readonly BeatmapInfo beatmap; - private OsuColour palette; - - protected DifficultyColouredContainer(BeatmapInfo beatmap) - { - this.beatmap = beatmap; - } - - [BackgroundDependencyLoader] - private void load(OsuColour palette) - { - if (palette == null) - throw new ArgumentNullException(nameof(palette)); - - this.palette = palette; - AccentColour = getColour(beatmap); - } - - private enum DifficultyRating - { - Easy, - Normal, - Hard, - Insane, - Expert, - ExpertPlus - } - - private DifficultyRating getDifficultyRating(BeatmapInfo beatmap) - { - if (beatmap == null) - throw new ArgumentNullException(nameof(beatmap)); - - var rating = beatmap.StarDifficulty; - - if (rating < 2.0) return DifficultyRating.Easy; - if (rating < 2.7) return DifficultyRating.Normal; - if (rating < 4.0) return DifficultyRating.Hard; - if (rating < 5.3) return DifficultyRating.Insane; - if (rating < 6.5) return DifficultyRating.Expert; - - return DifficultyRating.ExpertPlus; - } - - private Color4 getColour(BeatmapInfo beatmap) - { - switch (getDifficultyRating(beatmap)) - { - case DifficultyRating.Easy: - return palette.Green; - - default: - case DifficultyRating.Normal: - return palette.Blue; - - case DifficultyRating.Hard: - return palette.Yellow; - - case DifficultyRating.Insane: - return palette.Pink; - - case DifficultyRating.Expert: - return palette.Purple; - - case DifficultyRating.ExpertPlus: - return palette.Gray0; - } - } - } -} diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs index 0a0ad28fdf..96e18f120a 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs @@ -1,40 +1,94 @@ -// 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.Threading; +using JetBrains.Annotations; 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.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osuTK; using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables { - public class DifficultyIcon : DifficultyColouredContainer + public class DifficultyIcon : CompositeDrawable, IHasCustomTooltip { + private readonly Container iconContainer; + + /// + /// Size of this difficulty icon. + /// + public new Vector2 Size + { + get => iconContainer.Size; + set => iconContainer.Size = value; + } + + [NotNull] + private readonly BeatmapInfo beatmap; + + [CanBeNull] private readonly RulesetInfo ruleset; - public DifficultyIcon(BeatmapInfo beatmap, RulesetInfo ruleset = null) - : base(beatmap) + [CanBeNull] + private readonly IReadOnlyList mods; + + private readonly bool shouldShowTooltip; + + private readonly bool performBackgroundDifficultyLookup; + + private readonly Bindable difficultyBindable = new Bindable(); + + private Drawable background; + + /// + /// Creates a new with a given and combination. + /// + /// The beatmap to show the difficulty of. + /// The ruleset to show the difficulty with. + /// The mods to show the difficulty with. + /// Whether to display a tooltip when hovered. + public DifficultyIcon([NotNull] BeatmapInfo beatmap, [CanBeNull] RulesetInfo ruleset, [CanBeNull] IReadOnlyList mods, bool shouldShowTooltip = true) + : this(beatmap, shouldShowTooltip) { - if (beatmap == null) - throw new ArgumentNullException(nameof(beatmap)); - this.ruleset = ruleset ?? beatmap.Ruleset; + this.mods = mods ?? Array.Empty(); + } - Size = new Vector2(20); + /// + /// Creates a new that follows the currently-selected ruleset and mods. + /// + /// The beatmap to show the difficulty of. + /// Whether to display a tooltip when hovered. + /// Whether to perform difficulty lookup (including calculation if necessary). + public DifficultyIcon([NotNull] BeatmapInfo beatmap, bool shouldShowTooltip = true, bool performBackgroundDifficultyLookup = true) + { + this.beatmap = beatmap ?? throw new ArgumentNullException(nameof(beatmap)); + this.shouldShowTooltip = shouldShowTooltip; + this.performBackgroundDifficultyLookup = performBackgroundDifficultyLookup; + + AutoSizeAxes = Axes.Both; + + InternalChild = iconContainer = new Container { Size = new Vector2(20f) }; } [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours) { - Children = new Drawable[] + iconContainer.Children = new Drawable[] { new CircularContainer { @@ -49,10 +103,10 @@ namespace osu.Game.Beatmaps.Drawables Type = EdgeEffectType.Shadow, Radius = 5, }, - Child = new Box + Child = background = new Box { RelativeSizeAxes = Axes.Both, - Colour = AccentColour, + Colour = colours.ForDifficultyRating(beatmap.DifficultyRating) // Default value that will be re-populated once difficulty calculation completes }, }, new ConstrainedIconContainer @@ -61,9 +115,169 @@ namespace osu.Game.Beatmaps.Drawables Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, // the null coalesce here is only present to make unit tests work (ruleset dlls aren't copied correctly for testing at the moment) - Icon = ruleset?.CreateInstance().CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle } - } + Icon = (ruleset ?? beatmap.Ruleset)?.CreateInstance()?.CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle } + }, }; + + if (performBackgroundDifficultyLookup) + iconContainer.Add(new DelayedLoadUnloadWrapper(() => new DifficultyRetriever(beatmap, ruleset, mods) { StarDifficulty = { BindTarget = difficultyBindable } }, 0)); + else + difficultyBindable.Value = new StarDifficulty(beatmap.StarDifficulty, 0); + + difficultyBindable.BindValueChanged(difficulty => background.Colour = colours.ForDifficultyRating(difficulty.NewValue.DifficultyRating)); + } + + public ITooltip GetCustomTooltip() => new DifficultyIconTooltip(); + + public object TooltipContent => shouldShowTooltip ? new DifficultyIconTooltipContent(beatmap, difficultyBindable) : null; + + private class DifficultyRetriever : Component + { + public readonly Bindable StarDifficulty = new Bindable(); + + private readonly BeatmapInfo beatmap; + private readonly RulesetInfo ruleset; + private readonly IReadOnlyList mods; + + private CancellationTokenSource difficultyCancellation; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + + public DifficultyRetriever(BeatmapInfo beatmap, RulesetInfo ruleset, IReadOnlyList mods) + { + this.beatmap = beatmap; + this.ruleset = ruleset; + this.mods = mods; + } + + private IBindable localStarDifficulty; + + [BackgroundDependencyLoader] + private void load() + { + difficultyCancellation = new CancellationTokenSource(); + localStarDifficulty = ruleset != null + ? difficultyCache.GetBindableDifficulty(beatmap, ruleset, mods, difficultyCancellation.Token) + : difficultyCache.GetBindableDifficulty(beatmap, difficultyCancellation.Token); + localStarDifficulty.BindValueChanged(difficulty => StarDifficulty.Value = difficulty.NewValue); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + difficultyCancellation?.Cancel(); + } + } + + private class DifficultyIconTooltipContent + { + public readonly BeatmapInfo Beatmap; + public readonly IBindable Difficulty; + + public DifficultyIconTooltipContent(BeatmapInfo beatmap, IBindable difficulty) + { + Beatmap = beatmap; + Difficulty = difficulty; + } + } + + private class DifficultyIconTooltip : VisibilityContainer, ITooltip + { + private readonly OsuSpriteText difficultyName, starRating; + private readonly Box background; + private readonly FillFlowContainer difficultyFlow; + + public DifficultyIconTooltip() + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 200, + AutoSizeEasing = Easing.OutQuint, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(10), + Children = new Drawable[] + { + difficultyName = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold), + }, + difficultyFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + starRating = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Left = 4 }, + Icon = FontAwesome.Solid.Star, + Size = new Vector2(12), + }, + } + } + } + } + }; + } + + [Resolved] + private OsuColour colours { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + background.Colour = colours.Gray3; + } + + private readonly IBindable starDifficulty = new Bindable(); + + public bool SetContent(object content) + { + if (!(content is DifficultyIconTooltipContent iconContent)) + return false; + + difficultyName.Text = iconContent.Beatmap.Version; + + starDifficulty.UnbindAll(); + starDifficulty.BindTo(iconContent.Difficulty); + starDifficulty.BindValueChanged(difficulty => + { + starRating.Text = $"{difficulty.NewValue.Stars:0.##}"; + difficultyFlow.Colour = colours.ForDifficultyRating(difficulty.NewValue.DifficultyRating, true); + }, true); + + return true; + } + + public void Move(Vector2 pos) => Position = pos; + + protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + + protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); } } } diff --git a/osu.Game/Beatmaps/Drawables/GroupedDifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/GroupedDifficultyIcon.cs new file mode 100644 index 0000000000..fcee4c2f1a --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/GroupedDifficultyIcon.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 System.Linq; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; +using osuTK.Graphics; + +namespace osu.Game.Beatmaps.Drawables +{ + /// + /// A difficulty icon that contains a counter on the right-side of it. + /// + /// + /// Used in cases when there are too many difficulty icons to show. + /// + public class GroupedDifficultyIcon : DifficultyIcon + { + public GroupedDifficultyIcon(List beatmaps, RulesetInfo ruleset, Color4 counterColour) + : base(beatmaps.OrderBy(b => b.StarDifficulty).Last(), ruleset, null, false) + { + AddInternal(new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Padding = new MarginPadding { Left = Size.X }, + Margin = new MarginPadding { Left = 2, Right = 5 }, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold), + Text = beatmaps.Count.ToString(), + Colour = counterColour, + }); + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs index 30346a8a96..eb05cbaf85 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs @@ -16,6 +16,8 @@ namespace osu.Game.Beatmaps.Drawables { public readonly Bindable Beatmap = new Bindable(); + protected override double LoadDelay => 500; + [Resolved] private BeatmapManager beatmaps { get; set; } diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs index 16eecb7198..6c229755e7 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.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.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; @@ -50,7 +49,7 @@ namespace osu.Game.Beatmaps.Drawables Child = new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(OsuColour.Gray(0.2f), OsuColour.Gray(0.1f)), + Colour = OsuColour.Gray(0.2f), }; } @@ -68,19 +67,18 @@ namespace osu.Game.Beatmaps.Drawables if (beatmapSet != null) { - BeatmapSetCover cover; - - Add(displayedCover = new DelayedLoadWrapper( - cover = new BeatmapSetCover(beatmapSet, 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); + }; + cover.OnLoadComplete += d => d.FadeInFromZero(400, Easing.Out); + return cover; + })); } } } diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index 3a4c677bd1..c114358771 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Threading; +using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; @@ -19,7 +21,7 @@ namespace osu.Game.Beatmaps { private readonly TextureStore textures; - public DummyWorkingBeatmap(AudioManager audio, TextureStore textures) + public DummyWorkingBeatmap([NotNull] AudioManager audio, TextureStore textures) : base(new BeatmapInfo { Metadata = new BeatmapMetadata @@ -44,17 +46,17 @@ namespace osu.Game.Beatmaps protected override Texture GetBackground() => textures?.Get(@"Backgrounds/bg4"); - protected override Track GetTrack() => GetVirtualTrack(); + protected override Track GetBeatmapTrack() => GetVirtualTrack(); private class DummyRulesetInfo : RulesetInfo { - public override Ruleset CreateInstance() => new DummyRuleset(this); + public override Ruleset CreateInstance() => new DummyRuleset(); private class DummyRuleset : Ruleset { - public override IEnumerable GetModsFor(ModType type) => new Mod[] { }; + public override IEnumerable GetModsFor(ModType type) => Array.Empty(); - public override DrawableRuleset CreateDrawableRulesetWith(WorkingBeatmap beatmap, IReadOnlyList mods) + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) { throw new NotImplementedException(); } @@ -67,20 +69,15 @@ namespace osu.Game.Beatmaps public override string ShortName => "dummy"; - public DummyRuleset(RulesetInfo rulesetInfo = null) - : base(rulesetInfo) - { - } - private class DummyBeatmapConverter : IBeatmapConverter { public event Action> ObjectConverted; public IBeatmap Beatmap { get; set; } - public bool CanConvert => true; + public bool CanConvert() => true; - public IBeatmap Convert() + public IBeatmap Convert(CancellationToken cancellationToken = default) { foreach (var obj in Beatmap.HitObjects) ObjectConverted?.Invoke(obj, obj.Yield()); diff --git a/osu.Game/Beatmaps/Formats/Decoder.cs b/osu.Game/Beatmaps/Formats/Decoder.cs index 953e50eadc..845ac20db0 100644 --- a/osu.Game/Beatmaps/Formats/Decoder.cs +++ b/osu.Game/Beatmaps/Formats/Decoder.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using osu.Game.IO; namespace osu.Game.Beatmaps.Formats { @@ -13,20 +14,21 @@ namespace osu.Game.Beatmaps.Formats { protected virtual TOutput CreateTemplateObject() => new TOutput(); - public TOutput Decode(StreamReader primaryStream, params StreamReader[] otherStreams) + public TOutput Decode(LineBufferedReader primaryStream, params LineBufferedReader[] otherStreams) { var output = CreateTemplateObject(); - foreach (StreamReader stream in otherStreams.Prepend(primaryStream)) + foreach (LineBufferedReader stream in otherStreams.Prepend(primaryStream)) ParseStreamInto(stream, output); return output; } - protected abstract void ParseStreamInto(StreamReader stream, TOutput output); + protected abstract void ParseStreamInto(LineBufferedReader stream, TOutput output); } public abstract class Decoder { private static readonly Dictionary>> decoders = new Dictionary>>(); + private static readonly Dictionary> fallback_decoders = new Dictionary>(); static Decoder() { @@ -39,7 +41,7 @@ namespace osu.Game.Beatmaps.Formats /// Retrieves a to parse a . /// /// A stream pointing to the . - public static Decoder GetDecoder(StreamReader stream) + public static Decoder GetDecoder(LineBufferedReader stream) where T : new() { if (stream == null) @@ -48,21 +50,31 @@ namespace osu.Game.Beatmaps.Formats if (!decoders.TryGetValue(typeof(T), out var typedDecoders)) throw new IOException(@"Unknown decoder type"); - string line; + // start off with the first line of the file + string line = stream.PeekLine()?.Trim(); - do + while (line != null && line.Length == 0) { - line = stream.ReadLine()?.Trim(); - } while (line != null && line.Length == 0); + // consume the previously peeked empty line and advance to the next one + stream.ReadLine(); + line = stream.PeekLine()?.Trim(); + } if (line == null) - throw new IOException(@"Unknown file format (null)"); + throw new IOException("Unknown file format (null)"); - var decoder = typedDecoders.Select(d => line.StartsWith(d.Key, StringComparison.InvariantCulture) ? d.Value : null).FirstOrDefault(); - if (decoder == null) - throw new IOException($@"Unknown file format ({line})"); + var decoder = typedDecoders.Where(d => line.StartsWith(d.Key, StringComparison.InvariantCulture)).Select(d => d.Value).FirstOrDefault(); - return (Decoder)decoder.Invoke(line); + // it's important the magic does NOT get consumed here, since sometimes it's part of the structure + // (see JsonBeatmapDecoder - the magic string is the opening brace) + // decoder implementations should therefore not die on receiving their own magic + if (decoder != null) + return (Decoder)decoder.Invoke(line); + + if (!fallback_decoders.TryGetValue(typeof(T), out var fallbackDecoder)) + throw new IOException($"Unknown file format ({line})"); + + return (Decoder)fallbackDecoder.Invoke(); } /// @@ -77,5 +89,17 @@ namespace osu.Game.Beatmaps.Formats typedDecoders[magic] = constructor; } + + /// + /// Registers a fallback decoder instantiation function. + /// The fallback will be returned if the first non-empty line of the decoded stream does not match any known magic. + /// Calling this method will overwrite any existing global fallback registration for type - use with caution. + /// + /// Type of object being decoded. + /// A function that constructs the fallback. + protected static void SetFallbackDecoder(Func constructor) + { + fallback_decoders[typeof(T)] = constructor; + } } } diff --git a/osu.Game/Beatmaps/Formats/IHasComboColours.cs b/osu.Game/Beatmaps/Formats/IHasComboColours.cs index 4c15cb96d1..41c85db063 100644 --- a/osu.Game/Beatmaps/Formats/IHasComboColours.cs +++ b/osu.Game/Beatmaps/Formats/IHasComboColours.cs @@ -8,6 +8,14 @@ namespace osu.Game.Beatmaps.Formats { public interface IHasComboColours { - List ComboColours { get; set; } + /// + /// Retrieves the list of combo colours for presentation only. + /// + IReadOnlyList ComboColours { get; } + + /// + /// Adds combo colours to the list. + /// + void AddComboColours(params Color4[] colours); } } diff --git a/osu.Game/Beatmaps/Formats/IHasCustomColours.cs b/osu.Game/Beatmaps/Formats/IHasCustomColours.cs index 8f6c7dc328..dba3a37545 100644 --- a/osu.Game/Beatmaps/Formats/IHasCustomColours.cs +++ b/osu.Game/Beatmaps/Formats/IHasCustomColours.cs @@ -8,6 +8,6 @@ namespace osu.Game.Beatmaps.Formats { public interface IHasCustomColours { - Dictionary CustomColours { get; set; } + Dictionary CustomColours { get; } } } diff --git a/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs index d8482b200f..988968fa42 100644 --- a/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/JsonBeatmapDecoder.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. -using System.IO; +using osu.Game.IO; using osu.Game.IO.Serialization; namespace osu.Game.Beatmaps.Formats @@ -13,11 +13,8 @@ namespace osu.Game.Beatmaps.Formats AddDecoder("{", m => new JsonBeatmapDecoder()); } - protected override void ParseStreamInto(StreamReader stream, Beatmap output) + protected override void ParseStreamInto(LineBufferedReader stream, Beatmap output) { - stream.BaseStream.Position = 0; - stream.DiscardBufferedData(); - stream.ReadToEnd().DeserializeInto(output); foreach (var hitObject in output.HitObjects) diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 3cd425ea44..442be6e837 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -2,13 +2,15 @@ // 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 osu.Framework.IO.File; -using osu.Framework.Logging; -using osu.Game.Beatmaps.Timing; -using osu.Game.Rulesets.Objects.Legacy; +using osu.Framework.Extensions; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Beatmaps.Timing; +using osu.Game.IO; +using osu.Game.Rulesets.Objects.Legacy; namespace osu.Game.Beatmaps.Formats { @@ -26,6 +28,7 @@ namespace osu.Game.Beatmaps.Formats public static void Register() { AddDecoder(@"osu file format v", m => new LegacyBeatmapDecoder(Parsing.ParseInt(m.Split('v').Last()))); + SetFallbackDecoder(() => new LegacyBeatmapDecoder()); } /// @@ -42,13 +45,15 @@ namespace osu.Game.Beatmaps.Formats offset = FormatVersion < 5 ? 24 : 0; } - protected override void ParseStreamInto(StreamReader stream, Beatmap beatmap) + protected override void ParseStreamInto(LineBufferedReader stream, Beatmap beatmap) { this.beatmap = beatmap; this.beatmap.BeatmapInfo.BeatmapVersion = FormatVersion; base.ParseStreamInto(stream, beatmap); + flushPendingPoints(); + // Objects may be out of order *only* if a user has manually edited an .osu file. // Unfortunately there are ranked maps in this state (example: https://osu.ppy.sh/s/594828). // OrderBy is used to guarantee that the parsing order of hitobjects with equal start times is maintained (stably-sorted) @@ -59,7 +64,7 @@ namespace osu.Game.Beatmaps.Formats hitObject.ApplyDefaults(this.beatmap.ControlPointInfo, this.beatmap.BeatmapInfo.BaseDifficulty); } - protected override bool ShouldSkipLine(string line) => base.ShouldSkipLine(line) || line.StartsWith(" ", StringComparison.Ordinal) || line.StartsWith("_", StringComparison.Ordinal); + protected override bool ShouldSkipLine(string line) => base.ShouldSkipLine(line) || line.StartsWith(' ') || line.StartsWith('_'); protected override void ParseLine(Beatmap beatmap, Section section, string line) { @@ -108,7 +113,7 @@ namespace osu.Game.Beatmaps.Formats switch (pair.Key) { case @"AudioFilename": - metadata.AudioFile = FileSafety.PathStandardise(pair.Value); + metadata.AudioFile = pair.Value.ToStandardisedPath(); break; case @"AudioLeadIn": @@ -170,6 +175,10 @@ namespace osu.Game.Beatmaps.Formats case @"WidescreenStoryboard": beatmap.BeatmapInfo.WidescreenStoryboard = Parsing.ParseInt(pair.Value) == 1; break; + + case @"EpilepsyWarning": + beatmap.BeatmapInfo.EpilepsyWarning = Parsing.ParseInt(pair.Value) == 1; + break; } } @@ -234,11 +243,11 @@ namespace osu.Game.Beatmaps.Formats break; case @"Source": - beatmap.BeatmapInfo.Metadata.Source = pair.Value; + metadata.Source = pair.Value; break; case @"Tags": - beatmap.BeatmapInfo.Metadata.Tags = pair.Value; + metadata.Tags = pair.Value; break; case @"BeatmapID": @@ -289,195 +298,135 @@ namespace osu.Game.Beatmaps.Formats { string[] split = line.Split(','); - EventType type; - if (!Enum.TryParse(split[0], out type)) - throw new InvalidDataException($@"Unknown event type {split[0]}"); + if (!Enum.TryParse(split[0], out LegacyEventType type)) + throw new InvalidDataException($@"Unknown event type: {split[0]}"); switch (type) { - case EventType.Background: - string filename = split[2].Trim('"'); - beatmap.BeatmapInfo.Metadata.BackgroundFile = FileSafety.PathStandardise(filename); + case LegacyEventType.Background: + beatmap.BeatmapInfo.Metadata.BackgroundFile = CleanFilename(split[2]); break; - case EventType.Break: + case LegacyEventType.Break: double start = getOffsetTime(Parsing.ParseDouble(split[1])); + double end = Math.Max(start, getOffsetTime(Parsing.ParseDouble(split[2]))); - var breakEvent = new BreakPeriod - { - StartTime = start, - EndTime = Math.Max(start, getOffsetTime(Parsing.ParseDouble(split[2]))) - }; - - if (!breakEvent.HasEffect) - return; - - beatmap.Breaks.Add(breakEvent); + beatmap.Breaks.Add(new BreakPeriod(start, end)); break; } } private void handleTimingPoint(string line) { - try + string[] split = line.Split(','); + + double time = getOffsetTime(Parsing.ParseDouble(split[0].Trim())); + double beatLength = Parsing.ParseDouble(split[1].Trim()); + double speedMultiplier = beatLength < 0 ? 100.0 / -beatLength : 1; + + TimeSignatures timeSignature = TimeSignatures.SimpleQuadruple; + if (split.Length >= 3) + timeSignature = split[2][0] == '0' ? TimeSignatures.SimpleQuadruple : (TimeSignatures)Parsing.ParseInt(split[2]); + + LegacySampleBank sampleSet = defaultSampleBank; + if (split.Length >= 4) + sampleSet = (LegacySampleBank)Parsing.ParseInt(split[3]); + + int customSampleBank = 0; + if (split.Length >= 5) + customSampleBank = Parsing.ParseInt(split[4]); + + int sampleVolume = defaultSampleVolume; + if (split.Length >= 6) + sampleVolume = Parsing.ParseInt(split[5]); + + bool timingChange = true; + if (split.Length >= 7) + timingChange = split[6][0] == '1'; + + bool kiaiMode = false; + bool omitFirstBarSignature = false; + + if (split.Length >= 8) { - string[] split = line.Split(','); - - double time = getOffsetTime(Parsing.ParseDouble(split[0].Trim())); - double beatLength = Parsing.ParseDouble(split[1].Trim()); - double speedMultiplier = beatLength < 0 ? 100.0 / -beatLength : 1; - - TimeSignatures timeSignature = TimeSignatures.SimpleQuadruple; - if (split.Length >= 3) - timeSignature = split[2][0] == '0' ? TimeSignatures.SimpleQuadruple : (TimeSignatures)Parsing.ParseInt(split[2]); - - LegacySampleBank sampleSet = defaultSampleBank; - if (split.Length >= 4) - sampleSet = (LegacySampleBank)Parsing.ParseInt(split[3]); - - int customSampleBank = 0; - if (split.Length >= 5) - customSampleBank = Parsing.ParseInt(split[4]); - - int sampleVolume = defaultSampleVolume; - if (split.Length >= 6) - sampleVolume = Parsing.ParseInt(split[5]); - - bool timingChange = true; - if (split.Length >= 7) - timingChange = split[6][0] == '1'; - - bool kiaiMode = false; - bool omitFirstBarSignature = false; - - if (split.Length >= 8) - { - EffectFlags effectFlags = (EffectFlags)Parsing.ParseInt(split[7]); - kiaiMode = effectFlags.HasFlag(EffectFlags.Kiai); - omitFirstBarSignature = effectFlags.HasFlag(EffectFlags.OmitFirstBarLine); - } - - string stringSampleSet = sampleSet.ToString().ToLowerInvariant(); - if (stringSampleSet == @"none") - stringSampleSet = @"normal"; - - if (timingChange) - { - var controlPoint = CreateTimingControlPoint(); - controlPoint.Time = time; - controlPoint.BeatLength = beatLength; - controlPoint.TimeSignature = timeSignature; - - handleTimingControlPoint(controlPoint); - } - - handleDifficultyControlPoint(new DifficultyControlPoint - { - Time = time, - SpeedMultiplier = speedMultiplier, - AutoGenerated = timingChange - }); - - handleEffectControlPoint(new EffectControlPoint - { - Time = time, - KiaiMode = kiaiMode, - OmitFirstBarLine = omitFirstBarSignature, - AutoGenerated = timingChange - }); - - handleSampleControlPoint(new LegacySampleControlPoint - { - Time = time, - SampleBank = stringSampleSet, - SampleVolume = sampleVolume, - CustomSampleBank = customSampleBank, - AutoGenerated = timingChange - }); + LegacyEffectFlags effectFlags = (LegacyEffectFlags)Parsing.ParseInt(split[7]); + kiaiMode = effectFlags.HasFlag(LegacyEffectFlags.Kiai); + omitFirstBarSignature = effectFlags.HasFlag(LegacyEffectFlags.OmitFirstBarLine); } - catch (FormatException) + + string stringSampleSet = sampleSet.ToString().ToLowerInvariant(); + if (stringSampleSet == @"none") + stringSampleSet = @"normal"; + + if (timingChange) { - Logger.Log("A timing point could not be parsed correctly and will be ignored", LoggingTarget.Runtime, LogLevel.Important); + var controlPoint = CreateTimingControlPoint(); + + controlPoint.BeatLength = beatLength; + controlPoint.TimeSignature = timeSignature; + + addControlPoint(time, controlPoint, true); } - catch (OverflowException) + +#pragma warning disable 618 + addControlPoint(time, new LegacyDifficultyControlPoint(beatLength) +#pragma warning restore 618 { - Logger.Log("A timing point could not be parsed correctly and will be ignored", LoggingTarget.Runtime, LogLevel.Important); - } + SpeedMultiplier = speedMultiplier, + }, timingChange); + + addControlPoint(time, new EffectControlPoint + { + KiaiMode = kiaiMode, + OmitFirstBarLine = omitFirstBarSignature, + }, timingChange); + + addControlPoint(time, new LegacySampleControlPoint + { + SampleBank = stringSampleSet, + SampleVolume = sampleVolume, + CustomSampleBank = customSampleBank, + }, timingChange); } - private void handleTimingControlPoint(TimingControlPoint newPoint) + private readonly List pendingControlPoints = new List(); + private readonly HashSet pendingControlPointTypes = new HashSet(); + private double pendingControlPointsTime; + + private void addControlPoint(double time, ControlPoint point, bool timingChange) { - var existing = beatmap.ControlPointInfo.TimingPointAt(newPoint.Time); + if (time != pendingControlPointsTime) + flushPendingPoints(); - if (existing.Time == newPoint.Time) - { - // autogenerated points should not replace non-autogenerated. - // this allows for incorrectly ordered timing points to still be correctly handled. - if (newPoint.AutoGenerated && !existing.AutoGenerated) - return; + if (timingChange) + pendingControlPoints.Insert(0, point); + else + pendingControlPoints.Add(point); - beatmap.ControlPointInfo.TimingPoints.Remove(existing); - } - - beatmap.ControlPointInfo.TimingPoints.Add(newPoint); + pendingControlPointsTime = time; } - private void handleDifficultyControlPoint(DifficultyControlPoint newPoint) + private void flushPendingPoints() { - var existing = beatmap.ControlPointInfo.DifficultyPointAt(newPoint.Time); - - if (existing.Time == newPoint.Time) + // Changes from non-timing-points are added to the end of the list (see addControlPoint()) and should override any changes from timing-points (added to the start of the list). + for (int i = pendingControlPoints.Count - 1; i >= 0; i--) { - // autogenerated points should not replace non-autogenerated. - // this allows for incorrectly ordered timing points to still be correctly handled. - if (newPoint.AutoGenerated && !existing.AutoGenerated) - return; + var type = pendingControlPoints[i].GetType(); + if (pendingControlPointTypes.Contains(type)) + continue; - beatmap.ControlPointInfo.DifficultyPoints.Remove(existing); + pendingControlPointTypes.Add(type); + beatmap.ControlPointInfo.Add(pendingControlPointsTime, pendingControlPoints[i]); } - beatmap.ControlPointInfo.DifficultyPoints.Add(newPoint); - } - - private void handleEffectControlPoint(EffectControlPoint newPoint) - { - var existing = beatmap.ControlPointInfo.EffectPointAt(newPoint.Time); - - if (existing.Time == newPoint.Time) - { - // autogenerated points should not replace non-autogenerated. - // this allows for incorrectly ordered timing points to still be correctly handled. - if (newPoint.AutoGenerated && !existing.AutoGenerated) - return; - - beatmap.ControlPointInfo.EffectPoints.Remove(existing); - } - - beatmap.ControlPointInfo.EffectPoints.Add(newPoint); - } - - private void handleSampleControlPoint(SampleControlPoint newPoint) - { - var existing = beatmap.ControlPointInfo.SamplePointAt(newPoint.Time); - - if (existing.Time == newPoint.Time) - { - // autogenerated points should not replace non-autogenerated. - // this allows for incorrectly ordered timing points to still be correctly handled. - if (newPoint.AutoGenerated && !existing.AutoGenerated) - return; - - beatmap.ControlPointInfo.SamplePoints.Remove(existing); - } - - beatmap.ControlPointInfo.SamplePoints.Add(newPoint); + pendingControlPoints.Clear(); + pendingControlPointTypes.Clear(); } private void handleHitObject(string line) { // If the ruleset wasn't specified, assume the osu!standard ruleset. - if (parser == null) - parser = new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion); + parser ??= new Rulesets.Objects.Legacy.Osu.ConvertHitObjectParser(getOffsetTime(), FormatVersion); var obj = parser.Parse(line); if (obj != null) @@ -491,13 +440,5 @@ namespace osu.Game.Beatmaps.Formats private double getOffsetTime(double time) => time + (ApplyOffsets ? offset : 0); protected virtual TimingControlPoint CreateTimingControlPoint() => new TimingControlPoint(); - - [Flags] - internal enum EffectFlags - { - None = 0, - Kiai = 1, - OmitFirstBarLine = 8 - } } } diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs new file mode 100644 index 0000000000..7ddb0b4caa --- /dev/null +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -0,0 +1,483 @@ +// 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.Linq; +using System.Text; +using JetBrains.Annotations; +using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Beatmaps.Formats +{ + public class LegacyBeatmapEncoder + { + public const int LATEST_VERSION = 128; + + private readonly IBeatmap beatmap; + + [CanBeNull] + private readonly ISkin skin; + + /// + /// Creates a new . + /// + /// The beatmap to encode. + /// The beatmap's skin, used for encoding combo colours. + public LegacyBeatmapEncoder(IBeatmap beatmap, [CanBeNull] ISkin skin) + { + this.beatmap = beatmap; + this.skin = skin; + + if (beatmap.BeatmapInfo.RulesetID < 0 || beatmap.BeatmapInfo.RulesetID > 3) + throw new ArgumentException("Only beatmaps in the osu, taiko, catch, or mania rulesets can be encoded to the legacy beatmap format.", nameof(beatmap)); + } + + public void Encode(TextWriter writer) + { + writer.WriteLine($"osu file format v{LATEST_VERSION}"); + + writer.WriteLine(); + handleGeneral(writer); + + writer.WriteLine(); + handleEditor(writer); + + writer.WriteLine(); + handleMetadata(writer); + + writer.WriteLine(); + handleDifficulty(writer); + + writer.WriteLine(); + handleEvents(writer); + + writer.WriteLine(); + handleControlPoints(writer); + + writer.WriteLine(); + handleColours(writer); + + writer.WriteLine(); + handleHitObjects(writer); + } + + private void handleGeneral(TextWriter writer) + { + writer.WriteLine("[General]"); + + if (beatmap.Metadata.AudioFile != null) writer.WriteLine(FormattableString.Invariant($"AudioFilename: {Path.GetFileName(beatmap.Metadata.AudioFile)}")); + writer.WriteLine(FormattableString.Invariant($"AudioLeadIn: {beatmap.BeatmapInfo.AudioLeadIn}")); + writer.WriteLine(FormattableString.Invariant($"PreviewTime: {beatmap.Metadata.PreviewTime}")); + // Todo: Not all countdown types are supported by lazer yet + writer.WriteLine(FormattableString.Invariant($"Countdown: {(beatmap.BeatmapInfo.Countdown ? '1' : '0')}")); + writer.WriteLine(FormattableString.Invariant($"SampleSet: {toLegacySampleBank(beatmap.ControlPointInfo.SamplePointAt(double.MinValue).SampleBank)}")); + writer.WriteLine(FormattableString.Invariant($"StackLeniency: {beatmap.BeatmapInfo.StackLeniency}")); + writer.WriteLine(FormattableString.Invariant($"Mode: {beatmap.BeatmapInfo.RulesetID}")); + writer.WriteLine(FormattableString.Invariant($"LetterboxInBreaks: {(beatmap.BeatmapInfo.LetterboxInBreaks ? '1' : '0')}")); + // if (beatmap.BeatmapInfo.UseSkinSprites) + // writer.WriteLine(@"UseSkinSprites: 1"); + // if (b.AlwaysShowPlayfield) + // writer.WriteLine(@"AlwaysShowPlayfield: 1"); + // if (b.OverlayPosition != OverlayPosition.NoChange) + // writer.WriteLine(@"OverlayPosition: " + b.OverlayPosition); + // if (!string.IsNullOrEmpty(b.SkinPreference)) + // writer.WriteLine(@"SkinPreference:" + b.SkinPreference); + // if (b.EpilepsyWarning) + // writer.WriteLine(@"EpilepsyWarning: 1"); + // if (b.CountdownOffset > 0) + // writer.WriteLine(@"CountdownOffset: " + b.CountdownOffset.ToString()); + if (beatmap.BeatmapInfo.RulesetID == 3) + writer.WriteLine(FormattableString.Invariant($"SpecialStyle: {(beatmap.BeatmapInfo.SpecialStyle ? '1' : '0')}")); + writer.WriteLine(FormattableString.Invariant($"WidescreenStoryboard: {(beatmap.BeatmapInfo.WidescreenStoryboard ? '1' : '0')}")); + // if (b.SamplesMatchPlaybackRate) + // writer.WriteLine(@"SamplesMatchPlaybackRate: 1"); + } + + private void handleEditor(TextWriter writer) + { + writer.WriteLine("[Editor]"); + + if (beatmap.BeatmapInfo.Bookmarks.Length > 0) + writer.WriteLine(FormattableString.Invariant($"Bookmarks: {string.Join(',', beatmap.BeatmapInfo.Bookmarks)}")); + writer.WriteLine(FormattableString.Invariant($"DistanceSpacing: {beatmap.BeatmapInfo.DistanceSpacing}")); + writer.WriteLine(FormattableString.Invariant($"BeatDivisor: {beatmap.BeatmapInfo.BeatDivisor}")); + writer.WriteLine(FormattableString.Invariant($"GridSize: {beatmap.BeatmapInfo.GridSize}")); + writer.WriteLine(FormattableString.Invariant($"TimelineZoom: {beatmap.BeatmapInfo.TimelineZoom}")); + } + + private void handleMetadata(TextWriter writer) + { + writer.WriteLine("[Metadata]"); + + writer.WriteLine(FormattableString.Invariant($"Title: {beatmap.Metadata.Title}")); + if (beatmap.Metadata.TitleUnicode != null) writer.WriteLine(FormattableString.Invariant($"TitleUnicode: {beatmap.Metadata.TitleUnicode}")); + writer.WriteLine(FormattableString.Invariant($"Artist: {beatmap.Metadata.Artist}")); + if (beatmap.Metadata.ArtistUnicode != null) writer.WriteLine(FormattableString.Invariant($"ArtistUnicode: {beatmap.Metadata.ArtistUnicode}")); + writer.WriteLine(FormattableString.Invariant($"Creator: {beatmap.Metadata.AuthorString}")); + writer.WriteLine(FormattableString.Invariant($"Version: {beatmap.BeatmapInfo.Version}")); + if (beatmap.Metadata.Source != null) writer.WriteLine(FormattableString.Invariant($"Source: {beatmap.Metadata.Source}")); + if (beatmap.Metadata.Tags != null) writer.WriteLine(FormattableString.Invariant($"Tags: {beatmap.Metadata.Tags}")); + if (beatmap.BeatmapInfo.OnlineBeatmapID != null) writer.WriteLine(FormattableString.Invariant($"BeatmapID: {beatmap.BeatmapInfo.OnlineBeatmapID}")); + if (beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID != null) writer.WriteLine(FormattableString.Invariant($"BeatmapSetID: {beatmap.BeatmapInfo.BeatmapSet.OnlineBeatmapSetID}")); + } + + private void handleDifficulty(TextWriter writer) + { + writer.WriteLine("[Difficulty]"); + + writer.WriteLine(FormattableString.Invariant($"HPDrainRate: {beatmap.BeatmapInfo.BaseDifficulty.DrainRate}")); + writer.WriteLine(FormattableString.Invariant($"CircleSize: {beatmap.BeatmapInfo.BaseDifficulty.CircleSize}")); + writer.WriteLine(FormattableString.Invariant($"OverallDifficulty: {beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty}")); + writer.WriteLine(FormattableString.Invariant($"ApproachRate: {beatmap.BeatmapInfo.BaseDifficulty.ApproachRate}")); + + // Taiko adjusts the slider multiplier (see: TaikoBeatmapConverter.LEGACY_VELOCITY_MULTIPLIER) + writer.WriteLine(beatmap.BeatmapInfo.RulesetID == 1 + ? FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier / 1.4f}") + : FormattableString.Invariant($"SliderMultiplier: {beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier}")); + + writer.WriteLine(FormattableString.Invariant($"SliderTickRate: {beatmap.BeatmapInfo.BaseDifficulty.SliderTickRate}")); + } + + private void handleEvents(TextWriter writer) + { + writer.WriteLine("[Events]"); + + if (!string.IsNullOrEmpty(beatmap.BeatmapInfo.Metadata.BackgroundFile)) + writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Background},0,\"{beatmap.BeatmapInfo.Metadata.BackgroundFile}\",0,0")); + + foreach (var b in beatmap.Breaks) + writer.WriteLine(FormattableString.Invariant($"{(int)LegacyEventType.Break},{b.StartTime},{b.EndTime}")); + } + + private void handleControlPoints(TextWriter writer) + { + if (beatmap.ControlPointInfo.Groups.Count == 0) + return; + + writer.WriteLine("[TimingPoints]"); + + foreach (var group in beatmap.ControlPointInfo.Groups) + { + var groupTimingPoint = group.ControlPoints.OfType().FirstOrDefault(); + + // If the group contains a timing control point, it needs to be output separately. + if (groupTimingPoint != null) + { + writer.Write(FormattableString.Invariant($"{groupTimingPoint.Time},")); + writer.Write(FormattableString.Invariant($"{groupTimingPoint.BeatLength},")); + outputControlPointEffectsAt(groupTimingPoint.Time, true); + } + + // Output any remaining effects as secondary non-timing control point. + var difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(group.Time); + writer.Write(FormattableString.Invariant($"{group.Time},")); + writer.Write(FormattableString.Invariant($"{-100 / difficultyPoint.SpeedMultiplier},")); + outputControlPointEffectsAt(group.Time, false); + } + + void outputControlPointEffectsAt(double time, bool isTimingPoint) + { + var samplePoint = beatmap.ControlPointInfo.SamplePointAt(time); + var effectPoint = beatmap.ControlPointInfo.EffectPointAt(time); + + // Apply the control point to a hit sample to uncover legacy properties (e.g. suffix) + HitSampleInfo tempHitSample = samplePoint.ApplyTo(new ConvertHitObjectParser.LegacyHitSampleInfo()); + + // Convert effect flags to the legacy format + LegacyEffectFlags effectFlags = LegacyEffectFlags.None; + if (effectPoint.KiaiMode) + effectFlags |= LegacyEffectFlags.Kiai; + if (effectPoint.OmitFirstBarLine) + effectFlags |= LegacyEffectFlags.OmitFirstBarLine; + + writer.Write(FormattableString.Invariant($"{(int)beatmap.ControlPointInfo.TimingPointAt(time).TimeSignature},")); + writer.Write(FormattableString.Invariant($"{(int)toLegacySampleBank(tempHitSample.Bank)},")); + writer.Write(FormattableString.Invariant($"{toLegacyCustomSampleBank(tempHitSample)},")); + writer.Write(FormattableString.Invariant($"{tempHitSample.Volume},")); + writer.Write(FormattableString.Invariant($"{(isTimingPoint ? '1' : '0')},")); + writer.Write(FormattableString.Invariant($"{(int)effectFlags}")); + writer.WriteLine(); + } + } + + private void handleColours(TextWriter writer) + { + var colours = skin?.GetConfig>(GlobalSkinColours.ComboColours)?.Value; + + if (colours == null || colours.Count == 0) + return; + + writer.WriteLine("[Colours]"); + + for (var i = 0; i < colours.Count; i++) + { + var comboColour = colours[i]; + + writer.Write(FormattableString.Invariant($"Combo{i}: ")); + writer.Write(FormattableString.Invariant($"{(byte)(comboColour.R * byte.MaxValue)},")); + writer.Write(FormattableString.Invariant($"{(byte)(comboColour.G * byte.MaxValue)},")); + writer.Write(FormattableString.Invariant($"{(byte)(comboColour.B * byte.MaxValue)},")); + writer.Write(FormattableString.Invariant($"{(byte)(comboColour.A * byte.MaxValue)}")); + writer.WriteLine(); + } + } + + private void handleHitObjects(TextWriter writer) + { + writer.WriteLine("[HitObjects]"); + + if (beatmap.HitObjects.Count == 0) + return; + + foreach (var h in beatmap.HitObjects) + handleHitObject(writer, h); + } + + private void handleHitObject(TextWriter writer, HitObject hitObject) + { + Vector2 position = new Vector2(256, 192); + + switch (beatmap.BeatmapInfo.RulesetID) + { + case 0: + position = ((IHasPosition)hitObject).Position; + break; + + case 2: + position.X = ((IHasXPosition)hitObject).X; + break; + + case 3: + int totalColumns = (int)Math.Max(1, beatmap.BeatmapInfo.BaseDifficulty.CircleSize); + position.X = (int)Math.Ceiling(((IHasXPosition)hitObject).X * (512f / totalColumns)); + break; + } + + writer.Write(FormattableString.Invariant($"{position.X},")); + writer.Write(FormattableString.Invariant($"{position.Y},")); + writer.Write(FormattableString.Invariant($"{hitObject.StartTime},")); + writer.Write(FormattableString.Invariant($"{(int)getObjectType(hitObject)},")); + writer.Write(FormattableString.Invariant($"{(int)toLegacyHitSoundType(hitObject.Samples)},")); + + if (hitObject is IHasPath path) + { + addPathData(writer, path, position); + writer.Write(getSampleBank(hitObject.Samples, zeroBanks: true)); + } + else + { + if (hitObject is IHasDuration) + addEndTimeData(writer, hitObject); + + writer.Write(getSampleBank(hitObject.Samples)); + } + + writer.WriteLine(); + } + + private LegacyHitObjectType getObjectType(HitObject hitObject) + { + LegacyHitObjectType type = 0; + + if (hitObject is IHasCombo combo) + { + type = (LegacyHitObjectType)(combo.ComboOffset << 4); + + if (combo.NewCombo) + type |= LegacyHitObjectType.NewCombo; + } + + switch (hitObject) + { + case IHasPath _: + type |= LegacyHitObjectType.Slider; + break; + + case IHasDuration _: + if (beatmap.BeatmapInfo.RulesetID == 3) + type |= LegacyHitObjectType.Hold; + else + type |= LegacyHitObjectType.Spinner; + break; + + default: + type |= LegacyHitObjectType.Circle; + break; + } + + return type; + } + + private void addPathData(TextWriter writer, IHasPath pathData, Vector2 position) + { + PathType? lastType = null; + + for (int i = 0; i < pathData.Path.ControlPoints.Count; i++) + { + PathControlPoint point = pathData.Path.ControlPoints[i]; + + if (point.Type.Value != null) + { + if (point.Type.Value != lastType) + { + switch (point.Type.Value) + { + case PathType.Bezier: + writer.Write("B|"); + break; + + case PathType.Catmull: + writer.Write("C|"); + break; + + case PathType.PerfectCurve: + writer.Write("P|"); + break; + + case PathType.Linear: + writer.Write("L|"); + break; + } + + lastType = point.Type.Value; + } + else + { + // New segment with the same type - duplicate the control point + writer.Write(FormattableString.Invariant($"{position.X + point.Position.Value.X}:{position.Y + point.Position.Value.Y}|")); + } + } + + if (i != 0) + { + writer.Write(FormattableString.Invariant($"{position.X + point.Position.Value.X}:{position.Y + point.Position.Value.Y}")); + writer.Write(i != pathData.Path.ControlPoints.Count - 1 ? "|" : ","); + } + } + + var curveData = pathData as IHasPathWithRepeats; + + writer.Write(FormattableString.Invariant($"{(curveData?.RepeatCount ?? 0) + 1},")); + writer.Write(FormattableString.Invariant($"{pathData.Path.Distance},")); + + if (curveData != null) + { + for (int i = 0; i < curveData.NodeSamples.Count; i++) + { + writer.Write(FormattableString.Invariant($"{(int)toLegacyHitSoundType(curveData.NodeSamples[i])}")); + writer.Write(i != curveData.NodeSamples.Count - 1 ? "|" : ","); + } + + for (int i = 0; i < curveData.NodeSamples.Count; i++) + { + writer.Write(getSampleBank(curveData.NodeSamples[i], true)); + writer.Write(i != curveData.NodeSamples.Count - 1 ? "|" : ","); + } + } + } + + private void addEndTimeData(TextWriter writer, HitObject hitObject) + { + var endTimeData = (IHasDuration)hitObject; + var type = getObjectType(hitObject); + + char suffix = ','; + + // Holds write the end time as if it's part of sample data. + if (type == LegacyHitObjectType.Hold) + suffix = ':'; + + writer.Write(FormattableString.Invariant($"{endTimeData.EndTime}{suffix}")); + } + + private string getSampleBank(IList samples, bool banksOnly = false, bool zeroBanks = 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)}")); + + if (!banksOnly) + { + string customSampleBank = toLegacyCustomSampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name))); + string sampleFilename = samples.FirstOrDefault(s => string.IsNullOrEmpty(s.Name))?.LookupNames.First() ?? string.Empty; + int volume = samples.FirstOrDefault()?.Volume ?? 100; + + sb.Append(':'); + sb.Append(FormattableString.Invariant($"{customSampleBank}:")); + sb.Append(FormattableString.Invariant($"{volume}:")); + sb.Append(FormattableString.Invariant($"{sampleFilename}")); + } + + return sb.ToString(); + } + + private LegacyHitSoundType toLegacyHitSoundType(IList samples) + { + LegacyHitSoundType type = LegacyHitSoundType.None; + + foreach (var sample in samples) + { + switch (sample.Name) + { + case HitSampleInfo.HIT_WHISTLE: + type |= LegacyHitSoundType.Whistle; + break; + + case HitSampleInfo.HIT_FINISH: + type |= LegacyHitSoundType.Finish; + break; + + case HitSampleInfo.HIT_CLAP: + type |= LegacyHitSoundType.Clap; + break; + } + } + + return type; + } + + private LegacySampleBank toLegacySampleBank(string sampleBank) + { + switch (sampleBank?.ToLowerInvariant()) + { + case "normal": + return LegacySampleBank.Normal; + + case "soft": + return LegacySampleBank.Soft; + + case "drum": + return LegacySampleBank.Drum; + + default: + return LegacySampleBank.None; + } + } + + private string toLegacyCustomSampleBank(HitSampleInfo hitSampleInfo) + { + if (hitSampleInfo == null) + return "0"; + + if (hitSampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy) + return legacy.CustomSampleBank.ToString(CultureInfo.InvariantCulture); + + return "0"; + } + } +} diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 7999c82761..7b377e481f 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; -using System.IO; +using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.IO; +using osu.Game.Rulesets.Objects.Legacy; using osuTK.Graphics; namespace osu.Game.Beatmaps.Formats @@ -21,7 +23,7 @@ namespace osu.Game.Beatmaps.Formats FormatVersion = version; } - protected override void ParseStreamInto(StreamReader stream, T output) + protected override void ParseStreamInto(LineBufferedReader stream, T output) { Section section = Section.None; @@ -32,14 +34,15 @@ namespace osu.Game.Beatmaps.Formats if (ShouldSkipLine(line)) continue; - if (line.StartsWith(@"[", StringComparison.Ordinal) && line.EndsWith(@"]", StringComparison.Ordinal)) + if (line.StartsWith('[') && line.EndsWith(']')) { - if (!Enum.TryParse(line.Substring(1, line.Length - 2), out section)) + if (!Enum.TryParse(line[1..^1], out section)) { - Logger.Log($"Unknown section \"{line}\" in {output}"); + Logger.Log($"Unknown section \"{line}\" in \"{output}\""); section = Section.None; } + OnBeginNewSection(section); continue; } @@ -49,13 +52,21 @@ namespace osu.Game.Beatmaps.Formats } catch (Exception e) { - Logger.Error(e, $"Failed to process line \"{line}\" into {output}"); + Logger.Log($"Failed to process line \"{line}\" into \"{output}\": {e.Message}", LoggingTarget.Runtime, LogLevel.Important); } } } protected virtual bool ShouldSkipLine(string line) => string.IsNullOrWhiteSpace(line) || line.AsSpan().TrimStart().StartsWith("//".AsSpan(), StringComparison.Ordinal); + /// + /// Invoked when a new has been entered. + /// + /// The entered . + protected virtual void OnBeginNewSection(Section section) + { + } + protected virtual void ParseLine(T output, Section section, string line) { line = StripComments(line); @@ -63,7 +74,7 @@ namespace osu.Game.Beatmaps.Formats switch (section) { case Section.Colours: - handleColours(output, line); + HandleColours(output, line); return; } } @@ -77,13 +88,11 @@ namespace osu.Game.Beatmaps.Formats return line; } - private bool hasComboColours; - - private void handleColours(T output, string line) + protected void HandleColours(TModel output, string line) { var pair = SplitKeyVal(line); - bool isCombo = pair.Key.StartsWith(@"Combo"); + bool isCombo = pair.Key.StartsWith(@"Combo", StringComparison.Ordinal); string[] split = pair.Value.Split(','); @@ -94,7 +103,8 @@ namespace osu.Game.Beatmaps.Formats try { - colour = new Color4(byte.Parse(split[0]), byte.Parse(split[1]), byte.Parse(split[2]), split.Length == 4 ? byte.Parse(split[3]) : (byte)255); + byte alpha = split.Length == 4 ? byte.Parse(split[3]) : (byte)255; + colour = new Color4(byte.Parse(split[0]), byte.Parse(split[1]), byte.Parse(split[2]), alpha); } catch { @@ -105,14 +115,7 @@ namespace osu.Game.Beatmaps.Formats { if (!(output is IHasComboColours tHasComboColours)) return; - if (!hasComboColours) - { - // remove default colours. - tHasComboColours.ComboColours.Clear(); - hasComboColours = true; - } - - tHasComboColours.ComboColours.Add(colour); + tHasComboColours.AddComboColours(colour); } else { @@ -124,7 +127,7 @@ namespace osu.Game.Beatmaps.Formats protected KeyValuePair SplitKeyVal(string line, char separator = ':') { - var split = line.Trim().Split(new[] { separator }, 2); + var split = line.Split(separator, 2); return new KeyValuePair ( @@ -133,6 +136,8 @@ namespace osu.Game.Beatmaps.Formats ); } + protected string CleanFilename(string path) => path.Trim('"').ToStandardisedPath(); + protected enum Section { None, @@ -145,51 +150,29 @@ namespace osu.Game.Beatmaps.Formats Colours, HitObjects, Variables, - Fonts + Fonts, + CatchTheBeat, + Mania, } - internal enum LegacySampleBank + [Obsolete("Do not use unless you're a legacy ruleset and 100% sure.")] + public class LegacyDifficultyControlPoint : DifficultyControlPoint { - None = 0, - Normal = 1, - Soft = 2, - Drum = 3 + /// + /// Legacy BPM multiplier that introduces floating-point errors for rulesets that depend on it. + /// DO NOT USE THIS UNLESS 100% SURE. + /// + public readonly float BpmMultiplier; + + public LegacyDifficultyControlPoint(double beatLength) + { + SpeedMultiplierBindable.Precision = double.Epsilon; + + BpmMultiplier = beatLength < 0 ? Math.Clamp((float)-beatLength, 10, 10000) / 100f : 1; + } } - internal enum EventType - { - Background = 0, - Video = 1, - Break = 2, - Colour = 3, - Sprite = 4, - Sample = 5, - Animation = 6 - } - - internal enum LegacyOrigins - { - TopLeft, - Centre, - CentreLeft, - TopRight, - BottomCentre, - TopCentre, - Custom, - CentreRight, - BottomLeft, - BottomRight - } - - internal enum StoryLayer - { - Background = 0, - Fail = 1, - Pass = 2, - Foreground = 3 - } - - internal class LegacySampleControlPoint : SampleControlPoint, IEquatable + internal class LegacySampleControlPoint : SampleControlPoint { public int CustomSampleBank; @@ -197,15 +180,19 @@ namespace osu.Game.Beatmaps.Formats { var baseInfo = base.ApplyTo(hitSampleInfo); - if (string.IsNullOrEmpty(baseInfo.Suffix) && CustomSampleBank > 1) - baseInfo.Suffix = CustomSampleBank.ToString(); + if (baseInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy + && legacy.CustomSampleBank == 0) + { + legacy.CustomSampleBank = CustomSampleBank; + } return baseInfo; } - public bool Equals(LegacySampleControlPoint other) - => base.Equals(other) - && CustomSampleBank == other?.CustomSampleBank; + public override bool IsRedundant(ControlPoint existing) + => base.IsRedundant(existing) + && existing is LegacySampleControlPoint existingSample + && CustomSampleBank == existingSample.CustomSampleBank; } } } diff --git a/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs index 540f616ea9..3420fcf260 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDifficultyCalculatorBeatmapDecoder.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Beatmaps.Formats { @@ -24,14 +23,7 @@ namespace osu.Game.Beatmaps.Formats public new static void Register() { AddDecoder(@"osu file format v", m => new LegacyDifficultyCalculatorBeatmapDecoder(int.Parse(m.Split('v').Last()))); - } - - protected override TimingControlPoint CreateTimingControlPoint() - => new LegacyDifficultyCalculatorControlPoint(); - - private class LegacyDifficultyCalculatorControlPoint : TimingControlPoint - { - public override double BeatLength { get; set; } = 1000; + SetFallbackDecoder(() => new LegacyDifficultyCalculatorBeatmapDecoder()); } } } diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs index f6e2bf6966..8d8ca523d5 100644 --- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs @@ -3,14 +3,14 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; -using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Beatmaps.Legacy; +using osu.Game.IO; +using osu.Game.Storyboards; using osuTK; using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.IO.File; -using osu.Game.Storyboards; namespace osu.Game.Beatmaps.Formats { @@ -33,16 +33,13 @@ namespace osu.Game.Beatmaps.Formats // note that this isn't completely correct AddDecoder(@"osu file format v", m => new LegacyStoryboardDecoder()); AddDecoder(@"[Events]", m => new LegacyStoryboardDecoder()); + SetFallbackDecoder(() => new LegacyStoryboardDecoder()); } - protected override void ParseStreamInto(StreamReader stream, Storyboard storyboard) + protected override void ParseStreamInto(LineBufferedReader stream, Storyboard storyboard) { this.storyboard = storyboard; base.ParseStreamInto(stream, storyboard); - - // OrderBy is used to guarantee that the parsing order of elements with equal start times is maintained (stably-sorted) - foreach (StoryboardLayer layer in storyboard.Layers) - layer.Elements = layer.Elements.OrderBy(h => h.StartTime).ToList(); } protected override void ParseLine(Storyboard storyboard, Section section, string line) @@ -51,6 +48,10 @@ namespace osu.Game.Beatmaps.Formats switch (section) { + case Section.General: + handleGeneral(storyboard, line); + return; + case Section.Events: handleEvents(line); return; @@ -63,16 +64,32 @@ namespace osu.Game.Beatmaps.Formats base.ParseLine(storyboard, section, line); } + private void handleGeneral(Storyboard storyboard, string line) + { + var pair = SplitKeyVal(line); + + switch (pair.Key) + { + case "UseSkinSprites": + storyboard.UseSkinSprites = pair.Value == "1"; + break; + } + } + private void handleEvents(string line) { var depth = 0; - while (line.StartsWith(" ", StringComparison.Ordinal) || line.StartsWith("_", StringComparison.Ordinal)) + foreach (char c in line) { - ++depth; - line = line.Substring(1); + if (c == ' ' || c == '_') + depth++; + else + break; } + line = line.Substring(depth); + decodeVariables(ref line); string[] split = line.Split(','); @@ -81,46 +98,54 @@ namespace osu.Game.Beatmaps.Formats { storyboardSprite = null; - EventType type; - if (!Enum.TryParse(split[0], out type)) - throw new InvalidDataException($@"Unknown event type {split[0]}"); + if (!Enum.TryParse(split[0], out LegacyEventType type)) + throw new InvalidDataException($@"Unknown event type: {split[0]}"); switch (type) { - case EventType.Sprite: + case LegacyEventType.Video: + { + var offset = Parsing.ParseInt(split[1]); + var path = CleanFilename(split[2]); + + storyboard.GetLayer("Video").Add(new StoryboardVideo(path, offset)); + break; + } + + case LegacyEventType.Sprite: { var layer = parseLayer(split[1]); var origin = parseOrigin(split[2]); - var path = cleanFilename(split[3]); - var x = float.Parse(split[4], NumberFormatInfo.InvariantInfo); - var y = float.Parse(split[5], NumberFormatInfo.InvariantInfo); + var path = CleanFilename(split[3]); + var x = Parsing.ParseFloat(split[4], Parsing.MAX_COORDINATE_VALUE); + var y = Parsing.ParseFloat(split[5], Parsing.MAX_COORDINATE_VALUE); storyboardSprite = new StoryboardSprite(path, origin, new Vector2(x, y)); storyboard.GetLayer(layer).Add(storyboardSprite); break; } - case EventType.Animation: + case LegacyEventType.Animation: { var layer = parseLayer(split[1]); var origin = parseOrigin(split[2]); - var path = cleanFilename(split[3]); - var x = float.Parse(split[4], NumberFormatInfo.InvariantInfo); - var y = float.Parse(split[5], NumberFormatInfo.InvariantInfo); - var frameCount = int.Parse(split[6]); - var frameDelay = double.Parse(split[7], NumberFormatInfo.InvariantInfo); + var path = CleanFilename(split[3]); + var x = Parsing.ParseFloat(split[4], Parsing.MAX_COORDINATE_VALUE); + var y = Parsing.ParseFloat(split[5], Parsing.MAX_COORDINATE_VALUE); + var frameCount = Parsing.ParseInt(split[6]); + var frameDelay = Parsing.ParseDouble(split[7]); var loopType = split.Length > 8 ? (AnimationLoopType)Enum.Parse(typeof(AnimationLoopType), split[8]) : AnimationLoopType.LoopForever; storyboardSprite = new StoryboardAnimation(path, origin, new Vector2(x, y), frameCount, frameDelay, loopType); storyboard.GetLayer(layer).Add(storyboardSprite); break; } - case EventType.Sample: + case LegacyEventType.Sample: { - var time = double.Parse(split[1], CultureInfo.InvariantCulture); + var time = Parsing.ParseDouble(split[1]); var layer = parseLayer(split[2]); - var path = cleanFilename(split[3]); - var volume = split.Length > 4 ? float.Parse(split[4], CultureInfo.InvariantCulture) : 100; - storyboard.GetLayer(layer).Add(new StoryboardSample(path, time, volume)); + var path = CleanFilename(split[3]); + var volume = split.Length > 4 ? Parsing.ParseFloat(split[4]) : 100; + storyboard.GetLayer(layer).Add(new StoryboardSampleInfo(path, time, (int)volume)); break; } } @@ -137,106 +162,106 @@ namespace osu.Game.Beatmaps.Formats case "T": { var triggerName = split[1]; - var startTime = split.Length > 2 ? double.Parse(split[2], CultureInfo.InvariantCulture) : double.MinValue; - var endTime = split.Length > 3 ? double.Parse(split[3], CultureInfo.InvariantCulture) : double.MaxValue; - var groupNumber = split.Length > 4 ? int.Parse(split[4]) : 0; + var startTime = split.Length > 2 ? Parsing.ParseDouble(split[2]) : double.MinValue; + var endTime = split.Length > 3 ? Parsing.ParseDouble(split[3]) : double.MaxValue; + var groupNumber = split.Length > 4 ? Parsing.ParseInt(split[4]) : 0; timelineGroup = storyboardSprite?.AddTrigger(triggerName, startTime, endTime, groupNumber); - } break; + } case "L": { - var startTime = double.Parse(split[1], CultureInfo.InvariantCulture); - var loopCount = int.Parse(split[2]); + var startTime = Parsing.ParseDouble(split[1]); + var loopCount = Parsing.ParseInt(split[2]); timelineGroup = storyboardSprite?.AddLoop(startTime, loopCount); - } break; + } default: { if (string.IsNullOrEmpty(split[3])) split[3] = split[2]; - var easing = (Easing)int.Parse(split[1]); - var startTime = double.Parse(split[2], CultureInfo.InvariantCulture); - var endTime = double.Parse(split[3], CultureInfo.InvariantCulture); + var easing = (Easing)Parsing.ParseInt(split[1]); + var startTime = Parsing.ParseDouble(split[2]); + var endTime = Parsing.ParseDouble(split[3]); switch (commandType) { case "F": { - var startValue = float.Parse(split[4], CultureInfo.InvariantCulture); - var endValue = split.Length > 5 ? float.Parse(split[5], CultureInfo.InvariantCulture) : startValue; + var startValue = Parsing.ParseFloat(split[4]); + var endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; timelineGroup?.Alpha.Add(easing, startTime, endTime, startValue, endValue); - } break; + } case "S": { - var startValue = float.Parse(split[4], CultureInfo.InvariantCulture); - var endValue = split.Length > 5 ? float.Parse(split[5], CultureInfo.InvariantCulture) : startValue; - timelineGroup?.Scale.Add(easing, startTime, endTime, new Vector2(startValue), new Vector2(endValue)); - } + var startValue = Parsing.ParseFloat(split[4]); + var endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; + timelineGroup?.Scale.Add(easing, startTime, endTime, startValue, endValue); break; + } case "V": { - var startX = float.Parse(split[4], CultureInfo.InvariantCulture); - var startY = float.Parse(split[5], CultureInfo.InvariantCulture); - var endX = split.Length > 6 ? float.Parse(split[6], CultureInfo.InvariantCulture) : startX; - var endY = split.Length > 7 ? float.Parse(split[7], CultureInfo.InvariantCulture) : startY; - timelineGroup?.Scale.Add(easing, startTime, endTime, new Vector2(startX, startY), new Vector2(endX, endY)); - } + var startX = Parsing.ParseFloat(split[4]); + var startY = Parsing.ParseFloat(split[5]); + var endX = split.Length > 6 ? Parsing.ParseFloat(split[6]) : startX; + var endY = split.Length > 7 ? Parsing.ParseFloat(split[7]) : startY; + timelineGroup?.VectorScale.Add(easing, startTime, endTime, new Vector2(startX, startY), new Vector2(endX, endY)); break; + } case "R": { - var startValue = float.Parse(split[4], CultureInfo.InvariantCulture); - var endValue = split.Length > 5 ? float.Parse(split[5], CultureInfo.InvariantCulture) : startValue; - timelineGroup?.Rotation.Add(easing, startTime, endTime, MathHelper.RadiansToDegrees(startValue), MathHelper.RadiansToDegrees(endValue)); - } + var startValue = Parsing.ParseFloat(split[4]); + var endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; + timelineGroup?.Rotation.Add(easing, startTime, endTime, MathUtils.RadiansToDegrees(startValue), MathUtils.RadiansToDegrees(endValue)); break; + } case "M": { - var startX = float.Parse(split[4], CultureInfo.InvariantCulture); - var startY = float.Parse(split[5], CultureInfo.InvariantCulture); - var endX = split.Length > 6 ? float.Parse(split[6], CultureInfo.InvariantCulture) : startX; - var endY = split.Length > 7 ? float.Parse(split[7], CultureInfo.InvariantCulture) : startY; + var startX = Parsing.ParseFloat(split[4]); + var startY = Parsing.ParseFloat(split[5]); + var endX = split.Length > 6 ? Parsing.ParseFloat(split[6]) : startX; + var endY = split.Length > 7 ? Parsing.ParseFloat(split[7]) : startY; timelineGroup?.X.Add(easing, startTime, endTime, startX, endX); timelineGroup?.Y.Add(easing, startTime, endTime, startY, endY); - } break; + } case "MX": { - var startValue = float.Parse(split[4], CultureInfo.InvariantCulture); - var endValue = split.Length > 5 ? float.Parse(split[5], CultureInfo.InvariantCulture) : startValue; + var startValue = Parsing.ParseFloat(split[4]); + var endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; timelineGroup?.X.Add(easing, startTime, endTime, startValue, endValue); - } break; + } case "MY": { - var startValue = float.Parse(split[4], CultureInfo.InvariantCulture); - var endValue = split.Length > 5 ? float.Parse(split[5], CultureInfo.InvariantCulture) : startValue; + var startValue = Parsing.ParseFloat(split[4]); + var endValue = split.Length > 5 ? Parsing.ParseFloat(split[5]) : startValue; timelineGroup?.Y.Add(easing, startTime, endTime, startValue, endValue); - } break; + } case "C": { - var startRed = float.Parse(split[4], CultureInfo.InvariantCulture); - var startGreen = float.Parse(split[5], CultureInfo.InvariantCulture); - var startBlue = float.Parse(split[6], CultureInfo.InvariantCulture); - var endRed = split.Length > 7 ? float.Parse(split[7], CultureInfo.InvariantCulture) : startRed; - var endGreen = split.Length > 8 ? float.Parse(split[8], CultureInfo.InvariantCulture) : startGreen; - var endBlue = split.Length > 9 ? float.Parse(split[9], CultureInfo.InvariantCulture) : startBlue; + var startRed = Parsing.ParseFloat(split[4]); + var startGreen = Parsing.ParseFloat(split[5]); + var startBlue = Parsing.ParseFloat(split[6]); + var endRed = split.Length > 7 ? Parsing.ParseFloat(split[7]) : startRed; + var endGreen = split.Length > 8 ? Parsing.ParseFloat(split[8]) : startGreen; + var endBlue = split.Length > 9 ? Parsing.ParseFloat(split[9]) : startBlue; timelineGroup?.Colour.Add(easing, startTime, endTime, new Color4(startRed / 255f, startGreen / 255f, startBlue / 255f, 1), new Color4(endRed / 255f, endGreen / 255f, endBlue / 255f, 1)); - } break; + } case "P": { @@ -245,7 +270,7 @@ namespace osu.Game.Beatmaps.Formats switch (type) { case "A": - timelineGroup?.BlendingMode.Add(easing, startTime, endTime, BlendingMode.Additive, startTime == endTime ? BlendingMode.Additive : BlendingMode.Inherit); + timelineGroup?.BlendingParameters.Add(easing, startTime, endTime, BlendingParameters.Additive, startTime == endTime ? BlendingParameters.Additive : BlendingParameters.Inherit); break; case "H": @@ -256,19 +281,21 @@ namespace osu.Game.Beatmaps.Formats timelineGroup?.FlipV.Add(easing, startTime, endTime, true, startTime == endTime); break; } - } + break; + } default: throw new InvalidDataException($@"Unknown command type: {commandType}"); } - } + break; + } } } } - private string parseLayer(string value) => Enum.Parse(typeof(StoryLayer), value).ToString(); + private string parseLayer(string value) => Enum.Parse(typeof(LegacyStoryLayer), value).ToString(); private Anchor parseOrigin(string value) { @@ -320,7 +347,7 @@ namespace osu.Game.Beatmaps.Formats /// The line which may contains variables. private void decodeVariables(ref string line) { - while (line.IndexOf('$') >= 0) + while (line.Contains('$')) { string origLine = line; @@ -331,7 +358,5 @@ namespace osu.Game.Beatmaps.Formats break; } } - - private string cleanFilename(string path) => FileSafety.PathStandardise(path.Trim('"')); } } diff --git a/osu.Game/Beatmaps/Formats/Parsing.cs b/osu.Game/Beatmaps/Formats/Parsing.cs index c3efb8c760..c4795a6931 100644 --- a/osu.Game/Beatmaps/Formats/Parsing.cs +++ b/osu.Game/Beatmaps/Formats/Parsing.cs @@ -11,7 +11,7 @@ namespace osu.Game.Beatmaps.Formats /// public static class Parsing { - public const int MAX_COORDINATE_VALUE = 65536; + public const int MAX_COORDINATE_VALUE = 131072; public const double MAX_PARSE_VALUE = int.MaxValue; diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index 512fe25809..8f27e0b0e9 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -53,4 +53,13 @@ namespace osu.Game.Beatmaps /// The shallow-cloned beatmap. IBeatmap Clone(); } + + public interface IBeatmap : IBeatmap + where T : HitObject + { + /// + /// The hitobjects contained by this beatmap. + /// + new IReadOnlyList HitObjects { get; } + } } diff --git a/osu.Game/Beatmaps/IBeatmapConverter.cs b/osu.Game/Beatmaps/IBeatmapConverter.cs index f2213b85f1..2833af8ca2 100644 --- a/osu.Game/Beatmaps/IBeatmapConverter.cs +++ b/osu.Game/Beatmaps/IBeatmapConverter.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Threading; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; @@ -25,11 +26,13 @@ namespace osu.Game.Beatmaps /// /// Whether can be converted by this . /// - bool CanConvert { get; } + bool CanConvert(); /// /// Converts . /// - IBeatmap Convert(); + /// The cancellation token. + /// The converted Beatmap. + IBeatmap Convert(CancellationToken cancellationToken = default); } } diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs new file mode 100644 index 0000000000..bcd94d76fd --- /dev/null +++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs @@ -0,0 +1,71 @@ +// 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.Framework.Audio.Track; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Skinning; +using osu.Game.Storyboards; + +namespace osu.Game.Beatmaps +{ + public interface IWorkingBeatmap + { + /// + /// Retrieves the which this represents. + /// + IBeatmap Beatmap { get; } + + /// + /// Retrieves the background for this . + /// + Texture Background { get; } + + /// + /// Retrieves the for the of this . + /// + Waveform Waveform { get; } + + /// + /// Retrieves the which this provides. + /// + Storyboard Storyboard { get; } + + /// + /// Retrieves the which this provides. + /// + ISkin Skin { get; } + + /// + /// Constructs a playable from using the applicable converters for a specific . + /// + /// The returned is in a playable state - all and s + /// have been applied, and s have been fully constructed. + /// + /// + /// The to create a playable for. + /// The s to apply to the . + /// The maximum length in milliseconds to wait for load to complete. Defaults to 10,000ms. + /// The converted . + /// If could not be converted to . + IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods = null, TimeSpan? timeout = null); + + /// + /// Load a new audio track instance for this beatmap. This should be called once before accessing . + /// The caller of this method is responsible for the lifetime of the track. + /// + /// + /// In a standard game context, the loading of the track is managed solely by MusicController, which will + /// automatically load the track of the current global IBindable WorkingBeatmap. + /// As such, this method should only be called in very special scenarios, such as external tests or apps which are + /// outside of the game context. + /// + /// A fresh track instance, which will also be available via . + Track LoadTrack(); + } +} diff --git a/osu.Game/Beatmaps/Legacy/LegacyEffectFlags.cs b/osu.Game/Beatmaps/Legacy/LegacyEffectFlags.cs new file mode 100644 index 0000000000..5bf80c34d7 --- /dev/null +++ b/osu.Game/Beatmaps/Legacy/LegacyEffectFlags.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Beatmaps.Legacy +{ + [Flags] + internal enum LegacyEffectFlags + { + None = 0, + Kiai = 1, + OmitFirstBarLine = 8 + } +} diff --git a/osu.Game/Beatmaps/Legacy/LegacyEventType.cs b/osu.Game/Beatmaps/Legacy/LegacyEventType.cs new file mode 100644 index 0000000000..32a7122978 --- /dev/null +++ b/osu.Game/Beatmaps/Legacy/LegacyEventType.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. + +namespace osu.Game.Beatmaps.Legacy +{ + internal enum LegacyEventType + { + Background = 0, + Video = 1, + Break = 2, + Colour = 3, + Sprite = 4, + Sample = 5, + Animation = 6 + } +} diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectType.cs b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs similarity index 58% rename from osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectType.cs rename to osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs index c9f7224643..ec9839b893 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectType.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyHitObjectType.cs @@ -1,14 +1,14 @@ -// 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; -namespace osu.Game.Rulesets.Objects.Legacy +namespace osu.Game.Beatmaps.Legacy { [Flags] - internal enum ConvertHitObjectType + internal enum LegacyHitObjectType { - Circle = 1 << 0, + Circle = 1, Slider = 1 << 1, NewCombo = 1 << 2, Spinner = 1 << 3, diff --git a/osu.Game/Beatmaps/Legacy/LegacyHitSoundType.cs b/osu.Game/Beatmaps/Legacy/LegacyHitSoundType.cs new file mode 100644 index 0000000000..d7743565f8 --- /dev/null +++ b/osu.Game/Beatmaps/Legacy/LegacyHitSoundType.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; + +namespace osu.Game.Beatmaps.Legacy +{ + [Flags] + internal enum LegacyHitSoundType + { + None = 0, + Normal = 1, + Whistle = 2, + Finish = 4, + Clap = 8 + } +} diff --git a/osu.Game/Beatmaps/Legacy/LegacyMods.cs b/osu.Game/Beatmaps/Legacy/LegacyMods.cs index 8e53c24e7b..0e517ea3df 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyMods.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyMods.cs @@ -9,7 +9,7 @@ namespace osu.Game.Beatmaps.Legacy public enum LegacyMods { None = 0, - NoFail = 1 << 0, + NoFail = 1, Easy = 1 << 1, TouchDevice = 1 << 2, Hidden = 1 << 3, @@ -38,5 +38,6 @@ namespace osu.Game.Beatmaps.Legacy Key1 = 1 << 26, Key3 = 1 << 27, Key2 = 1 << 28, + Mirror = 1 << 30, } } diff --git a/osu.Game/Beatmaps/Legacy/LegacyOrigins.cs b/osu.Game/Beatmaps/Legacy/LegacyOrigins.cs new file mode 100644 index 0000000000..31f67d6dfd --- /dev/null +++ b/osu.Game/Beatmaps/Legacy/LegacyOrigins.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. + +namespace osu.Game.Beatmaps.Legacy +{ + internal enum LegacyOrigins + { + TopLeft, + Centre, + CentreLeft, + TopRight, + BottomCentre, + TopCentre, + Custom, + CentreRight, + BottomLeft, + BottomRight + } +} diff --git a/osu.Game/Beatmaps/Legacy/LegacySampleBank.cs b/osu.Game/Beatmaps/Legacy/LegacySampleBank.cs new file mode 100644 index 0000000000..8cac29cb87 --- /dev/null +++ b/osu.Game/Beatmaps/Legacy/LegacySampleBank.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. + +namespace osu.Game.Beatmaps.Legacy +{ + internal enum LegacySampleBank + { + None = 0, + Normal = 1, + Soft = 2, + Drum = 3 + } +} diff --git a/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.cs b/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.cs new file mode 100644 index 0000000000..ea23c49c4a --- /dev/null +++ b/osu.Game/Beatmaps/Legacy/LegacyStoryLayer.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.Beatmaps.Legacy +{ + internal enum LegacyStoryLayer + { + Background = 0, + Fail = 1, + Pass = 2, + Foreground = 3, + Overlay = 4, + Video = 5 + } +} diff --git a/osu.Game/Beatmaps/StarDifficulty.cs b/osu.Game/Beatmaps/StarDifficulty.cs new file mode 100644 index 0000000000..f438b6f0bc --- /dev/null +++ b/osu.Game/Beatmaps/StarDifficulty.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 JetBrains.Annotations; +using osu.Game.Rulesets.Difficulty; + +namespace osu.Game.Beatmaps +{ + public readonly struct StarDifficulty + { + /// + /// The star difficulty rating for the given beatmap. + /// + public readonly double Stars; + + /// + /// The maximum combo achievable on the given beatmap. + /// + public readonly int MaxCombo; + + /// + /// The difficulty attributes computed for the given beatmap. + /// Might not be available if the star difficulty is associated with a beatmap that's not locally available. + /// + [CanBeNull] + public readonly DifficultyAttributes Attributes; + + /// + /// Creates a structure based on computed + /// by a . + /// + public StarDifficulty([NotNull] DifficultyAttributes attributes) + { + Stars = attributes.StarRating; + MaxCombo = attributes.MaxCombo; + Attributes = attributes; + // Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...) + } + + /// + /// Creates a structure with a pre-populated star difficulty and max combo + /// in scenarios where computing is not feasible (i.e. when working with online sources). + /// + public StarDifficulty(double starDifficulty, int maxCombo) + { + Stars = starDifficulty; + MaxCombo = maxCombo; + Attributes = null; + } + + public DifficultyRating DifficultyRating => BeatmapDifficultyCache.GetDifficultyRating(Stars); + } +} diff --git a/osu.Game/Beatmaps/Timing/BreakPeriod.cs b/osu.Game/Beatmaps/Timing/BreakPeriod.cs index 856a5fefd4..4c90b16745 100644 --- a/osu.Game/Beatmaps/Timing/BreakPeriod.cs +++ b/osu.Game/Beatmaps/Timing/BreakPeriod.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 osu.Game.Screens.Play; + namespace osu.Game.Beatmaps.Timing { public class BreakPeriod @@ -26,15 +28,26 @@ namespace osu.Game.Beatmaps.Timing public double Duration => EndTime - StartTime; /// - /// Whether the break has any effect. Breaks that are too short are culled before they are added to the beatmap. + /// Whether the break has any effect. /// public bool HasEffect => Duration >= MIN_BREAK_DURATION; + /// + /// Constructs a new break period. + /// + /// The start time of the break period. + /// The end time of the break period. + public BreakPeriod(double startTime, double endTime) + { + StartTime = startTime; + EndTime = endTime; + } + /// /// Whether this break contains a specified time. /// /// The time to check in milliseconds. /// Whether the time falls within this . - public bool Contains(double time) => time >= StartTime && time <= EndTime; + public bool Contains(double time) => time >= StartTime && time <= EndTime - BreakOverlay.BREAK_FADE_DURATION; } } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 949a2aab6f..30382c444f 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.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 osu.Framework.Audio.Track; -using osu.Framework.Graphics.Textures; -using osu.Game.Rulesets.Mods; using System; using System.Collections.Generic; -using osu.Game.Storyboards; -using osu.Framework.IO.File; -using System.IO; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics.Textures; +using osu.Framework.Logging; using osu.Framework.Statistics; -using osu.Game.IO.Serialization; +using osu.Framework.Testing; using osu.Game.Rulesets; -using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.UI; using osu.Game.Skinning; +using osu.Game.Storyboards; namespace osu.Game.Beatmaps { - public abstract class WorkingBeatmap : IDisposable + [ExcludeFromDynamicCompile] + public abstract class WorkingBeatmap : IWorkingBeatmap { public readonly BeatmapInfo BeatmapInfo; @@ -42,30 +43,29 @@ namespace osu.Game.Beatmaps BeatmapSetInfo = beatmapInfo.BeatmapSet; Metadata = beatmapInfo.Metadata ?? BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); - track = new RecyclableLazy(() => GetTrack() ?? GetVirtualTrack()); background = new RecyclableLazy(GetBackground, BackgroundStillValid); waveform = new RecyclableLazy(GetWaveform); storyboard = new RecyclableLazy(GetStoryboard); - skin = new RecyclableLazy(GetSkin); + skin = new RecyclableLazy(GetSkin); total_count.Value++; } - protected virtual Track GetVirtualTrack() + protected virtual Track GetVirtualTrack(double emptyLength = 0) { const double excess_length = 1000; - var lastObject = Beatmap.HitObjects.LastOrDefault(); + var lastObject = Beatmap?.HitObjects.LastOrDefault(); double length; switch (lastObject) { case null: - length = excess_length; + length = emptyLength; break; - case IHasEndTime endTime: + case IHasDuration endTime: length = endTime.EndTime + excess_length; break; @@ -78,77 +78,138 @@ namespace osu.Game.Beatmaps } /// - /// Saves the . + /// Creates a to convert a for a specified . /// - /// The absolute path of the output file. - public string Save() + /// The to be converted. + /// The for which should be converted. + /// The applicable . + protected virtual IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) => ruleset.CreateBeatmapConverter(beatmap); + + public IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods = null, TimeSpan? timeout = null) { - var path = FileSafety.GetTempPath(Guid.NewGuid().ToString().Replace("-", string.Empty) + ".json"); - using (var sw = new StreamWriter(path)) - sw.WriteLine(Beatmap.Serialize()); - return path; + using (var cancellationSource = createCancellationTokenSource(timeout)) + { + mods ??= Array.Empty(); + + var rulesetInstance = ruleset.CreateInstance(); + + IBeatmapConverter converter = CreateBeatmapConverter(Beatmap, rulesetInstance); + + // Check if the beatmap can be converted + if (Beatmap.HitObjects.Count > 0 && !converter.CanConvert()) + throw new BeatmapInvalidForRulesetException($"{nameof(Beatmaps.Beatmap)} can not be converted for the ruleset (ruleset: {ruleset.InstantiationInfo}, converter: {converter})."); + + // Apply conversion mods + foreach (var mod in mods.OfType()) + { + if (cancellationSource.IsCancellationRequested) + throw new BeatmapLoadTimeoutException(BeatmapInfo); + + mod.ApplyToBeatmapConverter(converter); + } + + // Convert + IBeatmap converted = converter.Convert(cancellationSource.Token); + + // Apply conversion mods to the result + foreach (var mod in mods.OfType()) + { + if (cancellationSource.IsCancellationRequested) + throw new BeatmapLoadTimeoutException(BeatmapInfo); + + mod.ApplyToBeatmap(converted); + } + + // Apply difficulty mods + if (mods.Any(m => m is IApplicableToDifficulty)) + { + converted.BeatmapInfo = converted.BeatmapInfo.Clone(); + converted.BeatmapInfo.BaseDifficulty = converted.BeatmapInfo.BaseDifficulty.Clone(); + + foreach (var mod in mods.OfType()) + { + if (cancellationSource.IsCancellationRequested) + throw new BeatmapLoadTimeoutException(BeatmapInfo); + + mod.ApplyToDifficulty(converted.BeatmapInfo.BaseDifficulty); + } + } + + IBeatmapProcessor processor = rulesetInstance.CreateBeatmapProcessor(converted); + + processor?.PreProcess(); + + // Compute default values for hitobjects, including creating nested hitobjects in-case they're needed + try + { + foreach (var obj in converted.HitObjects) + { + if (cancellationSource.IsCancellationRequested) + throw new BeatmapLoadTimeoutException(BeatmapInfo); + + obj.ApplyDefaults(converted.ControlPointInfo, converted.BeatmapInfo.BaseDifficulty, cancellationSource.Token); + } + } + catch (OperationCanceledException) + { + throw new BeatmapLoadTimeoutException(BeatmapInfo); + } + + foreach (var mod in mods.OfType()) + { + foreach (var obj in converted.HitObjects) + { + if (cancellationSource.IsCancellationRequested) + throw new BeatmapLoadTimeoutException(BeatmapInfo); + + mod.ApplyToHitObject(obj); + } + } + + processor?.PostProcess(); + + foreach (var mod in mods.OfType()) + { + cancellationSource.Token.ThrowIfCancellationRequested(); + mod.ApplyToBeatmap(converted); + } + + return converted; + } + } + + private CancellationTokenSource loadCancellation = new CancellationTokenSource(); + + /// + /// Beings loading the contents of this asynchronously. + /// + public void BeginAsyncLoad() + { + loadBeatmapAsync(); } /// - /// Constructs a playable from using the applicable converters for a specific . - /// - /// The returned is in a playable state - all and s - /// have been applied, and s have been fully constructed. - /// + /// Cancels the asynchronous loading of the contents of this . /// - /// The to create a playable for. - /// The s to apply to the . - /// The converted . - /// If could not be converted to . - public IBeatmap GetPlayableBeatmap(RulesetInfo ruleset, IReadOnlyList mods) + public void CancelAsyncLoad() { - var rulesetInstance = ruleset.CreateInstance(); + loadCancellation?.Cancel(); + loadCancellation = new CancellationTokenSource(); - IBeatmapConverter converter = rulesetInstance.CreateBeatmapConverter(Beatmap); - - // Check if the beatmap can be converted - if (!converter.CanConvert) - throw new BeatmapInvalidForRulesetException($"{nameof(Beatmaps.Beatmap)} can not be converted for the ruleset (ruleset: {ruleset.InstantiationInfo}, converter: {converter})."); - - // Apply conversion mods - foreach (var mod in mods.OfType()) - mod.ApplyToBeatmapConverter(converter); - - // Convert - IBeatmap converted = converter.Convert(); - - // Apply difficulty mods - if (mods.Any(m => m is IApplicableToDifficulty)) - { - converted.BeatmapInfo = converted.BeatmapInfo.Clone(); - converted.BeatmapInfo.BaseDifficulty = converted.BeatmapInfo.BaseDifficulty.Clone(); - - foreach (var mod in mods.OfType()) - mod.ApplyToDifficulty(converted.BeatmapInfo.BaseDifficulty); - } - - IBeatmapProcessor processor = rulesetInstance.CreateBeatmapProcessor(converted); - - processor?.PreProcess(); - - // Compute default values for hitobjects, including creating nested hitobjects in-case they're needed - foreach (var obj in converted.HitObjects) - obj.ApplyDefaults(converted.ControlPointInfo, converted.BeatmapInfo.BaseDifficulty); - - foreach (var mod in mods.OfType()) - foreach (var obj in converted.HitObjects) - mod.ApplyToHitObject(obj); - - processor?.PostProcess(); - - return converted; + if (beatmapLoadTask?.IsCompleted != true) + beatmapLoadTask = null; } - public override string ToString() => BeatmapInfo.ToString(); + private CancellationTokenSource createCancellationTokenSource(TimeSpan? timeout) + { + if (Debugger.IsAttached) + // ignore timeout when debugger is attached (may be breakpointing / debugging). + return new CancellationTokenSource(); - public bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; + return new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(10)); + } - public Task LoadBeatmapAsync() => (beatmapLoadTask ?? (beatmapLoadTask = Task.Factory.StartNew(() => + private Task loadBeatmapAsync() => beatmapLoadTask ??= Task.Factory.StartNew(() => { // Todo: Handle cancellation during beatmap parsing var b = GetBeatmap() ?? new Beatmap(); @@ -160,7 +221,11 @@ namespace osu.Game.Beatmaps b.BeatmapInfo = BeatmapInfo; return b; - }, beatmapCancellation.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default))); + }, loadCancellation.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + public override string ToString() => BeatmapInfo.ToString(); + + public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; public IBeatmap Beatmap { @@ -168,16 +233,25 @@ namespace osu.Game.Beatmaps { try { - return LoadBeatmapAsync().Result; + return loadBeatmapAsync().Result; } - catch (TaskCanceledException) + catch (AggregateException ae) { + // This is the exception that is generally expected here, which occurs via natural cancellation of the asynchronous load + if (ae.InnerExceptions.FirstOrDefault() is TaskCanceledException) + return null; + + Logger.Error(ae, "Beatmap failed to load"); + return null; + } + catch (Exception e) + { + Logger.Error(e, "Beatmap failed to load"); return null; } } } - private readonly CancellationTokenSource beatmapCancellation = new CancellationTokenSource(); protected abstract IBeatmap GetBeatmap(); private Task beatmapLoadTask; @@ -187,10 +261,39 @@ namespace osu.Game.Beatmaps protected abstract Texture GetBackground(); private readonly RecyclableLazy background; - public bool TrackLoaded => track.IsResultAvailable; - public Track Track => track.Value; - protected abstract Track GetTrack(); - private RecyclableLazy track; + private Track loadedTrack; + + [NotNull] + public Track LoadTrack() => loadedTrack = GetBeatmapTrack() ?? GetVirtualTrack(1000); + + /// + /// Transfer a valid audio track into this working beatmap. Used as an optimisation to avoid reload / track swap + /// across difficulties in the same beatmap set. + /// + /// The track to transfer. + public void TransferTrack([NotNull] Track track) => loadedTrack = track ?? throw new ArgumentNullException(nameof(track)); + + /// + /// Whether this beatmap's track has been loaded via . + /// + public virtual bool TrackLoaded => loadedTrack != null; + + /// + /// Get the loaded audio track instance. must have first been called. + /// This generally happens via MusicController when changing the global beatmap. + /// + public Track Track + { + get + { + if (!TrackLoaded) + throw new InvalidOperationException($"Cannot access {nameof(Track)} without first calling {nameof(LoadTrack)}."); + + return loadedTrack; + } + } + + protected abstract Track GetBeatmapTrack(); public bool WaveformLoaded => waveform.IsResultAvailable; public Waveform Waveform => waveform.Value; @@ -203,61 +306,16 @@ namespace osu.Game.Beatmaps private readonly RecyclableLazy storyboard; public bool SkinLoaded => skin.IsResultAvailable; - public Skin Skin => skin.Value; + public ISkin Skin => skin.Value; - protected virtual Skin GetSkin() => new DefaultSkin(); - private readonly RecyclableLazy skin; - - /// - /// Transfer pieces of a beatmap to a new one, where possible, to save on loading. - /// - /// The new beatmap which is being switched to. - public virtual void TransferTo(WorkingBeatmap other) - { - if (track.IsResultAvailable && Track != null && BeatmapInfo.AudioEquals(other.BeatmapInfo)) - other.track = track; - } - - /// - /// Eagerly dispose of the audio track associated with this (if any). - /// Accessing track again will load a fresh instance. - /// - public virtual void RecycleTrack() => track.Recycle(); - - #region Disposal - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - private bool isDisposed; - - protected virtual void Dispose(bool isDisposing) - { - if (isDisposed) - return; - - isDisposed = true; - - // recycling logic is not here for the time being, as components which use - // retrieved objects from WorkingBeatmap may not hold a reference to the WorkingBeatmap itself. - // this should be fine as each retrieved component do have their own finalizers. - - // cancelling the beatmap load is safe for now since the retrieval is a synchronous - // operation. if we add an async retrieval method this may need to be reconsidered. - beatmapCancellation?.Cancel(); - total_count.Value--; - } + protected virtual ISkin GetSkin() => new DefaultSkin(); + private readonly RecyclableLazy skin; ~WorkingBeatmap() { - Dispose(false); + total_count.Value--; } - #endregion - public class RecyclableLazy { private Lazy lazy; @@ -301,5 +359,13 @@ namespace osu.Game.Beatmaps private void recreate() => lazy = new Lazy(valueFactory, LazyThreadSafetyMode.ExecutionAndPublication); } + + private class BeatmapLoadTimeoutException : TimeoutException + { + public BeatmapLoadTimeoutException(BeatmapInfo beatmapInfo) + : base($"Timed out while loading beatmap ({beatmapInfo}).") + { + } + } } } diff --git a/osu.Game/Collections/BeatmapCollection.cs b/osu.Game/Collections/BeatmapCollection.cs new file mode 100644 index 0000000000..7e4b15ecf9 --- /dev/null +++ b/osu.Game/Collections/BeatmapCollection.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 System; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; + +namespace osu.Game.Collections +{ + /// + /// A collection of beatmaps grouped by a name. + /// + public class BeatmapCollection + { + /// + /// Invoked whenever any change occurs on this . + /// + public event Action Changed; + + /// + /// The collection's name. + /// + public readonly Bindable Name = new Bindable(); + + /// + /// The beatmaps contained by the collection. + /// + public readonly BindableList Beatmaps = new BindableList(); + + /// + /// The date when this collection was last modified. + /// + public DateTimeOffset LastModifyDate { get; private set; } = DateTimeOffset.UtcNow; + + public BeatmapCollection() + { + Beatmaps.CollectionChanged += (_, __) => onChange(); + Name.ValueChanged += _ => onChange(); + } + + private void onChange() + { + LastModifyDate = DateTimeOffset.Now; + Changed?.Invoke(); + } + } +} diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs new file mode 100644 index 0000000000..ec0e9d5a89 --- /dev/null +++ b/osu.Game/Collections/CollectionFilterDropdown.cs @@ -0,0 +1,283 @@ +// 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.Specialized; +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.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Collections +{ + /// + /// A dropdown to select the to filter beatmaps using. + /// + public class CollectionFilterDropdown : OsuDropdown + { + /// + /// Whether to show the "manage collections..." menu item in the dropdown. + /// + protected virtual bool ShowManageCollectionsItem => true; + + private readonly IBindableList collections = new BindableList(); + private readonly IBindableList beatmaps = new BindableList(); + private readonly BindableList filters = new BindableList(); + + [Resolved(CanBeNull = true)] + private ManageCollectionsDialog manageCollectionsDialog { get; set; } + + public CollectionFilterDropdown() + { + ItemSource = filters; + } + + [BackgroundDependencyLoader(permitNulls: true)] + private void load([CanBeNull] CollectionManager collectionManager) + { + if (collectionManager != null) + collections.BindTo(collectionManager.Collections); + + collections.CollectionChanged += (_, __) => collectionsChanged(); + collectionsChanged(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(filterChanged, true); + } + + /// + /// Occurs when a collection has been added or removed. + /// + private void collectionsChanged() + { + var selectedItem = SelectedItem?.Value?.Collection; + + filters.Clear(); + filters.Add(new AllBeatmapsCollectionFilterMenuItem()); + filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c))); + + if (ShowManageCollectionsItem) + filters.Add(new ManageCollectionsFilterMenuItem()); + + Current.Value = filters.SingleOrDefault(f => f.Collection != null && f.Collection == selectedItem) ?? filters[0]; + } + + /// + /// Occurs when the selection has changed. + /// + private void filterChanged(ValueChangedEvent filter) + { + // Binding the beatmaps will trigger a collection change event, which results in an infinite-loop. This is rebound later, when it's safe to do so. + beatmaps.CollectionChanged -= filterBeatmapsChanged; + + if (filter.OldValue?.Collection != null) + beatmaps.UnbindFrom(filter.OldValue.Collection.Beatmaps); + + if (filter.NewValue?.Collection != null) + beatmaps.BindTo(filter.NewValue.Collection.Beatmaps); + + beatmaps.CollectionChanged += filterBeatmapsChanged; + + // Never select the manage collection filter - rollback to the previous filter. + // This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value. + if (filter.NewValue is ManageCollectionsFilterMenuItem) + { + Current.Value = filter.OldValue; + manageCollectionsDialog?.Show(); + } + } + + /// + /// Occurs when the beatmaps contained by a have changed. + /// + private void filterBeatmapsChanged(object sender, NotifyCollectionChangedEventArgs e) + { + // The filtered beatmaps have changed, without the filter having changed itself. So a change in filter must be notified. + // Note that this does NOT propagate to bound bindables, so the FilterControl must bind directly to the value change event of this bindable. + Current.TriggerChange(); + } + + protected override string GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName.Value; + + protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader().With(d => + { + d.SelectedItem.BindTarget = Current; + }); + + protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu(); + + protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader(); + + protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu(); + + public class CollectionDropdownHeader : OsuDropdownHeader + { + public readonly Bindable SelectedItem = new Bindable(); + private readonly Bindable collectionName = new Bindable(); + + protected override string Label + { + get => base.Label; + set { } // See updateText(). + } + + public CollectionDropdownHeader() + { + Height = 25; + Icon.Size = new Vector2(16); + Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 4 }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedItem.BindValueChanged(_ => updateBindable(), true); + } + + private void updateBindable() + { + collectionName.UnbindAll(); + + if (SelectedItem.Value != null) + collectionName.BindTo(SelectedItem.Value.CollectionName); + + collectionName.BindValueChanged(_ => updateText(), true); + } + + // Dropdowns don't bind to value changes, so the real name is copied directly from the selected item here. + private void updateText() => base.Label = collectionName.Value; + } + + protected class CollectionDropdownMenu : OsuDropdownMenu + { + public CollectionDropdownMenu() + { + MaxHeight = 200; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownMenuItem(item); + } + + protected class CollectionDropdownMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem + { + [NotNull] + protected new CollectionFilterMenuItem Item => ((DropdownMenuItem)base.Item).Value; + + [Resolved] + private OsuColour colours { get; set; } + + [Resolved] + private IBindable beatmap { get; set; } + + [CanBeNull] + private readonly BindableList collectionBeatmaps; + + [NotNull] + private readonly Bindable collectionName; + + private IconButton addOrRemoveButton; + private Content content; + private bool beatmapInCollection; + + public CollectionDropdownMenuItem(MenuItem item) + : base(item) + { + collectionBeatmaps = Item.Collection?.Beatmaps.GetBoundCopy(); + collectionName = Item.CollectionName.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(addOrRemoveButton = new IconButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + X = -OsuScrollContainer.SCROLL_BAR_HEIGHT, + Scale = new Vector2(0.65f), + Action = addOrRemove, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (collectionBeatmaps != null) + { + collectionBeatmaps.CollectionChanged += (_, __) => collectionChanged(); + beatmap.BindValueChanged(_ => collectionChanged(), true); + } + + // Although the DrawableMenuItem binds to value changes of the item's text, the item is an internal implementation detail of Dropdown that has no knowledge + // of the underlying CollectionFilter value and its accompanying name, so the real name has to be copied here. Without this, the collection name wouldn't update when changed. + collectionName.BindValueChanged(name => content.Text = name.NewValue, true); + + updateButtonVisibility(); + } + + protected override bool OnHover(HoverEvent e) + { + updateButtonVisibility(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateButtonVisibility(); + base.OnHoverLost(e); + } + + private void collectionChanged() + { + Debug.Assert(collectionBeatmaps != null); + + beatmapInCollection = collectionBeatmaps.Contains(beatmap.Value.BeatmapInfo); + + addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; + addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; + addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap"; + + updateButtonVisibility(); + } + + protected override void OnSelectChange() + { + base.OnSelectChange(); + updateButtonVisibility(); + } + + private void updateButtonVisibility() + { + if (collectionBeatmaps == null) + addOrRemoveButton.Alpha = 0; + else + addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0; + } + + private void addOrRemove() + { + Debug.Assert(collectionBeatmaps != null); + + if (!collectionBeatmaps.Remove(beatmap.Value.BeatmapInfo)) + collectionBeatmaps.Add(beatmap.Value.BeatmapInfo); + } + + protected override Drawable CreateContent() => content = (Content)base.CreateContent(); + } + } +} diff --git a/osu.Game/Collections/CollectionFilterMenuItem.cs b/osu.Game/Collections/CollectionFilterMenuItem.cs new file mode 100644 index 0000000000..4a489d2945 --- /dev/null +++ b/osu.Game/Collections/CollectionFilterMenuItem.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 JetBrains.Annotations; +using osu.Framework.Bindables; + +namespace osu.Game.Collections +{ + /// + /// A filter. + /// + public class CollectionFilterMenuItem + { + /// + /// The collection to filter beatmaps from. + /// May be null to not filter by collection (include all beatmaps). + /// + [CanBeNull] + public readonly BeatmapCollection Collection; + + /// + /// The name of the collection. + /// + [NotNull] + public readonly Bindable CollectionName; + + /// + /// Creates a new . + /// + /// The collection to filter beatmaps from. + public CollectionFilterMenuItem([CanBeNull] BeatmapCollection collection) + { + Collection = collection; + CollectionName = Collection?.Name.GetBoundCopy() ?? new Bindable("All beatmaps"); + } + } + + public class AllBeatmapsCollectionFilterMenuItem : CollectionFilterMenuItem + { + public AllBeatmapsCollectionFilterMenuItem() + : base(null) + { + } + } + + public class ManageCollectionsFilterMenuItem : CollectionFilterMenuItem + { + public ManageCollectionsFilterMenuItem() + : base(null) + { + CollectionName.Value = "Manage collections..."; + } + } +} diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs new file mode 100644 index 0000000000..569ac749a4 --- /dev/null +++ b/osu.Game/Collections/CollectionManager.cs @@ -0,0 +1,303 @@ +// 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.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.Legacy; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Collections +{ + /// + /// Handles user-defined collections of beatmaps. + /// + /// + /// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the + /// database backing the game. Going forward writing should be done in a similar way to other model stores. + /// + public class CollectionManager : Component + { + /// + /// Database version in stable-compatible YYYYMMDD format. + /// + private const int database_version = 30000000; + + private const string database_name = "collection.db"; + + public readonly BindableList Collections = new BindableList(); + + public bool SupportsImportFromStable => RuntimeInfo.IsDesktop; + + [Resolved] + private GameHost host { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } + + private readonly Storage storage; + + public CollectionManager(Storage storage) + { + this.storage = storage; + } + + [BackgroundDependencyLoader] + private void load() + { + Collections.CollectionChanged += collectionsChanged; + + if (storage.Exists(database_name)) + { + using (var stream = storage.GetStream(database_name)) + importCollections(readCollections(stream)); + } + } + + private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var c in e.NewItems.Cast()) + c.Changed += backgroundSave; + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var c in e.OldItems.Cast()) + c.Changed -= backgroundSave; + break; + + case NotifyCollectionChangedAction.Replace: + foreach (var c in e.OldItems.Cast()) + c.Changed -= backgroundSave; + + foreach (var c in e.NewItems.Cast()) + c.Changed += backgroundSave; + break; + } + + backgroundSave(); + } + + /// + /// Set an endpoint for notifications to be posted to. + /// + 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() + { + 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)) + { + // 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); + return Task.CompletedTask; + } + + return Task.Run(async () => + { + using (var stream = stable.GetStream(database_name)) + await Import(stream); + }); + } + + public async Task Import(Stream stream) + { + var notification = new ProgressNotification + { + State = ProgressNotificationState.Active, + Text = "Collections import is initialising..." + }; + + PostNotification?.Invoke(notification); + + var collection = readCollections(stream, notification); + bool importCompleted = false; + + Schedule(() => + { + importCollections(collection); + importCompleted = true; + }); + + while (!IsDisposed && !importCompleted) + await Task.Delay(10); + + notification.CompletionText = $"Imported {collection.Count} collections"; + notification.State = ProgressNotificationState.Completed; + } + + private void importCollections(List newCollections) + { + foreach (var newCol in newCollections) + { + var existing = Collections.FirstOrDefault(c => c.Name == newCol.Name); + if (existing == null) + Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } }); + + foreach (var newBeatmap in newCol.Beatmaps) + { + if (!existing.Beatmaps.Contains(newBeatmap)) + existing.Beatmaps.Add(newBeatmap); + } + } + } + + private List readCollections(Stream stream, ProgressNotification notification = null) + { + if (notification != null) + { + notification.Text = "Reading collections..."; + notification.Progress = 0; + } + + var result = new List(); + + try + { + using (var sr = new SerializationReader(stream)) + { + sr.ReadInt32(); // Version + + int collectionCount = sr.ReadInt32(); + result.Capacity = collectionCount; + + for (int i = 0; i < collectionCount; i++) + { + if (notification?.CancellationToken.IsCancellationRequested == true) + return result; + + var collection = new BeatmapCollection { Name = { Value = sr.ReadString() } }; + int mapCount = sr.ReadInt32(); + + for (int j = 0; j < mapCount; j++) + { + if (notification?.CancellationToken.IsCancellationRequested == true) + return result; + + string checksum = sr.ReadString(); + + var beatmap = beatmaps.QueryBeatmap(b => b.MD5Hash == checksum); + if (beatmap != null) + collection.Beatmaps.Add(beatmap); + } + + if (notification != null) + { + notification.Text = $"Imported {i + 1} of {collectionCount} collections"; + notification.Progress = (float)(i + 1) / collectionCount; + } + + result.Add(collection); + } + } + } + catch (Exception e) + { + Logger.Error(e, "Failed to read collection database."); + } + + return result; + } + + public void DeleteAll() + { + Collections.Clear(); + PostNotification?.Invoke(new ProgressCompletionNotification { Text = "Deleted all collections!" }); + } + + private readonly object saveLock = new object(); + private int lastSave; + private int saveFailures; + + /// + /// Perform a save with debounce. + /// + private void backgroundSave() + { + var current = Interlocked.Increment(ref lastSave); + Task.Delay(100).ContinueWith(task => + { + if (current != lastSave) + return; + + if (!save()) + backgroundSave(); + }); + } + + private bool save() + { + lock (saveLock) + { + Interlocked.Increment(ref lastSave); + + try + { + // This is NOT thread-safe!! + + using (var sw = new SerializationWriter(storage.GetStream(database_name, FileAccess.Write))) + { + sw.Write(database_version); + sw.Write(Collections.Count); + + foreach (var c in Collections) + { + sw.Write(c.Name.Value); + sw.Write(c.Beatmaps.Count); + + foreach (var b in c.Beatmaps) + sw.Write(b.MD5Hash); + } + } + + if (saveFailures < 10) + saveFailures = 0; + return true; + } + catch (Exception e) + { + // Since this code is not thread-safe, we may run into random exceptions (such as collection enumeration or out of range indexing). + // Failures are thus only alerted if they exceed a threshold (once) to indicate "actual" errors having occurred. + if (++saveFailures == 10) + Logger.Error(e, "Failed to save collection database!"); + } + + return false; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + save(); + } + } +} diff --git a/osu.Game/Collections/DeleteCollectionDialog.cs b/osu.Game/Collections/DeleteCollectionDialog.cs new file mode 100644 index 0000000000..e5a2f6fb81 --- /dev/null +++ b/osu.Game/Collections/DeleteCollectionDialog.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; +using Humanizer; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Collections +{ + public class DeleteCollectionDialog : PopupDialog + { + public DeleteCollectionDialog(BeatmapCollection collection, Action deleteAction) + { + HeaderText = "Confirm deletion of"; + BodyText = $"{collection.Name.Value} ({"beatmap".ToQuantity(collection.Beatmaps.Count)})"; + + Icon = FontAwesome.Regular.TrashAlt; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Yes. Go for it.", + Action = deleteAction + }, + new PopupDialogCancelButton + { + Text = @"No! Abort mission!", + }, + }; + } + } +} diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs new file mode 100644 index 0000000000..3c664a11d9 --- /dev/null +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -0,0 +1,122 @@ +// 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.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Collections +{ + /// + /// Visualises a list of s. + /// + public class DrawableCollectionList : OsuRearrangeableListContainer + { + private Scroll scroll; + + protected override ScrollContainer CreateScrollContainer() => scroll = new Scroll(); + + protected override FillFlowContainer> CreateListFillFlowContainer() => new Flow + { + DragActive = { BindTarget = DragActive } + }; + + protected override OsuRearrangeableListItem CreateOsuDrawable(BeatmapCollection item) + { + if (item == scroll.PlaceholderItem.Model) + return scroll.ReplacePlaceholder(); + + return new DrawableCollectionListItem(item, true); + } + + /// + /// The scroll container for this . + /// Contains the main flow of and attaches a placeholder item to the end of the list. + /// + /// + /// Use to transfer the placeholder into the main list. + /// + private class Scroll : OsuScrollContainer + { + /// + /// The currently-displayed placeholder item. + /// + public DrawableCollectionListItem PlaceholderItem { get; private set; } + + protected override Container Content => content; + private readonly Container content; + + private readonly Container placeholderContainer; + + public Scroll() + { + ScrollbarVisible = false; + Padding = new MarginPadding(10); + + base.Content.Add(new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + LayoutDuration = 200, + LayoutEasing = Easing.OutQuint, + Children = new Drawable[] + { + content = new Container { RelativeSizeAxes = Axes.X }, + placeholderContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + }); + + ReplacePlaceholder(); + } + + protected override void Update() + { + base.Update(); + + // AutoSizeAxes cannot be used as the height should represent the post-layout-transform height at all times, so that the placeholder doesn't bounce around. + content.Height = ((Flow)Child).Children.Sum(c => c.DrawHeight + 5); + } + + /// + /// Replaces the current with a new one, and returns the previous. + /// + /// The current . + public DrawableCollectionListItem ReplacePlaceholder() + { + var previous = PlaceholderItem; + + placeholderContainer.Clear(false); + placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection(), false)); + + return previous; + } + } + + /// + /// The flow of . Disables layout easing unless a drag is in progress. + /// + private class Flow : FillFlowContainer> + { + public readonly IBindable DragActive = new Bindable(); + + public Flow() + { + Spacing = new Vector2(0, 5); + LayoutEasing = Easing.OutQuint; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + DragActive.BindValueChanged(active => LayoutDuration = active.NewValue ? 200 : 0); + } + } + } +} diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs new file mode 100644 index 0000000000..988a3443c3 --- /dev/null +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -0,0 +1,237 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +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.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Collections +{ + /// + /// Visualises a inside a . + /// + public class DrawableCollectionListItem : OsuRearrangeableListItem + { + private const float item_height = 35; + private const float button_width = item_height * 0.75f; + + /// + /// Whether the currently exists inside the . + /// + public IBindable IsCreated => isCreated; + + private readonly Bindable isCreated = new Bindable(); + + /// + /// Creates a new . + /// + /// The . + /// Whether currently exists inside the . + public DrawableCollectionListItem(BeatmapCollection item, bool isCreated) + : base(item) + { + this.isCreated.Value = isCreated; + + ShowDragHandle.BindTo(this.isCreated); + } + + protected override Drawable CreateContent() => new ItemContent(Model) + { + IsCreated = { BindTarget = isCreated } + }; + + /// + /// The main content of the . + /// + private class ItemContent : CircularContainer + { + public readonly Bindable IsCreated = new Bindable(); + + private readonly IBindable collectionName; + private readonly BeatmapCollection collection; + + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + + private Container textBoxPaddingContainer; + private ItemTextBox textBox; + + public ItemContent(BeatmapCollection collection) + { + this.collection = collection; + + RelativeSizeAxes = Axes.X; + Height = item_height; + Masking = true; + + collectionName = collection.Name.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Children = new Drawable[] + { + new DeleteButton(collection) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + IsCreated = { BindTarget = IsCreated }, + IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v) + }, + textBoxPaddingContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = button_width }, + Children = new Drawable[] + { + textBox = new ItemTextBox + { + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + CornerRadius = item_height / 2, + Current = collection.Name, + PlaceholderText = IsCreated.Value ? string.Empty : "Create a new collection" + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + collectionName.BindValueChanged(_ => createNewCollection(), true); + IsCreated.BindValueChanged(created => textBoxPaddingContainer.Padding = new MarginPadding { Right = created.NewValue ? button_width : 0 }, true); + } + + private void createNewCollection() + { + if (IsCreated.Value) + return; + + if (string.IsNullOrEmpty(collectionName.Value)) + return; + + // Add the new collection and disable our placeholder. If all text is removed, the placeholder should not show back again. + collectionManager?.Collections.Add(collection); + textBox.PlaceholderText = string.Empty; + + // When this item changes from placeholder to non-placeholder (via changing containers), its textbox will lose focus, so it needs to be re-focused. + Schedule(() => GetContainingInputManager().ChangeFocus(textBox)); + + IsCreated.Value = true; + } + } + + private class ItemTextBox : OsuTextBox + { + protected override float LeftRightPadding => item_height / 2; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundUnfocused = colours.GreySeafoamDarker.Darken(0.5f); + BackgroundFocused = colours.GreySeafoam; + } + } + + public class DeleteButton : CompositeDrawable + { + public readonly IBindable IsCreated = new Bindable(); + + public Func IsTextBoxHovered; + + [Resolved(CanBeNull = true)] + private DialogOverlay dialogOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + + private readonly BeatmapCollection collection; + + private Drawable fadeContainer; + private Drawable background; + + public DeleteButton(BeatmapCollection collection) + { + this.collection = collection; + RelativeSizeAxes = Axes.Y; + + Width = button_width + item_height / 2; // add corner radius to cover with fill + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChild = fadeContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f, + Children = new[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Red + }, + new SpriteIcon + { + Anchor = Anchor.CentreRight, + Origin = Anchor.Centre, + X = -button_width * 0.6f, + Size = new Vector2(10), + Icon = FontAwesome.Solid.Trash + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + IsCreated.BindValueChanged(created => Alpha = created.NewValue ? 1 : 0, true); + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos); + + protected override bool OnHover(HoverEvent e) + { + fadeContainer.FadeTo(1f, 100, Easing.Out); + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + fadeContainer.FadeTo(0.1f, 100); + } + + protected override bool OnClick(ClickEvent e) + { + background.FlashColour(Color4.White, 150); + + if (collection.Beatmaps.Count == 0) + deleteCollection(); + else + dialogOverlay?.Push(new DeleteCollectionDialog(collection, deleteCollection)); + + return true; + } + + private void deleteCollection() => collectionManager?.Collections.Remove(collection); + } + } +} diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs new file mode 100644 index 0000000000..680fec904f --- /dev/null +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -0,0 +1,134 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Collections +{ + public class ManageCollectionsDialog : OsuFocusedOverlayContainer + { + private const double enter_duration = 500; + private const double exit_duration = 200; + + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + + public ManageCollectionsDialog() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(0.5f, 0.8f); + + Masking = true; + CornerRadius = 10; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Children = new Drawable[] + { + new Box + { + Colour = colours.GreySeafoamDark, + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Manage collections", + Font = OsuFont.GetFont(size: 30), + Padding = new MarginPadding { Vertical = 10 }, + }, + new IconButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Icon = FontAwesome.Solid.Times, + Colour = colours.GreySeafoamDarker, + Scale = new Vector2(0.8f), + X = -10, + Action = () => State.Value = Visibility.Hidden + } + } + } + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeafoamDarker + }, + new DrawableCollectionList + { + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = collectionManager?.Collections ?? new BindableList() } + } + } + } + }, + } + } + } + }; + } + + protected override void PopIn() + { + base.PopIn(); + + this.FadeIn(enter_duration, Easing.OutQuint); + this.ScaleTo(0.9f).Then().ScaleTo(1f, enter_duration, Easing.OutQuint); + } + + protected override void PopOut() + { + base.PopOut(); + + this.FadeOut(exit_duration, Easing.OutQuint); + this.ScaleTo(0.9f, exit_duration); + + // Ensure that textboxes commit + GetContainingInputManager()?.TriggerFocusContention(this); + } + } +} diff --git a/osu.Game/Configuration/BackgroundSource.cs b/osu.Game/Configuration/BackgroundSource.cs new file mode 100644 index 0000000000..5726e96eb1 --- /dev/null +++ b/osu.Game/Configuration/BackgroundSource.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. + +namespace osu.Game.Configuration +{ + public enum BackgroundSource + { + Skin, + Beatmap + } +} diff --git a/osu.Game/Configuration/DatabasedConfigManager.cs b/osu.Game/Configuration/DatabasedConfigManager.cs index d5cdd7e4bc..b3783b45a8 100644 --- a/osu.Game/Configuration/DatabasedConfigManager.cs +++ b/osu.Game/Configuration/DatabasedConfigManager.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.Bindables; @@ -9,18 +10,18 @@ using osu.Game.Rulesets; namespace osu.Game.Configuration { - public abstract class DatabasedConfigManager : ConfigManager - where T : struct + public abstract class DatabasedConfigManager : ConfigManager + where TLookup : struct, Enum { private readonly SettingsStore settings; private readonly int? variant; - private readonly List databasedSettings; + private List databasedSettings; private readonly RulesetInfo ruleset; - private readonly bool legacySettingsExist; + private bool legacySettingsExist; protected DatabasedConfigManager(SettingsStore settings, RulesetInfo ruleset = null, int? variant = null) { @@ -28,22 +29,32 @@ namespace osu.Game.Configuration this.ruleset = ruleset; this.variant = variant; - databasedSettings = settings.Query(ruleset?.ID, variant); - legacySettingsExist = databasedSettings.Any(s => int.TryParse(s.Key, out var _)); + Load(); InitialiseDefaults(); } protected override void PerformLoad() { + databasedSettings = settings.Query(ruleset?.ID, variant); + legacySettingsExist = databasedSettings.Any(s => int.TryParse(s.Key, out _)); } protected override bool PerformSave() { + lock (dirtySettings) + { + foreach (var setting in dirtySettings) + settings.Update(setting); + dirtySettings.Clear(); + } + return true; } - protected override void AddBindable(T lookup, Bindable bindable) + private readonly List dirtySettings = new List(); + + protected override void AddBindable(TLookup lookup, Bindable bindable) { base.AddBindable(lookup, bindable); @@ -80,7 +91,12 @@ namespace osu.Game.Configuration bindable.ValueChanged += b => { setting.Value = b.NewValue; - settings.Update(setting); + + lock (dirtySettings) + { + if (!dirtySettings.Contains(setting)) + dirtySettings.Add(setting); + } }; } } diff --git a/osu.Game/Configuration/HUDVisibilityMode.cs b/osu.Game/Configuration/HUDVisibilityMode.cs new file mode 100644 index 0000000000..10f3f65355 --- /dev/null +++ b/osu.Game/Configuration/HUDVisibilityMode.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.ComponentModel; + +namespace osu.Game.Configuration +{ + public enum HUDVisibilityMode + { + Never, + + [Description("Hide during gameplay")] + HideDuringGameplay, + + Always + } +} diff --git a/osu.Game/Configuration/InMemoryConfigManager.cs b/osu.Game/Configuration/InMemoryConfigManager.cs new file mode 100644 index 0000000000..ccf697f680 --- /dev/null +++ b/osu.Game/Configuration/InMemoryConfigManager.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.Configuration; + +namespace osu.Game.Configuration +{ + public class InMemoryConfigManager : ConfigManager + where TLookup : struct, Enum + { + public InMemoryConfigManager() + { + InitialiseDefaults(); + } + + protected override void PerformLoad() + { + } + + protected override bool PerformSave() => true; + } +} diff --git a/osu.Game/Configuration/IntroSequence.cs b/osu.Game/Configuration/IntroSequence.cs new file mode 100644 index 0000000000..5672c44bbe --- /dev/null +++ b/osu.Game/Configuration/IntroSequence.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. + +namespace osu.Game.Configuration +{ + public enum IntroSequence + { + Circles, + Welcome, + Triangles, + Random + } +} diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 1da7c7ec1d..8206a92a54 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -1,38 +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.Bindables; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Input; using osu.Game.Overlays; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; namespace osu.Game.Configuration { + [ExcludeFromDynamicCompile] public class OsuConfigManager : IniConfigManager { protected override void InitialiseDefaults() { // UI/selection defaults Set(OsuSetting.Ruleset, 0, 0, int.MaxValue); - Set(OsuSetting.Skin, 0, 0, int.MaxValue); + Set(OsuSetting.Skin, 0, -1, int.MaxValue); - Set(OsuSetting.BeatmapDetailTab, BeatmapDetailTab.Details); + Set(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); + Set(OsuSetting.BeatmapDetailModsFilter, false); Set(OsuSetting.ShowConvertedBeatmaps, true); Set(OsuSetting.DisplayStarsMinimum, 0.0, 0, 10, 0.1); - Set(OsuSetting.DisplayStarsMaximum, 10.0, 0, 10, 0.1); + Set(OsuSetting.DisplayStarsMaximum, 10.1, 0, 10.1, 0.1); + + Set(OsuSetting.SongSelectGroupingMode, GroupMode.All); + Set(OsuSetting.SongSelectSortingMode, SortMode.Title); Set(OsuSetting.RandomSelectAlgorithm, RandomSelectAlgorithm.RandomPermutation); - Set(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2, 1); + Set(OsuSetting.ChatDisplayHeight, ChatOverlay.DEFAULT_HEIGHT, 0.2f, 1f); // Online settings Set(OsuSetting.Username, string.Empty); Set(OsuSetting.Token, string.Empty); + Set(OsuSetting.AutomaticallyDownloadWhenSpectating, false); + Set(OsuSetting.SavePassword, false).ValueChanged += enabled => { if (enabled.NewValue) Set(OsuSetting.SaveUsername, true); @@ -44,6 +55,7 @@ namespace osu.Game.Configuration }; Set(OsuSetting.ExternalLinkWarning, true); + Set(OsuSetting.PreferNoVideo, false); // Audio Set(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); @@ -54,12 +66,13 @@ namespace osu.Game.Configuration Set(OsuSetting.AudioOffset, 0, -500.0, 500.0, 1); // Input - Set(OsuSetting.MenuCursorSize, 1.0, 0.5f, 2, 0.01); - Set(OsuSetting.GameplayCursorSize, 1.0, 0.1f, 2, 0.01); + Set(OsuSetting.MenuCursorSize, 1.0f, 0.5f, 2f, 0.01f); + Set(OsuSetting.GameplayCursorSize, 1.0f, 0.1f, 2f, 0.01f); Set(OsuSetting.AutoCursorSize, false); Set(OsuSetting.MouseDisableButtons, false); Set(OsuSetting.MouseDisableWheel, false); + Set(OsuSetting.ConfineMouseMode, OsuConfineMouseMode.DuringGameplay); // Graphics Set(OsuSetting.ShowFpsDisplay, false); @@ -73,18 +86,27 @@ namespace osu.Game.Configuration Set(OsuSetting.MenuParallax, true); // Gameplay - Set(OsuSetting.DimLevel, 0.3, 0, 1, 0.01); + Set(OsuSetting.DimLevel, 0.8, 0, 1, 0.01); Set(OsuSetting.BlurLevel, 0, 0, 1, 0.01); + Set(OsuSetting.LightenDuringBreaks, true); - Set(OsuSetting.ShowInterface, true); + Set(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); Set(OsuSetting.FloatingComments, false); Set(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised); Set(OsuSetting.IncreaseFirstObjectVisibility, true); + Set(OsuSetting.GameplayDisableWinKey, true); // Update Set(OsuSetting.ReleaseStream, ReleaseStream.Lazer); @@ -105,16 +127,55 @@ namespace osu.Game.Configuration Set(OsuSetting.ScalingPositionY, 0.5f, 0f, 1f); Set(OsuSetting.UIScale, 1f, 0.8f, 1.6f, 0.01f); + + Set(OsuSetting.UIHoldActivationDelay, 200f, 0f, 500f, 50f); + + Set(OsuSetting.IntroSequence, IntroSequence.Triangles); + + Set(OsuSetting.MenuBackgroundSource, BackgroundSource.Skin); + Set(OsuSetting.SeasonalBackgroundMode, SeasonalBackgroundMode.Sometimes); + + Set(OsuSetting.EditorWaveformOpacity, 1f); } public OsuConfigManager(Storage storage) : base(storage) { + Migrate(); + } + + public void Migrate() + { + // arrives as 2020.123.0 + var rawVersion = Get(OsuSetting.Version); + + if (rawVersion.Length < 6) + return; + + var pieces = rawVersion.Split('.'); + + // on a fresh install or when coming from a non-release build, execution will end here. + // we don't want to run migrations in such cases. + if (!int.TryParse(pieces[0], out int year)) return; + if (!int.TryParse(pieces[1], out int monthDay)) return; + + int combined = (year * 10000) + monthDay; + + if (combined < 20200305) + { + // 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; + } } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings { new TrackedSetting(OsuSetting.MouseDisableButtons, v => new SettingDescription(!v, "gameplay mouse buttons", v ? "disabled" : "enabled")), + new TrackedSetting(OsuSetting.HUDVisibilityMode, m => new SettingDescription(m, "HUD Visibility", m.GetDescription())), new TrackedSetting(OsuSetting.Scaling, m => new SettingDescription(m, "scaling", m.GetDescription())), }; } @@ -128,13 +189,20 @@ namespace osu.Game.Configuration AutoCursorSize, DimLevel, BlurLevel, + LightenDuringBreaks, ShowStoryboard, KeyOverlay, + PositionalHitSounds, + AlwaysPlayFirstComboBreak, + ScoreMeter, FloatingComments, - ShowInterface, + HUDVisibilityMode, + ShowProgressGraph, ShowHealthDisplayWhenCantFail, + FadePlayfieldWhenHealthLow, MouseDisableButtons, MouseDisableWheel, + ConfineMouseMode, AudioOffset, VolumeInactive, MenuMusic, @@ -142,12 +210,15 @@ namespace osu.Game.Configuration CursorRotation, MenuParallax, BeatmapDetailTab, + BeatmapDetailModsFilter, Username, ReleaseStream, SavePassword, SaveUsername, DisplayStarsMinimum, DisplayStarsMaximum, + SongSelectGroupingMode, + SongSelectSortingMode, RandomSelectAlgorithm, ShowFpsDisplay, ChatDisplayHeight, @@ -162,11 +233,20 @@ namespace osu.Game.Configuration IncreaseFirstObjectVisibility, ScoreDisplayMode, ExternalLinkWarning, + PreferNoVideo, Scaling, ScalingPositionX, ScalingPositionY, ScalingSizeX, ScalingSizeY, - UIScale + UIScale, + IntroSequence, + UIHoldActivationDelay, + HitLighting, + MenuBackgroundSource, + GameplayDisableWinKey, + SeasonalBackgroundMode, + EditorWaveformOpacity, + AutomaticallyDownloadWhenSpectating, } } diff --git a/osu.Game/Configuration/ScoreMeterType.cs b/osu.Game/Configuration/ScoreMeterType.cs index 21a63fb3ed..b9499c758e 100644 --- a/osu.Game/Configuration/ScoreMeterType.cs +++ b/osu.Game/Configuration/ScoreMeterType.cs @@ -1,12 +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.ComponentModel; + namespace osu.Game.Configuration { public enum ScoreMeterType { + [Description("None")] None, - Colour, - Error + + [Description("Hit Error (left)")] + HitErrorLeft, + + [Description("Hit Error (right)")] + HitErrorRight, + + [Description("Hit Error (bottom)")] + HitErrorBottom, + + [Description("Hit Error (left+right)")] + HitErrorBoth, + + [Description("Colour (left)")] + ColourLeft, + + [Description("Colour (right)")] + ColourRight, + + [Description("Colour (left+right)")] + ColourBoth, + + [Description("Colour (bottom)")] + ColourBottom, } } diff --git a/osu.Game/Configuration/SeasonalBackgroundMode.cs b/osu.Game/Configuration/SeasonalBackgroundMode.cs new file mode 100644 index 0000000000..6ef835ce5f --- /dev/null +++ b/osu.Game/Configuration/SeasonalBackgroundMode.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. + +namespace osu.Game.Configuration +{ + public enum SeasonalBackgroundMode + { + /// + /// Seasonal backgrounds are shown regardless of season, if at all available. + /// + Always, + + /// + /// Seasonal backgrounds are shown only during their corresponding season. + /// + Sometimes, + + /// + /// Seasonal backgrounds are never shown. + /// + Never + } +} diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs new file mode 100644 index 0000000000..03bc434aac --- /dev/null +++ b/osu.Game/Configuration/SessionStatics.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.Game.Online.API.Requests.Responses; + +namespace osu.Game.Configuration +{ + /// + /// Stores global per-session statics. These will not be stored after exiting the game. + /// + public class SessionStatics : InMemoryConfigManager + { + protected override void InitialiseDefaults() + { + Set(Static.LoginOverlayDisplayed, false); + Set(Static.MutedAudioNotificationShownOnce, false); + Set(Static.SeasonalBackgrounds, null); + } + } + + public enum Static + { + LoginOverlayDisplayed, + MutedAudioNotificationShownOnce, + + /// + /// Info about seasonal backgrounds available fetched from API - see . + /// Value under this lookup can be null if there are no backgrounds available (or API is not reachable). + /// + SeasonalBackgrounds, + } +} diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs new file mode 100644 index 0000000000..50069be4b2 --- /dev/null +++ b/osu.Game/Configuration/SettingSourceAttribute.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 System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JetBrains.Annotations; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Configuration +{ + /// + /// An attribute to mark a bindable as being exposed to the user via settings controls. + /// Can be used in conjunction with to automatically create UI controls. + /// + /// + /// All controls with set will be placed first in ascending order. + /// All controls with no will come afterward in default order. + /// + [MeansImplicitUse] + [AttributeUsage(AttributeTargets.Property)] + public class SettingSourceAttribute : Attribute + { + public string Label { get; } + + public string Description { get; } + + public int? OrderPosition { get; } + + public SettingSourceAttribute(string label, string description = null) + { + Label = label ?? string.Empty; + Description = description ?? string.Empty; + } + + public SettingSourceAttribute(string label, string description, int orderPosition) + : this(label, description) + { + OrderPosition = orderPosition; + } + } + + public static class SettingSourceExtensions + { + public static IEnumerable CreateSettingsControls(this object obj) + { + foreach (var (attr, property) in obj.GetOrderedSettingsSourceProperties()) + { + object value = property.GetValue(obj); + + switch (value) + { + case BindableNumber bNumber: + yield return new SettingsSlider + { + LabelText = attr.Label, + Current = bNumber, + KeyboardStep = 0.1f, + }; + + break; + + case BindableNumber bNumber: + yield return new SettingsSlider + { + LabelText = attr.Label, + Current = bNumber, + KeyboardStep = 0.1f, + }; + + break; + + case BindableNumber bNumber: + yield return new SettingsSlider + { + LabelText = attr.Label, + Current = bNumber + }; + + break; + + case Bindable bBool: + yield return new SettingsCheckbox + { + LabelText = attr.Label, + Current = bBool + }; + + break; + + case Bindable bString: + yield return new SettingsTextBox + { + LabelText = attr.Label, + Current = bString + }; + + break; + + case IBindable bindable: + var dropdownType = typeof(SettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]); + var dropdown = (Drawable)Activator.CreateInstance(dropdownType); + + dropdownType.GetProperty(nameof(SettingsDropdown.LabelText))?.SetValue(dropdown, attr.Label); + dropdownType.GetProperty(nameof(SettingsDropdown.Current))?.SetValue(dropdown, bindable); + + yield return dropdown; + + break; + + default: + throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} was attached to an unsupported type ({value})"); + } + } + } + + public static IEnumerable<(SettingSourceAttribute, PropertyInfo)> GetSettingsSourceProperties(this object obj) + { + foreach (var property in obj.GetType().GetProperties(BindingFlags.GetProperty | BindingFlags.Public | BindingFlags.Instance)) + { + var attr = property.GetCustomAttribute(true); + + if (attr == null) + continue; + + yield return (attr, property); + } + } + + 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); + } + } +} diff --git a/osu.Game/Configuration/StorageConfigManager.cs b/osu.Game/Configuration/StorageConfigManager.cs new file mode 100644 index 0000000000..929f8f22ad --- /dev/null +++ b/osu.Game/Configuration/StorageConfigManager.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.Configuration; +using osu.Framework.Platform; + +namespace osu.Game.Configuration +{ + public class StorageConfigManager : IniConfigManager + { + protected override string Filename => "storage.ini"; + + public StorageConfigManager(Storage storage) + : base(storage) + { + } + + protected override void InitialiseDefaults() + { + base.InitialiseDefaults(); + + Set(StorageConfig.FullPath, string.Empty); + } + } + + public enum StorageConfig + { + FullPath, + } +} diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index ed65bdc069..8bdc804311 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -7,11 +7,13 @@ using System.IO; using System.Linq; using System.Threading; 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.IO.File; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Threading; @@ -20,6 +22,7 @@ using osu.Game.IO.Archives; using osu.Game.IPC; using osu.Game.Overlays.Notifications; using osu.Game.Utils; +using SharpCompress.Archives.Zip; using SharpCompress.Common; using FileInfo = osu.Game.IO.FileInfo; @@ -31,28 +34,43 @@ namespace osu.Game.Database /// /// The model type. /// The associated file join type. - public abstract class ArchiveModelManager : ArchiveModelManager, ICanAcceptFiles, IModelManager + public abstract class ArchiveModelManager : ICanAcceptFiles, IModelManager where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete - where TFileModel : INamedFileInfo, new() + where TFileModel : class, INamedFileInfo, new() { + private const int import_queue_request_concurrency = 1; + + /// + /// A singleton scheduler shared by all . + /// + /// + /// This scheduler generally performs IO and CPU intensive work so concurrency is limited harshly. + /// It is mainly being used as a queue mechanism for large imports. + /// + private static readonly ThreadedTaskScheduler import_scheduler = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(ArchiveModelManager)); + /// /// Set an endpoint for notifications to be posted to. /// public Action PostNotification { protected get; set; } /// - /// Fired when a new becomes available in the database. + /// Fired when a new or updated becomes available in the database. /// This is not guaranteed to run on the update thread. /// - public event Action ItemAdded; + public IBindable> ItemUpdated => itemUpdated; + + private readonly Bindable> itemUpdated = new Bindable>(); /// - /// Fired when a is removed from the database. + /// Fired when a is removed from the database. /// This is not guaranteed to run on the update thread. /// - public event Action ItemRemoved; + public IBindable> ItemRemoved => itemRemoved; - public virtual string[] HandledExtensions => new[] { ".zip" }; + private readonly Bindable> itemRemoved = new Bindable>(); + + public virtual IEnumerable HandledExtensions => new[] { ".zip" }; public virtual bool SupportsImportFromStable => RuntimeInfo.IsDesktop; @@ -65,13 +83,17 @@ namespace osu.Game.Database // ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised) private ArchiveImportIPCChannel ipc; + private readonly Storage exportStorage; + protected ArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, MutableDatabaseBackedStoreWithFileIncludes modelStore, IIpcHost importHost = null) { ContextFactory = contextFactory; ModelStore = modelStore; - ModelStore.ItemAdded += item => handleEvent(() => ItemAdded?.Invoke(item)); - ModelStore.ItemRemoved += s => handleEvent(() => ItemRemoved?.Invoke(s)); + ModelStore.ItemUpdated += item => handleEvent(() => itemUpdated.Value = new WeakReference(item)); + ModelStore.ItemRemoved += item => handleEvent(() => itemRemoved.Value = new WeakReference(item)); + + exportStorage = storage.GetStorageForDirectory("exports"); Files = new FileStore(contextFactory, storage); @@ -82,7 +104,7 @@ namespace osu.Game.Database } /// - /// Import one or more items from filesystem . + /// Import one or more items from filesystem . /// This will post notifications tracking progress. /// /// One or more archive locations on disk. @@ -95,10 +117,10 @@ namespace osu.Game.Database return Import(notification, paths); } - protected async Task Import(ProgressNotification notification, params string[] paths) + protected async Task> Import(ProgressNotification notification, params string[] paths) { notification.Progress = 0; - notification.Text = "Import is initialising..."; + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising..."; int current = 0; @@ -134,7 +156,7 @@ namespace osu.Game.Database if (imported.Count == 0) { - notification.Text = "Import failed!"; + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed!"; notification.State = ProgressNotificationState.Cancelled; } else @@ -155,10 +177,12 @@ namespace osu.Game.Database notification.State = ProgressNotificationState.Completed; } + + return imported; } /// - /// Import one from the filesystem and delete the file on success. + /// Import one from the filesystem and delete the file on success. /// /// The archive location on disk. /// An optional cancellation token. @@ -208,9 +232,8 @@ namespace osu.Game.Database { model = CreateModel(archive); - if (model == null) return Task.FromResult(null); - - model.Hash = computeHash(archive); + if (model == null) + return Task.FromResult(null); } catch (TaskCanceledException) { @@ -230,9 +253,12 @@ namespace osu.Game.Database /// Generally should include all file types which determine the file's uniqueness. /// Large files should be avoided if possible. /// + /// + /// This is only used by the default hash implementation. If is overridden, it will not be used. + /// protected abstract string[] HashableFileTypes { get; } - protected static void LogForModel(TModel model, string message, Exception e = null) + internal static void LogForModel(TModel model, string message, Exception e = null) { string prefix = $"[{(model?.Hash ?? "?????").Substring(0, 5)}]"; @@ -245,19 +271,31 @@ namespace osu.Game.Database /// /// Create a SHA-2 hash from the provided archive based on file content of all files matching . /// - private string computeHash(ArchiveReader reader) + /// + /// In the case of no matching files, a hash will be generated from the passed archive's . + /// + protected virtual string ComputeHash(TModel item, ArchiveReader reader = null) { // for now, concatenate all .osu files in the set to create a unique hash. MemoryStream hashable = new MemoryStream(); - foreach (string file in reader.Filenames.Where(f => HashableFileTypes.Any(f.EndsWith))) - using (Stream s = reader.GetStream(file)) - s.CopyTo(hashable); - return hashable.ComputeSHA2Hash(); + foreach (TFileModel file in item.Files.Where(f => HashableFileTypes.Any(ext => f.Filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f.Filename)) + { + using (Stream s = Files.Store.GetStream(file.FileInfo.StoragePath)) + s.CopyTo(hashable); + } + + if (hashable.Length > 0) + return hashable.ComputeSHA2Hash(); + + if (reader != null) + return reader.Name.ComputeSHA2Hash(); + + return item.Hash; } /// - /// Import an item from a . + /// Import an item from a . /// /// The model to be imported. /// An optional archive to use for model population. @@ -283,6 +321,7 @@ namespace osu.Game.Database LogForModel(item, "Beginning import..."); item.Files = archive != null ? createFileInfos(archive, Files) : new List(); + item.Hash = ComputeHash(item, archive); await Populate(item, archive, cancellationToken); @@ -296,7 +335,7 @@ namespace osu.Game.Database if (existing != null) { - if (CanUndelete(existing, item)) + if (CanReuseExisting(existing, item)) { Undelete(existing); LogForModel(item, $"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); @@ -336,14 +375,103 @@ namespace osu.Game.Database flushEvents(true); return item; - }, cancellationToken, TaskCreationOptions.HideScheduler, IMPORT_SCHEDULER).Unwrap(); + }, cancellationToken, TaskCreationOptions.HideScheduler, import_scheduler).Unwrap(); + + /// + /// Exports an item to a legacy (.zip based) package. + /// + /// The item to export. + public void Export(TModel item) + { + var retrievedItem = ModelStore.ConsumableItems.FirstOrDefault(s => s.ID == item.ID); + + if (retrievedItem == null) + throw new ArgumentException("Specified model could not be found", nameof(item)); + + using (var archive = ZipArchive.Create()) + { + foreach (var file in retrievedItem.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(); + } + } + + /// + /// Replace an existing file with a new version. + /// + /// The item to operate on. + /// The existing file to be replaced. + /// The new file contents. + /// An optional filename for the new file. Will use the previous filename if not specified. + public void ReplaceFile(TModel model, TFileModel file, Stream contents, string filename = null) + { + using (ContextFactory.GetForWrite()) + { + DeleteFile(model, file); + AddFile(model, contents, filename ?? file.Filename); + } + } + + /// + /// Delete new file. + /// + /// The item to operate on. + /// The existing file to be deleted. + public void DeleteFile(TModel model, TFileModel file) + { + using (var usage = ContextFactory.GetForWrite()) + { + // Dereference the existing file info, since the file model will be removed. + if (file.FileInfo != null) + { + Files.Dereference(file.FileInfo); + + // This shouldn't be required, but here for safety in case the provided TModel is not being change tracked + // Definitely can be removed once we rework the database backend. + usage.Context.Set().Remove(file); + } + + model.Files.Remove(file); + } + } + + /// + /// Add a new file. + /// + /// The item to operate on. + /// The new file contents. + /// The filename for the new file. + public void AddFile(TModel model, Stream contents, string filename) + { + using (ContextFactory.GetForWrite()) + { + model.Files.Add(new TFileModel + { + Filename = filename, + FileInfo = Files.Add(contents) + }); + + Update(model); + } + } /// /// Perform an update of the specified item. - /// TODO: Support file changes. + /// TODO: Support file additions/removals. /// /// The item to update. - public void Update(TModel item) => ModelStore.Update(item); + public void Update(TModel item) + { + using (ContextFactory.GetForWrite()) + { + item.Hash = ComputeHash(item); + ModelStore.Update(item); + } + } /// /// Delete an item from the manager. @@ -387,20 +515,17 @@ namespace osu.Game.Database int i = 0; - using (ContextFactory.GetForWrite()) + foreach (var b in items) { - foreach (var b in items) - { - if (notification.State == ProgressNotificationState.Cancelled) - // user requested abort - return; + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; - notification.Text = $"Deleting {HumanisedModelName}s ({++i} of {items.Count})"; + notification.Text = $"Deleting {HumanisedModelName}s ({++i} of {items.Count})"; - Delete(b); + Delete(b); - notification.Progress = (float)i / items.Count; - } + notification.Progress = (float)i / items.Count; } notification.State = ProgressNotificationState.Completed; @@ -426,20 +551,17 @@ namespace osu.Game.Database int i = 0; - using (ContextFactory.GetForWrite()) + foreach (var item in items) { - foreach (var item in items) - { - if (notification.State == ProgressNotificationState.Cancelled) - // user requested abort - return; + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; - notification.Text = $"Restoring ({++i} of {items.Count})"; + notification.Text = $"Restoring ({++i} of {items.Count})"; - Undelete(item); + Undelete(item); - notification.Progress = (float)i / items.Count; - } + notification.Progress = (float)i / items.Count; } notification.State = ProgressNotificationState.Completed; @@ -470,14 +592,22 @@ namespace osu.Game.Database { var fileInfos = new List(); + string prefix = reader.Filenames.GetCommonPrefix(); + if (!(prefix.EndsWith('/') || prefix.EndsWith('\\'))) + prefix = string.Empty; + // import files to manager foreach (string file in reader.Filenames) + { using (Stream s = reader.GetStream(file)) + { fileInfos.Add(new TFileModel { - Filename = FileSafety.PathStandardise(file), + Filename = file.Substring(prefix.Length).ToStandardisedPath(), FileInfo = files.Add(s) }); + } + } return fileInfos; } @@ -569,13 +699,29 @@ namespace osu.Game.Database protected TModel CheckForExisting(TModel model) => model.Hash == null ? null : ModelStore.ConsumableItems.FirstOrDefault(b => b.Hash == model.Hash); /// - /// After an existing is found during an import process, the default behaviour is to restore the existing + /// After an existing is found during an import process, the default behaviour is to use/restore the existing /// item and skip the import. This method allows changing that behaviour. /// /// The existing model. /// The newly imported model. - /// Whether the existing model should be restored and used. Returning false will delete the existing a force a re-import. - protected virtual bool CanUndelete(TModel existing, TModel import) => true; + /// Whether the existing model should be restored and used. Returning false will delete the existing and force a re-import. + protected virtual bool CanReuseExisting(TModel existing, TModel import) => + // for the best or worst, we copy and import files of a new import before checking whether + // it is a duplicate. so to check if anything has changed, we can just compare all FileInfo IDs. + getIDs(existing.Files).SequenceEqual(getIDs(import.Files)) && + getFilenames(existing.Files).SequenceEqual(getFilenames(import.Files)); + + private IEnumerable getIDs(List files) + { + foreach (var f in files.OrderBy(f => f.Filename)) + yield return f.FileInfo.ID; + } + + private IEnumerable getFilenames(List files) + { + foreach (var f in files.OrderBy(f => f.Filename)) + yield return f.Filename; + } private DbSet queryModel() => ContextFactory.Get().Set(); @@ -638,26 +784,21 @@ namespace osu.Game.Database private void handleEvent(Action a) { if (delayingEvents) + { lock (queuedEvents) queuedEvents.Add(a); + } else a.Invoke(); } #endregion - } - public abstract class ArchiveModelManager - { - private const int import_queue_request_concurrency = 1; - - /// - /// A singleton scheduler shared by all . - /// - /// - /// This scheduler generally performs IO and CPU intensive work so concurrency is limited harshly. - /// It is mainly being used as a queue mechanism for large imports. - /// - protected static readonly ThreadedTaskScheduler IMPORT_SCHEDULER = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(ArchiveModelManager)); + private string getValidFilename(string filename) + { + foreach (char c in Path.GetInvalidFileNameChars()) + filename = filename.Replace(c, '_'); + return filename; + } } } diff --git a/osu.Game/Database/DatabaseContextFactory.cs b/osu.Game/Database/DatabaseContextFactory.cs index bb6bef1c50..1cceb59b11 100644 --- a/osu.Game/Database/DatabaseContextFactory.cs +++ b/osu.Game/Database/DatabaseContextFactory.cs @@ -149,8 +149,24 @@ namespace osu.Game.Database lock (writeLock) { recycleThreadContexts(); - storage.DeleteDatabase(database_name); + + try + { + storage.DeleteDatabase(database_name); + } + catch + { + // for now we are not sure why file handles are kept open by EF, but this is generally only used in testing + } } } + + public void FlushConnections() + { + foreach (var context in threadContexts.Values) + context.Dispose(); + + recycleThreadContexts(); + } } } diff --git a/osu.Game/Database/DatabaseWriteUsage.cs b/osu.Game/Database/DatabaseWriteUsage.cs index 1fd2f23d50..ddafd77066 100644 --- a/osu.Game/Database/DatabaseWriteUsage.cs +++ b/osu.Game/Database/DatabaseWriteUsage.cs @@ -26,7 +26,7 @@ namespace osu.Game.Database /// Whether this write usage will commit a transaction on completion. /// If false, there is a parent usage responsible for transaction commit. /// - public bool IsTransactionLeader = false; + public bool IsTransactionLeader; protected void Dispose(bool disposing) { diff --git a/osu.Game/Database/DownloadableArchiveModelManager.cs b/osu.Game/Database/DownloadableArchiveModelManager.cs index 78c0837ce9..8f469ca590 100644 --- a/osu.Game/Database/DownloadableArchiveModelManager.cs +++ b/osu.Game/Database/DownloadableArchiveModelManager.cs @@ -10,6 +10,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using osu.Framework.Bindables; namespace osu.Game.Database { @@ -21,11 +22,15 @@ namespace osu.Game.Database /// The associated file join type. public abstract class DownloadableArchiveModelManager : ArchiveModelManager, IModelDownloader where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete, IEquatable - where TFileModel : INamedFileInfo, new() + where TFileModel : class, INamedFileInfo, new() { - public event Action> DownloadBegan; + public IBindable>> DownloadBegan => downloadBegan; - public event Action> DownloadFailed; + private readonly Bindable>> downloadBegan = new Bindable>>(); + + public IBindable>> DownloadFailed => downloadFailed; + + private readonly Bindable>> downloadFailed = new Bindable>>(); private readonly IAPIProvider api; @@ -33,7 +38,8 @@ namespace osu.Game.Database private readonly MutableDatabaseBackedStoreWithFileIncludes modelStore; - protected DownloadableArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, IAPIProvider api, MutableDatabaseBackedStoreWithFileIncludes modelStore, IIpcHost importHost = null) + protected DownloadableArchiveModelManager(Storage storage, IDatabaseContextFactory contextFactory, IAPIProvider api, MutableDatabaseBackedStoreWithFileIncludes modelStore, + IIpcHost importHost = null) : base(storage, contextFactory, modelStore, importHost) { this.api = api; @@ -41,17 +47,17 @@ namespace osu.Game.Database } /// - /// Creates the download request for this . + /// Creates the download request for this . /// - /// The to be downloaded. + /// The to be downloaded. /// Whether this download should be optimised for slow connections. Generally means extras are not included in the download bundle. /// The request object. protected abstract ArchiveDownloadRequest CreateDownloadRequest(TModel model, bool minimiseDownloadSize); /// - /// Begin a download for the requested . + /// Begin a download for the requested . /// - /// The to be downloaded. + /// The to be downloaded. /// Whether this download should be optimised for slow connections. Generally means extras are not included in the download bundle. /// Whether the download was started. public bool Download(TModel model, bool minimiseDownloadSize = false) @@ -76,38 +82,43 @@ 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. - await Import(notification, filename); + var imported = await Import(notification, filename); + + // for now a failed import will be marked as a failed download for simplicity. + if (!imported.Any()) + downloadFailed.Value = new WeakReference>(request); + currentDownloads.Remove(request); }, TaskCreationOptions.LongRunning); }; - request.Failure += error => - { - DownloadFailed?.Invoke(request); - - if (error is OperationCanceledException) return; - - notification.State = ProgressNotificationState.Cancelled; - Logger.Error(error, $"{HumanisedModelName.Titleize()} download failed!"); - currentDownloads.Remove(request); - }; + request.Failure += triggerFailure; notification.CancelRequested += () => { request.Cancel(); - currentDownloads.Remove(request); - notification.State = ProgressNotificationState.Cancelled; return true; }; currentDownloads.Add(request); PostNotification?.Invoke(notification); - Task.Factory.StartNew(() => request.Perform(api), TaskCreationOptions.LongRunning); - - DownloadBegan?.Invoke(request); + api.PerformAsync(request); + downloadBegan.Value = new WeakReference>(request); return true; + + void triggerFailure(Exception error) + { + currentDownloads.Remove(request); + + downloadFailed.Value = new WeakReference>(request); + + notification.State = ProgressNotificationState.Cancelled; + + if (!(error is OperationCanceledException)) + Logger.Error(error, $"{HumanisedModelName.Titleize()} download failed!"); + } } public bool IsAvailableLocally(TModel model) => CheckLocalAvailability(model, modelStore.ConsumableItems.Where(m => !m.DeletePending)); @@ -115,10 +126,11 @@ namespace osu.Game.Database /// /// Performs implementation specific comparisons to determine whether a given model is present in the local store. /// - /// The whose existence needs to be checked. + /// The whose existence needs to be checked. /// The usable items present in the store. - /// Whether the exists. - protected abstract bool CheckLocalAvailability(TModel model, IQueryable items); + /// Whether the exists. + protected virtual bool CheckLocalAvailability(TModel model, IQueryable items) + => model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any()); public ArchiveDownloadRequest GetExistingDownload(TModel model) => currentDownloads.Find(r => r.Model.Equals(model)); diff --git a/osu.Game/Database/ICanAcceptFiles.cs b/osu.Game/Database/ICanAcceptFiles.cs index b9f882468d..e4d92d957c 100644 --- a/osu.Game/Database/ICanAcceptFiles.cs +++ b/osu.Game/Database/ICanAcceptFiles.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 System.Threading.Tasks; namespace osu.Game.Database @@ -19,6 +20,6 @@ namespace osu.Game.Database /// /// An array of accepted file extensions (in the standard format of ".abc"). /// - string[] HandledExtensions { get; } + IEnumerable HandledExtensions { get; } } } diff --git a/osu.Game/Database/IModelDownloader.cs b/osu.Game/Database/IModelDownloader.cs index f6f4b0aa42..0cb633280e 100644 --- a/osu.Game/Database/IModelDownloader.cs +++ b/osu.Game/Database/IModelDownloader.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 osu.Game.Online.API; using System; +using osu.Game.Online.API; +using osu.Framework.Bindables; namespace osu.Game.Database { @@ -14,34 +15,36 @@ namespace osu.Game.Database where TModel : class { /// - /// Fired when a download begins. + /// Fired when a download begins. + /// This is NOT run on the update thread and should be scheduled. /// - event Action> DownloadBegan; + IBindable>> DownloadBegan { get; } /// - /// Fired when a download is interrupted, either due to user cancellation or failure. + /// Fired when a download is interrupted, either due to user cancellation or failure. + /// This is NOT run on the update thread and should be scheduled. /// - event Action> DownloadFailed; + IBindable>> DownloadFailed { get; } /// - /// Checks whether a given is already available in the local store. + /// Checks whether a given is already available in the local store. /// - /// The whose existence needs to be checked. - /// Whether the exists. + /// The whose existence needs to be checked. + /// Whether the exists. bool IsAvailableLocally(TModel model); /// - /// Begin a download for the requested . + /// Begin a download for the requested . /// - /// The to be downloaded. + /// The to be downloaded. /// Whether this download should be optimised for slow connections. Generally means extras are not included in the download bundle.. /// Whether the download was started. bool Download(TModel model, bool minimiseDownloadSize); /// - /// Gets an existing download request if it exists. + /// Gets an existing download request if it exists. /// - /// The whose request is wanted. + /// The whose request is wanted. /// The object if it exists, otherwise null. ArchiveDownloadRequest GetExistingDownload(TModel model); } diff --git a/osu.Game/Database/IModelManager.cs b/osu.Game/Database/IModelManager.cs index 884814cb38..7f7e5565f1 100644 --- a/osu.Game/Database/IModelManager.cs +++ b/osu.Game/Database/IModelManager.cs @@ -2,18 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; namespace osu.Game.Database { /// - /// Represents a model manager that publishes events when s are added or removed. + /// Represents a model manager that publishes events when s are added or removed. /// /// The model type. - public interface IModelManager + public interface IModelManager where TModel : class { - event Action ItemAdded; + IBindable> ItemUpdated { get; } - event Action ItemRemoved; + IBindable> ItemRemoved { get; } } } diff --git a/osu.Game/Database/MemoryCachingComponent.cs b/osu.Game/Database/MemoryCachingComponent.cs new file mode 100644 index 0000000000..d913e66428 --- /dev/null +++ b/osu.Game/Database/MemoryCachingComponent.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.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Graphics; + +namespace osu.Game.Database +{ + /// + /// A component which performs lookups (or calculations) and caches the results. + /// Currently not persisted between game sessions. + /// + public abstract class MemoryCachingComponent : Component + { + private readonly ConcurrentDictionary cache = new ConcurrentDictionary(); + + protected virtual bool CacheNullValues => true; + + /// + /// Retrieve the cached value for the given lookup. + /// + /// The lookup to retrieve. + /// An optional to cancel the operation. + protected async Task GetAsync([NotNull] TLookup lookup, CancellationToken token = default) + { + if (CheckExists(lookup, out TValue performance)) + return performance; + + var computed = await ComputeValueAsync(lookup, token); + + if (computed != null || CacheNullValues) + cache[lookup] = computed; + + return computed; + } + + protected bool CheckExists([NotNull] TLookup lookup, out TValue value) => + cache.TryGetValue(lookup, out value); + + /// + /// Called on cache miss to compute the value for the specified lookup. + /// + /// The lookup to retrieve. + /// An optional to cancel the operation. + /// The computed value. + protected abstract Task ComputeValueAsync(TLookup lookup, CancellationToken token = default); + } +} diff --git a/osu.Game/Database/MutableDatabaseBackedStore.cs b/osu.Game/Database/MutableDatabaseBackedStore.cs index 39a48b5be6..c9d0c4bc41 100644 --- a/osu.Game/Database/MutableDatabaseBackedStore.cs +++ b/osu.Game/Database/MutableDatabaseBackedStore.cs @@ -16,7 +16,14 @@ namespace osu.Game.Database public abstract class MutableDatabaseBackedStore : DatabaseBackedStore where T : class, IHasPrimaryKey, ISoftDelete { - public event Action ItemAdded; + /// + /// Fired when an item was added or updated. + /// + public event Action ItemUpdated; + + /// + /// Fired when an item was removed. + /// public event Action ItemRemoved; protected MutableDatabaseBackedStore(IDatabaseContextFactory contextFactory, Storage storage = null) @@ -30,7 +37,7 @@ namespace osu.Game.Database public IQueryable ConsumableItems => AddIncludesForConsumption(ContextFactory.Get().Set()); /// - /// Add a to the database. + /// Add a to the database. /// /// The item to add. public void Add(T item) @@ -41,11 +48,11 @@ namespace osu.Game.Database context.Attach(item); } - ItemAdded?.Invoke(item); + ItemUpdated?.Invoke(item); } /// - /// Update a in the database. + /// Update a in the database. /// /// The item to update. public void Update(T item) @@ -53,12 +60,11 @@ namespace osu.Game.Database using (var usage = ContextFactory.GetForWrite()) usage.Context.Update(item); - ItemRemoved?.Invoke(item); - ItemAdded?.Invoke(item); + ItemUpdated?.Invoke(item); } /// - /// Delete a from the database. + /// Delete a from the database. /// /// The item to delete. public bool Delete(T item) @@ -77,7 +83,7 @@ namespace osu.Game.Database } /// - /// Restore a from a deleted state. + /// Restore a from a deleted state. /// /// The item to undelete. public bool Undelete(T item) @@ -91,7 +97,7 @@ namespace osu.Game.Database item.DeletePending = false; } - ItemAdded?.Invoke(item); + ItemUpdated?.Invoke(item); return true; } diff --git a/osu.Game/Database/MutableDatabaseBackedStoreWithFileIncludes.cs b/osu.Game/Database/MutableDatabaseBackedStoreWithFileIncludes.cs index 5d6ff6b09b..102081cd65 100644 --- a/osu.Game/Database/MutableDatabaseBackedStoreWithFileIncludes.cs +++ b/osu.Game/Database/MutableDatabaseBackedStoreWithFileIncludes.cs @@ -7,9 +7,9 @@ using osu.Framework.Platform; namespace osu.Game.Database { - public abstract class MutableDatabaseBackedStoreWithFileIncludes : MutableDatabaseBackedStore - where T : class, IHasPrimaryKey, ISoftDelete, IHasFiles - where U : INamedFileInfo + public abstract class MutableDatabaseBackedStoreWithFileIncludes : MutableDatabaseBackedStore + where T : class, IHasPrimaryKey, ISoftDelete, IHasFiles + where TFileInfo : INamedFileInfo { protected MutableDatabaseBackedStoreWithFileIncludes(IDatabaseContextFactory contextFactory, Storage storage = null) : base(contextFactory, storage) diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs index ea3318598f..2ae07b3cf8 100644 --- a/osu.Game/Database/OsuDbContext.cs +++ b/osu.Game/Database/OsuDbContext.cs @@ -166,19 +166,6 @@ namespace osu.Game.Database // no-op. called by tooling. } - private class OsuDbLoggerProvider : ILoggerProvider - { - #region Disposal - - public void Dispose() - { - } - - #endregion - - public ILogger CreateLogger(string categoryName) => new OsuDbLogger(); - } - private class OsuDbLogger : ILogger { public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs new file mode 100644 index 0000000000..c85ad6d651 --- /dev/null +++ b/osu.Game/Database/UserLookupCache.cs @@ -0,0 +1,117 @@ +// 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 osu.Framework.Allocation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Users; + +namespace osu.Game.Database +{ + public class UserLookupCache : MemoryCachingComponent + { + private readonly HashSet nextTaskIDs = new HashSet(); + + [Resolved] + private IAPIProvider api { get; set; } + + private readonly object taskAssignmentLock = new object(); + + private Task> pendingRequest; + + /// + /// Whether has already grabbed its IDs. + /// + private bool pendingRequestConsumedIDs; + + public Task GetUserAsync(int userId, CancellationToken token = default) => GetAsync(userId, token); + + protected override async Task ComputeValueAsync(int lookup, CancellationToken token = default) + { + var users = await getQueryTaskForUser(lookup); + return users.FirstOrDefault(u => u.Id == lookup); + } + + /// + /// Return the task responsible for fetching the provided user. + /// This may be part of a larger batch lookup to reduce web requests. + /// + /// The user to lookup. + /// The task responsible for the lookup. + private Task> getQueryTaskForUser(int userId) + { + lock (taskAssignmentLock) + { + nextTaskIDs.Add(userId); + + // if there's a pending request which hasn't been started yet (and is not yet full), we can wait on it. + if (pendingRequest != null && !pendingRequestConsumedIDs && nextTaskIDs.Count < 50) + return pendingRequest; + + return queueNextTask(nextLookup); + } + + List nextLookup() + { + int[] lookupItems; + + lock (taskAssignmentLock) + { + pendingRequestConsumedIDs = true; + lookupItems = nextTaskIDs.ToArray(); + nextTaskIDs.Clear(); + + if (lookupItems.Length == 0) + { + queueNextTask(null); + return new List(); + } + } + + var request = new GetUsersRequest(lookupItems); + + // rather than queueing, we maintain our own single-threaded request stream. + api.Perform(request); + + return request.Result?.Users; + } + } + + /// + /// Queues new work at the end of the current work tasks. + /// Ensures the provided work is eventually run. + /// + /// The work to run. Can be null to signify the end of available work. + /// The task tracking this work. + private Task> queueNextTask(Func> work) + { + lock (taskAssignmentLock) + { + if (work == null) + { + pendingRequest = null; + pendingRequestConsumedIDs = false; + } + else if (pendingRequest == null) + { + // special case for the first request ever. + pendingRequest = Task.Run(work); + pendingRequestConsumedIDs = false; + } + else + { + // append the new request on to the last to be executed. + pendingRequest = pendingRequest.ContinueWith(_ => work()); + pendingRequestConsumedIDs = false; + } + + return pendingRequest; + } + } + } +} diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs new file mode 100644 index 0000000000..1790eb608e --- /dev/null +++ b/osu.Game/Extensions/DrawableExtensions.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; +using osu.Framework.Input.Bindings; +using osu.Framework.Threading; + +namespace osu.Game.Extensions +{ + public static class DrawableExtensions + { + /// + /// 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. + /// + /// + /// The returned delegate can be cancelled to stop repeat events from firing (usually in ). + /// + /// The which is handling the repeat. + /// The to schedule repetitions on. + /// The to be invoked once immediately and with every repetition. + /// A which can be cancelled to stop the repeat events from firing. + public static ScheduledDelegate BeginKeyRepeat(this IKeyBindingHandler handler, Scheduler scheduler, Action action) + { + action(); + + ScheduledDelegate repeatDelegate = new ScheduledDelegate(action, handler.Time.Current + 250, 70); + scheduler.Add(repeatDelegate); + return repeatDelegate; + } + } +} diff --git a/osu.Game/Extensions/EditorDisplayExtensions.cs b/osu.Game/Extensions/EditorDisplayExtensions.cs new file mode 100644 index 0000000000..f749b88b46 --- /dev/null +++ b/osu.Game/Extensions/EditorDisplayExtensions.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.Extensions +{ + public static class EditorDisplayExtensions + { + /// + /// Get an editor formatted string (mm:ss:mss) + /// + /// A time value in milliseconds. + /// An editor formatted display string. + public static string ToEditorFormattedString(this double milliseconds) => + ToEditorFormattedString(TimeSpan.FromMilliseconds(milliseconds)); + + /// + /// Get an editor formatted string (mm:ss:mss) + /// + /// A time value. + /// An editor formatted display string. + public static string ToEditorFormattedString(this TimeSpan timeSpan) => + $"{(timeSpan < TimeSpan.Zero ? "-" : string.Empty)}{timeSpan:mm\\:ss\\:fff}"; + } +} diff --git a/osu.Game/Extensions/WebRequestExtensions.cs b/osu.Game/Extensions/WebRequestExtensions.cs new file mode 100644 index 0000000000..b940c7498b --- /dev/null +++ b/osu.Game/Extensions/WebRequestExtensions.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.Framework.IO.Network; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Extensions +{ + public static class WebRequestExtensions + { + /// + /// Add a pagination cursor to the web request in the format required by osu-web. + /// + public static void AddCursor(this WebRequest webRequest, Cursor cursor) + { + cursor?.Properties.ForEach(x => + { + webRequest.AddParameter("cursor[" + x.Key + "]", x.Value.ToString()); + }); + } + } +} diff --git a/osu.Game/Graphics/Backgrounds/Background.cs b/osu.Game/Graphics/Backgrounds/Background.cs index d13475189d..c90b1e0e98 100644 --- a/osu.Game/Graphics/Backgrounds/Background.cs +++ b/osu.Game/Graphics/Backgrounds/Background.cs @@ -16,6 +16,8 @@ namespace osu.Game.Graphics.Backgrounds /// public class Background : CompositeDrawable { + private const float blur_scale = 0.5f; + public readonly Sprite Sprite; private readonly string textureName; @@ -43,7 +45,7 @@ namespace osu.Game.Graphics.Backgrounds Sprite.Texture = textures.Get(textureName); } - public Vector2 BlurSigma => bufferedContainer?.BlurSigma ?? Vector2.Zero; + public Vector2 BlurSigma => bufferedContainer?.BlurSigma / blur_scale ?? Vector2.Zero; /// /// Smoothly adjusts over time. @@ -57,13 +59,17 @@ namespace osu.Game.Graphics.Backgrounds AddInternal(bufferedContainer = new BufferedContainer { - CacheDrawnFrameBuffer = true, RelativeSizeAxes = Axes.Both, + CacheDrawnFrameBuffer = true, + RedrawOnScale = false, Child = Sprite }); } - bufferedContainer?.BlurTo(newBlurSigma, duration, easing); + if (bufferedContainer != null) + bufferedContainer.FrameBufferScale = newBlurSigma == Vector2.Zero ? Vector2.One : new Vector2(blur_scale); + + bufferedContainer?.BlurTo(newBlurSigma * blur_scale, duration, easing); } } } diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs new file mode 100644 index 0000000000..058d2ed0f9 --- /dev/null +++ b/osu.Game/Graphics/Backgrounds/BeatmapBackground.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 osu.Framework.Allocation; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps; + +namespace osu.Game.Graphics.Backgrounds +{ + public class BeatmapBackground : Background + { + public readonly WorkingBeatmap Beatmap; + + private readonly string fallbackTextureName; + + public BeatmapBackground(WorkingBeatmap beatmap, string fallbackTextureName = @"Backgrounds/bg1") + { + Beatmap = beatmap; + this.fallbackTextureName = fallbackTextureName; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + Sprite.Texture = Beatmap?.Background ?? textures.Get(fallbackTextureName); + } + } +} diff --git a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs new file mode 100644 index 0000000000..a48da37804 --- /dev/null +++ b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs @@ -0,0 +1,103 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Graphics.Backgrounds +{ + public class SeasonalBackgroundLoader : Component + { + /// + /// Fired when background should be changed due to receiving backgrounds from API + /// or when the user setting is changed (as it might require unloading the seasonal background). + /// + public event Action SeasonalBackgroundChanged; + + [Resolved] + private IAPIProvider api { get; set; } + + private readonly IBindable apiState = new Bindable(); + private Bindable seasonalBackgroundMode; + private Bindable seasonalBackgrounds; + + private int current; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, SessionStatics sessionStatics) + { + seasonalBackgroundMode = config.GetBindable(OsuSetting.SeasonalBackgroundMode); + seasonalBackgroundMode.BindValueChanged(_ => SeasonalBackgroundChanged?.Invoke()); + + seasonalBackgrounds = sessionStatics.GetBindable(Static.SeasonalBackgrounds); + seasonalBackgrounds.BindValueChanged(_ => SeasonalBackgroundChanged?.Invoke()); + + apiState.BindTo(api.State); + apiState.BindValueChanged(fetchSeasonalBackgrounds, true); + } + + private void fetchSeasonalBackgrounds(ValueChangedEvent stateChanged) + { + if (seasonalBackgrounds.Value != null || stateChanged.NewValue != APIState.Online) + return; + + var request = new GetSeasonalBackgroundsRequest(); + request.Success += response => + { + seasonalBackgrounds.Value = response; + current = RNG.Next(0, response.Backgrounds?.Count ?? 0); + }; + + api.PerformAsync(request); + } + + public SeasonalBackground LoadNextBackground() + { + if (seasonalBackgroundMode.Value == SeasonalBackgroundMode.Never + || (seasonalBackgroundMode.Value == SeasonalBackgroundMode.Sometimes && !isInSeason)) + { + return null; + } + + var backgrounds = seasonalBackgrounds.Value?.Backgrounds; + if (backgrounds == null || !backgrounds.Any()) + return null; + + current = (current + 1) % backgrounds.Count; + string url = backgrounds[current].Url; + + return new SeasonalBackground(url); + } + + private bool isInSeason => seasonalBackgrounds.Value != null && DateTimeOffset.Now < seasonalBackgrounds.Value.EndDate; + } + + [LongRunningLoad] + public class SeasonalBackground : Background + { + private readonly string url; + private const string fallback_texture_name = @"Backgrounds/bg1"; + + public SeasonalBackground(string url) + { + this.url = url; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { + Sprite.Texture = textures.Get(url) ?? textures.Get(fallback_texture_name); + // ensure we're not loading in without a transition. + this.FadeInFromZero(200, Easing.InOutSine); + } + } +} diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 2b68e8530d..5b0fa44444 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osuTK; using osuTK.Graphics; using System; @@ -29,8 +29,33 @@ namespace osu.Game.Graphics.Backgrounds /// private const float edge_smoothness = 1; - public Color4 ColourLight = Color4.White; - public Color4 ColourDark = Color4.Black; + private Color4 colourLight = Color4.White; + + public Color4 ColourLight + { + get => colourLight; + set + { + if (colourLight == value) return; + + colourLight = value; + updateColours(); + } + } + + private Color4 colourDark = Color4.Black; + + public Color4 ColourDark + { + get => colourDark; + set + { + if (colourDark == value) return; + + colourDark = value; + updateColours(); + } + } /// /// Whether we want to expire triangles as they exit our draw area completely. @@ -61,13 +86,24 @@ namespace osu.Game.Graphics.Backgrounds /// public float Velocity = 1; + private readonly Random stableRandom; + + private float nextRandom() => (float)(stableRandom?.NextDouble() ?? RNG.NextSingle()); + private readonly SortedList parts = new SortedList(Comparer.Default); private IShader shader; private readonly Texture texture; - public Triangles() + /// + /// Construct a new triangle visualisation. + /// + /// An optional seed to stabilise random positions / attributes. Note that this does not guarantee stable playback when seeking in time. + public Triangles(int? seed = null) { + if (seed != null) + stableRandom = new Random(seed.Value); + texture = Texture.WhitePixel; } @@ -104,14 +140,14 @@ namespace osu.Game.Graphics.Backgrounds { base.Update(); - Invalidate(Invalidation.DrawNode, shallPropagate: false); + Invalidate(Invalidation.DrawNode); if (CreateNewTriangles) addTriangles(false); float adjustedAlpha = HideAlphaDiscrepancies // Cubically scale alpha to make it drop off more sharply. - ? (float)Math.Pow(DrawColourInfo.Colour.AverageColour.Linear.A, 3) + ? MathF.Pow(DrawColourInfo.Colour.AverageColour.Linear.A, 3) : 1; float elapsedSeconds = (float)Time.Elapsed / 1000; @@ -150,8 +186,9 @@ namespace osu.Game.Graphics.Backgrounds { TriangleParticle particle = CreateTriangle(); - particle.Position = new Vector2(RNG.NextSingle(), randomY ? RNG.NextSingle() : 1); - particle.Colour = CreateTriangleShade(); + particle.Position = new Vector2(nextRandom(), randomY ? nextRandom() : 1); + particle.ColourShade = nextRandom(); + particle.Colour = CreateTriangleShade(particle.ColourShade); return particle; } @@ -165,10 +202,10 @@ namespace osu.Game.Graphics.Backgrounds const float std_dev = 0.16f; const float mean = 0.5f; - float u1 = 1 - RNG.NextSingle(); //uniform(0,1] random floats - float u2 = 1 - RNG.NextSingle(); - float randStdNormal = (float)(Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2)); //random normal(0,1) - var scale = Math.Max(triangleScale * (mean + std_dev * randStdNormal), 0.1f); //random normal(mean,stdDev^2) + float u1 = 1 - nextRandom(); //uniform(0,1] random floats + float u2 = 1 - nextRandom(); + float randStdNormal = (float)(Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2)); // random normal(0,1) + var scale = Math.Max(triangleScale * (mean + std_dev * randStdNormal), 0.1f); // random normal(mean,stdDev^2) return new TriangleParticle { Scale = scale }; } @@ -177,7 +214,17 @@ namespace osu.Game.Graphics.Backgrounds /// Creates a shade of colour for the triangles. /// /// The colour. - protected virtual Color4 CreateTriangleShade() => Interpolation.ValueAt(RNG.NextSingle(), ColourDark, ColourLight, 0, 1); + protected virtual Color4 CreateTriangleShade(float shade) => Interpolation.ValueAt(shade, colourDark, colourLight, 0, 1); + + private void updateColours() + { + for (int i = 0; i < parts.Count; i++) + { + TriangleParticle newParticle = parts[i]; + newParticle.Colour = CreateTriangleShade(newParticle.ColourShade); + parts[i] = newParticle; + } + } protected override DrawNode CreateDrawNode() => new TrianglesDrawNode(this); @@ -191,7 +238,7 @@ namespace osu.Game.Graphics.Backgrounds private readonly List parts = new List(); private Vector2 size; - private TriangleBatch vertexBatch; + private QuadBatch vertexBatch; public TrianglesDrawNode(Triangles source) : base(source) @@ -217,7 +264,7 @@ namespace osu.Game.Graphics.Backgrounds if (Source.AimCount > 0 && (vertexBatch == null || vertexBatch.Size != Source.AimCount)) { vertexBatch?.Dispose(); - vertexBatch = new TriangleBatch(Source.AimCount, 1); + vertexBatch = new QuadBatch(Source.AimCount, 1); } shader.Bind(); @@ -264,6 +311,12 @@ namespace osu.Game.Graphics.Backgrounds /// public Vector2 Position; + /// + /// The colour shade of the triangle. + /// This is needed for colour recalculation of visible triangles when or is changed. + /// + public float ColourShade; + /// /// The colour of the triangle. /// diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index 621eeea2b7..1c9cdc174a 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -18,7 +18,7 @@ namespace osu.Game.Graphics.Containers private TimingControlPoint lastTimingPoint; /// - /// The amount of time before a beat we should fire . + /// The amount of time before a beat we should fire . /// This allows for adding easing to animations that may be synchronised to the beat. /// protected double EarlyActivationMilliseconds; @@ -33,41 +33,72 @@ namespace osu.Game.Graphics.Containers /// public double TimeSinceLastBeat { get; private set; } + /// + /// How many beats per beatlength to trigger. Defaults to 1. + /// + public int Divisor { get; set; } = 1; + + /// + /// An optional minimum beat length. Any beat length below this will be multiplied by two until valid. + /// + public double MinimumBeatLength { get; set; } + + protected bool IsBeatSyncedWithTrack { get; private set; } + protected override void Update() { - if (!Beatmap.Value.TrackLoaded || !Beatmap.Value.BeatmapLoaded) return; + ITrack track = null; + IBeatmap beatmap = null; - var track = Beatmap.Value.Track; - var beatmap = Beatmap.Value.Beatmap; + double currentTrackTime = 0; + TimingControlPoint timingPoint = null; + EffectControlPoint effectPoint = null; - if (track == null || beatmap == null) - return; + if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded) + { + track = Beatmap.Value.Track; + beatmap = Beatmap.Value.Beatmap; + } - double currentTrackTime = track.Length > 0 ? track.CurrentTime + EarlyActivationMilliseconds : Clock.CurrentTime; + if (track != null && beatmap != null && track.IsRunning && track.Length > 0) + { + currentTrackTime = track.CurrentTime + EarlyActivationMilliseconds; - TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(currentTrackTime); - EffectControlPoint effectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTrackTime); + timingPoint = beatmap.ControlPointInfo.TimingPointAt(currentTrackTime); + effectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTrackTime); + } - if (timingPoint.BeatLength == 0) - return; + IsBeatSyncedWithTrack = timingPoint?.BeatLength > 0; - int beatIndex = (int)((currentTrackTime - timingPoint.Time) / timingPoint.BeatLength); + if (timingPoint == null || !IsBeatSyncedWithTrack) + { + currentTrackTime = Clock.CurrentTime; + timingPoint = TimingControlPoint.DEFAULT; + effectPoint = EffectControlPoint.DEFAULT; + } + + double beatLength = timingPoint.BeatLength / Divisor; + + while (beatLength < MinimumBeatLength) + beatLength *= 2; + + int beatIndex = (int)((currentTrackTime - timingPoint.Time) / beatLength) - (effectPoint.OmitFirstBarLine ? 1 : 0); // The beats before the start of the first control point are off by 1, this should do the trick if (currentTrackTime < timingPoint.Time) beatIndex--; - TimeUntilNextBeat = (timingPoint.Time - currentTrackTime) % timingPoint.BeatLength; + TimeUntilNextBeat = (timingPoint.Time - currentTrackTime) % beatLength; if (TimeUntilNextBeat < 0) - TimeUntilNextBeat += timingPoint.BeatLength; + TimeUntilNextBeat += beatLength; - TimeSinceLastBeat = timingPoint.BeatLength - TimeUntilNextBeat; + TimeSinceLastBeat = beatLength - TimeUntilNextBeat; - if (timingPoint.Equals(lastTimingPoint) && beatIndex == lastBeat) + if (timingPoint == lastTimingPoint && beatIndex == lastBeat) return; using (BeginDelayedSequence(-TimeSinceLastBeat, true)) - OnNewBeat(beatIndex, timingPoint, effectPoint, track.CurrentAmplitudes); + OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? ChannelAmplitudes.Empty); lastBeat = beatIndex; lastTimingPoint = timingPoint; @@ -79,7 +110,7 @@ namespace osu.Game.Graphics.Containers Beatmap.BindTo(beatmap); } - protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { } } diff --git a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs index cda5e150de..fcf445a878 100644 --- a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs +++ b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs @@ -2,9 +2,11 @@ // 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.Game.Configuration; namespace osu.Game.Graphics.Containers { @@ -12,10 +14,13 @@ namespace osu.Game.Graphics.Containers { public Action Action; - private const int activate_delay = 400; private const int fadeout_delay = 200; - private bool fired; + /// + /// Whether currently in a fired state (and the confirm has been sent). + /// + public bool Fired { get; private set; } + private bool confirming; /// @@ -25,26 +30,35 @@ namespace osu.Game.Graphics.Containers public Bindable Progress = new BindableDouble(); + private Bindable holdActivationDelay; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + holdActivationDelay = config.GetBindable(OsuSetting.UIHoldActivationDelay); + } + protected void BeginConfirm() { - if (confirming || (!AllowMultipleFires && fired)) return; + if (confirming || (!AllowMultipleFires && Fired)) return; confirming = true; - this.TransformBindableTo(Progress, 1, activate_delay * (1 - Progress.Value), Easing.Out).OnComplete(_ => Confirm()); + this.TransformBindableTo(Progress, 1, holdActivationDelay.Value * (1 - Progress.Value), Easing.Out).OnComplete(_ => Confirm()); } protected virtual void Confirm() { Action?.Invoke(); - fired = true; + Fired = true; } protected void AbortConfirm() { - if (!AllowMultipleFires && fired) return; + if (!AllowMultipleFires && Fired) return; confirming = false; + Fired = false; this.TransformBindableTo(Progress, 0, fadeout_delay, Easing.Out); } diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index 15068d81c0..e3a9a5fe9d 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -8,9 +8,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using System.Collections.Generic; using osu.Framework.Graphics; -using osu.Framework.Logging; -using osu.Game.Overlays; -using osu.Game.Overlays.Notifications; using osu.Game.Users; namespace osu.Game.Graphics.Containers @@ -22,23 +19,8 @@ namespace osu.Game.Graphics.Containers { } - private OsuGame game; - private ChannelManager channelManager; - private Action showNotImplementedError; - - [BackgroundDependencyLoader(true)] - private void load(OsuGame game, NotificationOverlay notifications, ChannelManager channelManager) - { - // will be null in tests - this.game = game; - this.channelManager = channelManager; - - showNotImplementedError = () => notifications?.Post(new SimpleNotification - { - Text = @"This link type is not yet supported!", - Icon = FontAwesome.Solid.LifeRing, - }); - } + [Resolved(CanBeNull = true)] + private OsuGame game { get; set; } public void AddLinks(string text, List links) { @@ -55,86 +37,48 @@ namespace osu.Game.Graphics.Containers foreach (var link in links) { - AddText(text.Substring(previousLinkEnd, link.Index - previousLinkEnd)); - AddLink(text.Substring(link.Index, link.Length), link.Url, link.Action, link.Argument); + AddText(text[previousLinkEnd..link.Index]); + AddLink(text.Substring(link.Index, link.Length), link.Action, link.Argument ?? link.Url); previousLinkEnd = link.Index + link.Length; } AddText(text.Substring(previousLinkEnd)); } - public IEnumerable AddLink(string text, string url, LinkAction linkType = LinkAction.External, string linkArgument = null, string tooltipText = null, Action creationParameters = null) - => createLink(AddText(text, creationParameters), text, url, linkType, linkArgument, tooltipText); + public void AddLink(string text, string url, Action creationParameters = null) => + createLink(AddText(text, creationParameters), new LinkDetails(LinkAction.External, url), url); - public IEnumerable AddLink(string text, Action action, string tooltipText = null, Action creationParameters = null) - => createLink(AddText(text, creationParameters), text, tooltipText: tooltipText, action: action); + public void AddLink(string text, Action action, string tooltipText = null, Action creationParameters = null) + => createLink(AddText(text, creationParameters), new LinkDetails(LinkAction.Custom, null), tooltipText, action); - public IEnumerable AddLink(IEnumerable text, string url, LinkAction linkType = LinkAction.External, string linkArgument = null, string tooltipText = null) + public void AddLink(string text, LinkAction action, string argument, string tooltipText = null, Action creationParameters = null) + => createLink(AddText(text, creationParameters), new LinkDetails(action, argument), null); + + public void AddLink(IEnumerable text, LinkAction action = LinkAction.External, string linkArgument = null, string tooltipText = null) { foreach (var t in text) AddArbitraryDrawable(t); - return createLink(text, null, url, linkType, linkArgument, tooltipText); + createLink(text, new LinkDetails(action, linkArgument), tooltipText); } - public IEnumerable AddUserLink(User user, Action creationParameters = null) - => createLink(AddText(user.Username, creationParameters), user.Username, null, LinkAction.OpenUserProfile, user.Id.ToString(), "View profile"); + public void AddUserLink(User user, Action creationParameters = null) + => createLink(AddText(user.Username, creationParameters), new LinkDetails(LinkAction.OpenUserProfile, user.Id.ToString()), "view profile"); - private IEnumerable createLink(IEnumerable drawables, string text, string url = null, LinkAction linkType = LinkAction.External, string linkArgument = null, string tooltipText = null, Action action = null) + private void createLink(IEnumerable drawables, LinkDetails link, string tooltipText, Action action = null) { AddInternal(new DrawableLinkCompiler(drawables.OfType().ToList()) { RelativeSizeAxes = Axes.Both, - TooltipText = tooltipText ?? (url != text ? url : string.Empty), - Action = action ?? (() => + TooltipText = tooltipText, + Action = () => { - switch (linkType) - { - case LinkAction.OpenBeatmap: - // TODO: proper query params handling - if (linkArgument != null && int.TryParse(linkArgument.Contains('?') ? linkArgument.Split('?')[0] : linkArgument, out int beatmapId)) - game?.ShowBeatmap(beatmapId); - break; - - case LinkAction.OpenBeatmapSet: - if (int.TryParse(linkArgument, out int setId)) - game?.ShowBeatmapSet(setId); - break; - - case LinkAction.OpenChannel: - try - { - channelManager?.OpenChannel(linkArgument); - } - catch (ChannelNotFoundException) - { - Logger.Log($"The requested channel \"{linkArgument}\" does not exist"); - } - - break; - - case LinkAction.OpenEditorTimestamp: - case LinkAction.JoinMultiplayerMatch: - case LinkAction.Spectate: - showNotImplementedError?.Invoke(); - break; - - case LinkAction.External: - game?.OpenUrlExternally(url); - break; - - case LinkAction.OpenUserProfile: - if (long.TryParse(linkArgument, out long userId)) - game?.ShowUser(userId); - break; - - default: - throw new NotImplementedException($"This {nameof(LinkAction)} ({linkType.ToString()}) is missing an associated action."); - } - }), + if (action != null) + action(); + else + game?.HandleLink(link); + }, }); - - return drawables; } // We want the compilers to always be visible no matter where they are, so RelativeSizeAxes is used. diff --git a/osu.Game/Graphics/Containers/LogoTrackingContainer.cs b/osu.Game/Graphics/Containers/LogoTrackingContainer.cs index 23015e8bf5..dadd7d5240 100644 --- a/osu.Game/Graphics/Containers/LogoTrackingContainer.cs +++ b/osu.Game/Graphics/Containers/LogoTrackingContainer.cs @@ -4,7 +4,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Screens.Menu; using osuTK; diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index 5606328575..41fd37a0d7 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -15,13 +15,12 @@ using osu.Game.Overlays; namespace osu.Game.Graphics.Containers { + [Cached(typeof(IPreviewTrackOwner))] public abstract class OsuFocusedOverlayContainer : FocusedOverlayContainer, IPreviewTrackOwner, IKeyBindingHandler { private SampleChannel samplePopIn; private SampleChannel samplePopOut; - protected virtual bool PlaySamplesOnStateChange => true; - protected override bool BlockNonPositionalInput => true; /// @@ -31,30 +30,32 @@ namespace osu.Game.Graphics.Containers protected virtual bool DimMainContent => true; [Resolved(CanBeNull = true)] - private OsuGame osuGame { get; set; } + private OsuGame game { get; set; } [Resolved] private PreviewTrackManager previewTrackManager { get; set; } - protected readonly Bindable OverlayActivationMode = new Bindable(OverlayActivation.All); - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.CacheAs(this); - return dependencies; - } + protected readonly IBindable OverlayActivationMode = new Bindable(OverlayActivation.All); [BackgroundDependencyLoader(true)] private void load(AudioManager audio) { - if (osuGame != null) - OverlayActivationMode.BindTo(osuGame.OverlayActivationMode); - samplePopIn = audio.Samples.Get(@"UI/overlay-pop-in"); samplePopOut = audio.Samples.Get(@"UI/overlay-pop-out"); + } - State.ValueChanged += onStateChanged; + protected override void LoadComplete() + { + if (game != null) + OverlayActivationMode.BindTo(game.OverlayActivationMode); + + OverlayActivationMode.BindValueChanged(mode => + { + if (mode.NewValue == OverlayActivation.Disabled) + State.Value = Visibility.Hidden; + }, true); + + base.LoadComplete(); } /// @@ -66,15 +67,21 @@ namespace osu.Game.Graphics.Containers // receive input outside our bounds so we can trigger a close event on ourselves. public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => BlockScreenWideMouse || base.ReceivePositionalInputAt(screenSpacePos); - protected override bool OnClick(ClickEvent e) - { - if (!base.ReceivePositionalInputAt(e.ScreenSpaceMousePosition)) - { - Hide(); - return true; - } + private bool closeOnMouseUp; - return base.OnClick(e); + protected override bool OnMouseDown(MouseDownEvent e) + { + closeOnMouseUp = !base.ReceivePositionalInputAt(e.ScreenSpaceMousePosition); + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (closeOnMouseUp && !base.ReceivePositionalInputAt(e.ScreenSpaceMousePosition)) + Hide(); + + base.OnMouseUp(e); } public virtual bool OnPressed(GlobalAction action) @@ -92,28 +99,42 @@ namespace osu.Game.Graphics.Containers return false; } - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } - private void onStateChanged(ValueChangedEvent state) + private bool playedPopInSound; + + protected override void UpdateState(ValueChangedEvent state) { switch (state.NewValue) { case Visibility.Visible: - if (OverlayActivationMode.Value != OverlayActivation.Disabled) + if (OverlayActivationMode.Value == OverlayActivation.Disabled) { - if (PlaySamplesOnStateChange) samplePopIn?.Play(); - if (BlockScreenWideMouse && DimMainContent) osuGame?.AddBlockingOverlay(this); + // todo: visual/audible feedback that this operation could not complete. + State.Value = Visibility.Hidden; + return; } - else - Hide(); + samplePopIn?.Play(); + playedPopInSound = true; + + if (BlockScreenWideMouse && DimMainContent) game?.AddBlockingOverlay(this); break; case Visibility.Hidden: - if (PlaySamplesOnStateChange) samplePopOut?.Play(); - if (BlockScreenWideMouse) osuGame?.RemoveBlockingOverlay(this); + if (playedPopInSound) + { + samplePopOut?.Play(); + playedPopInSound = false; + } + + if (BlockScreenWideMouse) game?.RemoveBlockingOverlay(this); break; } + + base.UpdateState(state); } protected override void PopOut() @@ -125,7 +146,7 @@ namespace osu.Game.Graphics.Containers protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - osuGame?.RemoveBlockingOverlay(this); + game?.RemoveBlockingOverlay(this); } } } diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs new file mode 100644 index 0000000000..1048fd094c --- /dev/null +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.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 osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Graphics.Containers +{ + public abstract class OsuRearrangeableListContainer : RearrangeableListContainer + { + /// + /// Whether any item is currently being dragged. Used to hide other items' drag handles. + /// + protected readonly BindableBool DragActive = new BindableBool(); + + protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + + protected sealed override RearrangeableListItem CreateDrawable(TModel item) => CreateOsuDrawable(item).With(d => + { + d.DragActive.BindTo(DragActive); + }); + + protected abstract OsuRearrangeableListItem CreateOsuDrawable(TModel item); + } +} diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs new file mode 100644 index 0000000000..911d47704a --- /dev/null +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs @@ -0,0 +1,164 @@ +// 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.Sprites; +using osu.Framework.Input.Events; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.Containers +{ + public abstract class OsuRearrangeableListItem : RearrangeableListItem + { + public const float FADE_DURATION = 100; + + /// + /// Whether any item is currently being dragged. Used to hide other items' drag handles. + /// + public readonly BindableBool DragActive = new BindableBool(); + + private Color4 handleColour = Color4.White; + + /// + /// The colour of the drag handle. + /// + protected Color4 HandleColour + { + get => handleColour; + set + { + if (handleColour == value) + return; + + handleColour = value; + + if (handle != null) + handle.Colour = value; + } + } + + /// + /// Whether the drag handle should be shown. + /// + protected readonly Bindable ShowDragHandle = new Bindable(true); + + private Container handleContainer; + private PlaylistItemHandle handle; + + protected OsuRearrangeableListItem(TModel item) + : base(item) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] + { + new[] + { + handleContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 5 }, + Child = handle = new PlaylistItemHandle + { + Size = new Vector2(12), + Colour = HandleColour, + AlwaysPresent = true, + Alpha = 0 + } + }, + CreateContent() + } + }, + ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + ShowDragHandle.BindValueChanged(show => handleContainer.Alpha = show.NewValue ? 1 : 0, true); + } + + protected override bool OnDragStart(DragStartEvent e) + { + if (!base.OnDragStart(e)) + return false; + + DragActive.Value = true; + return true; + } + + protected override void OnDragEnd(DragEndEvent e) + { + DragActive.Value = false; + base.OnDragEnd(e); + } + + protected override bool IsDraggableAt(Vector2 screenSpacePos) => handle.HandlingDrag; + + protected override bool OnHover(HoverEvent e) + { + handle.UpdateHoverState(IsDragged || !DragActive.Value); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) => handle.UpdateHoverState(false); + + protected abstract Drawable CreateContent(); + + public class PlaylistItemHandle : SpriteIcon + { + public bool HandlingDrag { get; private set; } + private bool isHovering; + + public PlaylistItemHandle() + { + Icon = FontAwesome.Solid.Bars; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + base.OnMouseDown(e); + + HandlingDrag = true; + UpdateHoverState(isHovering); + + return false; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + base.OnMouseUp(e); + + HandlingDrag = false; + UpdateHoverState(isHovering); + } + + public void UpdateHoverState(bool hovering) + { + isHovering = hovering; + + if (isHovering || HandlingDrag) + this.FadeIn(FADE_DURATION); + else + this.FadeOut(FADE_DURATION); + } + } + } +} diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index 53092ddc9e..b9122d254d 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -14,11 +14,14 @@ namespace osu.Game.Graphics.Containers { public class OsuScrollContainer : ScrollContainer { + public const float SCROLL_BAR_HEIGHT = 10; + public const float SCROLL_BAR_PADDING = 3; + /// /// Allows controlling the scroll bar from any position in the container using the right mouse button. /// Uses the value of to smoothly scroll to the dragged location. /// - public bool RightMouseScrollbar = false; + public bool RightMouseScrollbar; /// /// Controls the rate with which the target position is approached when performing a relative drag. Default is 0.02. @@ -27,11 +30,12 @@ namespace osu.Game.Graphics.Containers private bool shouldPerformRightMouseScroll(MouseButtonEvent e) => RightMouseScrollbar && e.Button == MouseButton.Right; - private void scrollToRelative(float value) => ScrollTo(Clamp((value - Scrollbar.DrawSize[ScrollDim] / 2) / Scrollbar.Size[ScrollDim]), true, DistanceDecayOnRightMouseScrollbar); + private void scrollFromMouseEvent(MouseEvent e) => + ScrollTo(Clamp(ToLocalSpace(e.ScreenSpaceMousePosition)[ScrollDim] / DrawSize[ScrollDim]) * Content.DrawSize[ScrollDim], true, DistanceDecayOnRightMouseScrollbar); - private bool mouseScrollBarDragging; + private bool rightMouseDragging; - protected override bool IsDragging => base.IsDragging || mouseScrollBarDragging; + protected override bool IsDragging => base.IsDragging || rightMouseDragging; public OsuScrollContainer(Direction scrollDirection = Direction.Vertical) : base(scrollDirection) @@ -42,52 +46,59 @@ namespace osu.Game.Graphics.Containers { if (shouldPerformRightMouseScroll(e)) { - scrollToRelative(e.MousePosition[ScrollDim]); + scrollFromMouseEvent(e); return true; } return base.OnMouseDown(e); } - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) { - if (mouseScrollBarDragging) + if (rightMouseDragging) { - scrollToRelative(e.MousePosition[ScrollDim]); - return true; + scrollFromMouseEvent(e); + return; } - return base.OnDrag(e); + base.OnDrag(e); } protected override bool OnDragStart(DragStartEvent e) { if (shouldPerformRightMouseScroll(e)) { - mouseScrollBarDragging = true; + rightMouseDragging = true; return true; } return base.OnDragStart(e); } - protected override bool OnDragEnd(DragEndEvent e) + protected override void OnDragEnd(DragEndEvent e) { - if (mouseScrollBarDragging) + if (rightMouseDragging) { - mouseScrollBarDragging = false; - return true; + rightMouseDragging = false; + return; } - return base.OnDragEnd(e); + base.OnDragEnd(e); + } + + protected override bool OnScroll(ScrollEvent e) + { + // allow for controlling volume when alt is held. + // mostly for compatibility with osu-stable. + if (e.AltPressed) return false; + + return base.OnScroll(e); } protected override ScrollbarContainer CreateScrollbar(Direction direction) => new OsuScrollbar(direction); protected class OsuScrollbar : ScrollbarContainer { - private const float dim_size = 10; - private Color4 hoverColour; private Color4 defaultColour; private Color4 highlightColour; @@ -97,10 +108,13 @@ namespace osu.Game.Graphics.Containers public OsuScrollbar(Direction scrollDir) : base(scrollDir) { - Blending = BlendingMode.Additive; + Blending = BlendingParameters.Additive; CornerRadius = 5; + // needs to be set initially for the ResizeTo to respect minimum size + Size = new Vector2(SCROLL_BAR_HEIGHT); + const float margin = 3; Margin = new MarginPadding @@ -113,8 +127,6 @@ namespace osu.Game.Graphics.Containers Masking = true; Child = box = new Box { RelativeSizeAxes = Axes.Both }; - - ResizeTo(1); } [BackgroundDependencyLoader] @@ -127,7 +139,7 @@ namespace osu.Game.Graphics.Containers public override void ResizeTo(float val, int duration = 0, Easing easing = Easing.None) { - Vector2 size = new Vector2(dim_size) + Vector2 size = new Vector2(SCROLL_BAR_HEIGHT) { [(int)ScrollDirection] = val }; @@ -149,18 +161,18 @@ namespace osu.Game.Graphics.Containers { if (!base.OnMouseDown(e)) return false; - //note that we are changing the colour of the box here as to not interfere with the hover effect. + // note that we are changing the colour of the box here as to not interfere with the hover effect. box.FadeColour(highlightColour, 100); return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { - if (e.Button != MouseButton.Left) return false; + if (e.Button != MouseButton.Left) return; box.FadeColour(Color4.White, 100); - return base.OnMouseUp(e); + base.OnMouseUp(e); } } } diff --git a/osu.Game/Graphics/Containers/ParallaxContainer.cs b/osu.Game/Graphics/Containers/ParallaxContainer.cs index f65a0a469a..4cd3934cde 100644 --- a/osu.Game/Graphics/Containers/ParallaxContainer.cs +++ b/osu.Game/Graphics/Containers/ParallaxContainer.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.Containers; using osu.Framework.Graphics; using osu.Framework.Input; @@ -8,7 +9,7 @@ using osuTK; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Configuration; -using osu.Framework.MathUtils; +using osu.Framework.Utils; namespace osu.Game.Graphics.Containers { @@ -48,7 +49,7 @@ namespace osu.Game.Graphics.Containers if (!parallaxEnabled.Value) { content.MoveTo(Vector2.Zero, firstUpdate ? 0 : 1000, Easing.OutQuint); - content.Scale = new Vector2(1 + System.Math.Abs(ParallaxAmount)); + content.Scale = new Vector2(1 + Math.Abs(ParallaxAmount)); } }; } @@ -69,10 +70,12 @@ namespace osu.Game.Graphics.Containers { Vector2 offset = (input.CurrentState.Mouse == null ? Vector2.Zero : ToLocalSpace(input.CurrentState.Mouse.Position) - DrawSize / 2) * ParallaxAmount; - double elapsed = MathHelper.Clamp(Clock.ElapsedFrameTime, 0, 1000); + const float parallax_duration = 100; - content.Position = Interpolation.ValueAt(elapsed, content.Position, offset, 0, 1000, Easing.OutQuint); - content.Scale = Interpolation.ValueAt(elapsed, content.Scale, new Vector2(1 + System.Math.Abs(ParallaxAmount)), 0, 1000, Easing.OutQuint); + double elapsed = Math.Clamp(Clock.ElapsedFrameTime, 0, parallax_duration); + + content.Position = Interpolation.ValueAt(elapsed, content.Position, offset, 0, parallax_duration, Easing.OutQuint); + content.Scale = Interpolation.ValueAt(elapsed, content.Scale, new Vector2(1 + Math.Abs(ParallaxAmount)), 0, 1000, Easing.OutQuint); } firstUpdate = false; diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index 9d886c457f..f32f8e0c67 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.cs @@ -3,9 +3,11 @@ using System; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; namespace osu.Game.Graphics.Containers { @@ -15,12 +17,7 @@ namespace osu.Game.Graphics.Containers public class SectionsContainer : Container where T : Drawable { - private Drawable expandableHeader, fixedHeader, footer, headerBackground; - private readonly OsuScrollContainer scrollContainer; - private readonly Container headerBackgroundContainer; - private readonly FlowContainer scrollContentContainer; - - protected override Container Content => scrollContentContainer; + public Bindable SelectedSection { get; } = new Bindable(); public Drawable ExpandableHeader { @@ -29,8 +26,11 @@ namespace osu.Game.Graphics.Containers { if (value == expandableHeader) return; - expandableHeader?.Expire(); + if (expandableHeader != null) + RemoveInternal(expandableHeader); + expandableHeader = value; + if (value == null) return; AddInternal(expandableHeader); @@ -82,6 +82,7 @@ namespace osu.Game.Graphics.Containers headerBackgroundContainer.Clear(); headerBackground = value; + if (value == null) return; headerBackgroundContainer.Add(headerBackground); @@ -90,15 +91,37 @@ namespace osu.Game.Graphics.Containers } } - public Bindable SelectedSection { get; } = new Bindable(); + protected override Container Content => scrollContentContainer; - protected virtual FlowContainer CreateScrollContentContainer() - => new FillFlowContainer + private readonly OsuScrollContainer scrollContainer; + private readonly Container headerBackgroundContainer; + private readonly MarginPadding originalSectionsMargin; + private Drawable expandableHeader, fixedHeader, footer, headerBackground; + private FlowContainer scrollContentContainer; + + private float headerHeight, footerHeight; + + private float lastKnownScroll; + + public SectionsContainer() + { + AddRangeInternal(new Drawable[] { - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - }; + scrollContainer = CreateScrollContainer().With(s => + { + s.RelativeSizeAxes = Axes.Both; + s.Masking = true; + s.ScrollbarVisible = false; + s.Child = scrollContentContainer = CreateScrollContentContainer(); + }), + headerBackgroundContainer = new Container + { + RelativeSizeAxes = Axes.X + } + }); + + originalSectionsMargin = scrollContentContainer.Margin; + } public override void Add(T drawable) { @@ -108,52 +131,35 @@ namespace osu.Game.Graphics.Containers footerHeight = float.NaN; } - private float headerHeight, footerHeight; - private readonly MarginPadding originalSectionsMargin; - - private void updateSectionsMargin() - { - if (!Children.Any()) return; - - var newMargin = originalSectionsMargin; - newMargin.Top += headerHeight; - newMargin.Bottom += footerHeight; - - scrollContentContainer.Margin = newMargin; - } - - public SectionsContainer() - { - AddInternal(scrollContainer = new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Masking = true, - ScrollbarVisible = false, - Children = new Drawable[] { scrollContentContainer = CreateScrollContentContainer() } - }); - AddInternal(headerBackgroundContainer = new Container - { - RelativeSizeAxes = Axes.X - }); - originalSectionsMargin = scrollContentContainer.Margin; - } - - public void ScrollTo(Drawable section) => scrollContainer.ScrollTo(scrollContainer.GetChildPosInContent(section) - (FixedHeader?.BoundingBox.Height ?? 0)); + public void ScrollTo(Drawable section) => + scrollContainer.ScrollTo(scrollContainer.GetChildPosInContent(section) - (FixedHeader?.BoundingBox.Height ?? 0)); public void ScrollToTop() => scrollContainer.ScrollTo(0); - public override void InvalidateFromChild(Invalidation invalidation, Drawable source = null) - { - base.InvalidateFromChild(invalidation, source); + [NotNull] + protected virtual OsuScrollContainer CreateScrollContainer() => new OsuScrollContainer(); - if ((invalidation & Invalidation.DrawSize) != 0) + [NotNull] + protected virtual FlowContainer CreateScrollContentContainer() => + new FillFlowContainer { - if (source == ExpandableHeader) //We need to recalculate the positions if the ExpandableHeader changed its size - lastKnownScroll = -1; - } - } + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + }; - private float lastKnownScroll; + protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source) + { + var result = base.OnInvalidate(invalidation, source); + + if (source == InvalidationSource.Child && (invalidation & Invalidation.DrawSize) != 0) + { + lastKnownScroll = -1; + result = true; + } + + return result; + } protected override void UpdateAfterChildren() { @@ -186,24 +192,30 @@ namespace osu.Game.Graphics.Containers headerBackgroundContainer.Height = (ExpandableHeader?.LayoutSize.Y ?? 0) + (FixedHeader?.LayoutSize.Y ?? 0); headerBackgroundContainer.Y = ExpandableHeader?.Y ?? 0; - T bestMatch = null; - float minDiff = float.MaxValue; float scrollOffset = FixedHeader?.LayoutSize.Y ?? 0; + Func diff = section => scrollContainer.GetChildPosInContent(section) - currentScroll - scrollOffset; - foreach (var section in Children) + if (scrollContainer.IsScrolledToEnd()) { - float diff = Math.Abs(scrollContainer.GetChildPosInContent(section) - currentScroll - scrollOffset); - - if (diff < minDiff) - { - minDiff = diff; - bestMatch = section; - } + SelectedSection.Value = Children.LastOrDefault(); + } + else + { + SelectedSection.Value = Children.TakeWhile(section => diff(section) <= 0).LastOrDefault() + ?? Children.FirstOrDefault(); } - - if (bestMatch != null) - SelectedSection.Value = bestMatch; } } + + private void updateSectionsMargin() + { + if (!Children.Any()) return; + + var newMargin = originalSectionsMargin; + newMargin.Top += headerHeight; + newMargin.Bottom += footerHeight; + + scrollContentContainer.Margin = newMargin; + } } } diff --git a/osu.Game/Graphics/Containers/ShakeContainer.cs b/osu.Game/Graphics/Containers/ShakeContainer.cs index e5a6bcc28e..dca9df1e98 100644 --- a/osu.Game/Graphics/Containers/ShakeContainer.cs +++ b/osu.Game/Graphics/Containers/ShakeContainer.cs @@ -43,9 +43,11 @@ namespace osu.Game.Graphics.Containers // if we don't have enough time for the second shake, skip it. if (!maximumLength.HasValue || maximumLength >= ShakeDuration * 4) + { sequence = sequence .MoveToX(shake_amount, ShakeDuration, Easing.InOutSine).Then() .MoveToX(-shake_amount, ShakeDuration, Easing.InOutSine).Then(); + } sequence.MoveToX(0, ShakeDuration / 2, Easing.InSine); } diff --git a/osu.Game/Graphics/Containers/UserDimContainer.cs b/osu.Game/Graphics/Containers/UserDimContainer.cs index 03de5f651f..39c1fdad52 100644 --- a/osu.Game/Graphics/Containers/UserDimContainer.cs +++ b/osu.Game/Graphics/Containers/UserDimContainer.cs @@ -1,12 +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 osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Configuration; -using osuTK.Graphics; +using osu.Game.Screens.Play; namespace osu.Game.Graphics.Containers { @@ -15,18 +16,34 @@ namespace osu.Game.Graphics.Containers /// public abstract class UserDimContainer : Container { - protected const float BACKGROUND_FADE_DURATION = 800; + /// + /// Amount of lightening to apply to current dim level during break times. + /// + public const float BREAK_LIGHTEN_AMOUNT = 0.3f; + + 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 + /// + public readonly Bindable IgnoreUserSettings = new Bindable(); + /// /// Whether or not the storyboard loaded should completely hide the background behind it. /// public readonly Bindable StoryboardReplacesBackground = new Bindable(); + /// + /// Whether player is in break time. + /// Must be bound to to allow for dim adjustments in gameplay. + /// + public readonly IBindable IsBreakTime = new Bindable(); + /// /// Whether the content of this container is currently being displayed. /// @@ -34,8 +51,14 @@ namespace osu.Game.Graphics.Containers protected Bindable UserDimLevel { get; private set; } + protected Bindable LightenDuringBreaks { get; private set; } + protected Bindable ShowStoryboard { get; private set; } + 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 override Container Content => dimContent; private Container dimContent { get; } @@ -52,12 +75,16 @@ namespace osu.Game.Graphics.Containers private void load(OsuConfigManager config) { UserDimLevel = config.GetBindable(OsuSetting.DimLevel); + LightenDuringBreaks = config.GetBindable(OsuSetting.LightenDuringBreaks); ShowStoryboard = config.GetBindable(OsuSetting.ShowStoryboard); EnableUserDim.ValueChanged += _ => UpdateVisuals(); UserDimLevel.ValueChanged += _ => UpdateVisuals(); + LightenDuringBreaks.ValueChanged += _ => UpdateVisuals(); + IsBreakTime.ValueChanged += _ => UpdateVisuals(); ShowStoryboard.ValueChanged += _ => UpdateVisuals(); StoryboardReplacesBackground.ValueChanged += _ => UpdateVisuals(); + IgnoreUserSettings.ValueChanged += _ => UpdateVisuals(); } protected override void LoadComplete() @@ -78,8 +105,8 @@ namespace osu.Game.Graphics.Containers { ContentDisplayed = ShowDimContent; - dimContent.FadeTo((ContentDisplayed) ? 1 : 0, BACKGROUND_FADE_DURATION, Easing.OutQuint); - dimContent.FadeColour(EnableUserDim.Value ? OsuColour.Gray(1 - (float)UserDimLevel.Value) : Color4.White, BACKGROUND_FADE_DURATION, Easing.OutQuint); + dimContent.FadeTo(ContentDisplayed ? 1 : 0, BACKGROUND_FADE_DURATION, Easing.OutQuint); + dimContent.FadeColour(OsuColour.Gray(1f - DimLevel), BACKGROUND_FADE_DURATION, Easing.OutQuint); } } } diff --git a/osu.Game/Graphics/Containers/WaveContainer.cs b/osu.Game/Graphics/Containers/WaveContainer.cs index c01674f5b4..8b87ddaa20 100644 --- a/osu.Game/Graphics/Containers/WaveContainer.cs +++ b/osu.Game/Graphics/Containers/WaveContainer.cs @@ -159,8 +159,15 @@ namespace osu.Game.Graphics.Containers Height = Parent.Parent.DrawSize.Y * 1.5f; } - protected override void PopIn() => this.MoveToY(FinalPosition, APPEAR_DURATION, easing_show); - protected override void PopOut() => this.MoveToY(Parent.Parent.DrawSize.Y, DISAPPEAR_DURATION, easing_hide); + protected override void PopIn() => Schedule(() => this.MoveToY(FinalPosition, APPEAR_DURATION, easing_show)); + + protected override void PopOut() + { + double duration = IsLoaded ? DISAPPEAR_DURATION : 0; + + // scheduling is required as parent may not be present at the time this is called. + Schedule(() => this.MoveToY(Parent.Parent.DrawSize.Y, duration, easing_hide)); + } } } } diff --git a/osu.Game/Graphics/Cursor/MenuCursor.cs b/osu.Game/Graphics/Cursor/MenuCursor.cs index 092a23e787..fd8f016860 100644 --- a/osu.Game/Graphics/Cursor/MenuCursor.cs +++ b/osu.Game/Graphics/Cursor/MenuCursor.cs @@ -13,7 +13,7 @@ using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; -using osuTK.Input; +using osu.Framework.Utils; namespace osu.Game.Graphics.Cursor { @@ -55,7 +55,7 @@ namespace osu.Game.Graphics.Cursor if (dragRotationState == DragRotationState.Rotating && distance > 0) { Vector2 offset = e.MousePosition - positionMouseDown; - float degrees = (float)MathHelper.RadiansToDegrees(Math.Atan2(-offset.X, offset.Y)) + 24.3f; + float degrees = MathUtils.RadiansToDegrees(MathF.Atan2(-offset.X, offset.Y)) + 24.3f; // Always rotate in the direction of least distance float diff = (degrees - activeCursor.Rotation) % 360; @@ -73,17 +73,15 @@ namespace osu.Game.Graphics.Cursor protected override bool OnMouseDown(MouseDownEvent e) { // only trigger animation for main mouse buttons - if (e.Button <= MouseButton.Right) - { - activeCursor.Scale = new Vector2(1); - activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); + activeCursor.Scale = new Vector2(1); + activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); - activeCursor.AdditiveLayer.Alpha = 0; - activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); - } + activeCursor.AdditiveLayer.Alpha = 0; + activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); - if (e.Button == MouseButton.Left && cursorRotate.Value) + if (cursorRotate.Value && dragRotationState != DragRotationState.Rotating) { + // if cursor is already rotating don't reset its rotate origin dragRotationState = DragRotationState.DragStarted; positionMouseDown = e.MousePosition; } @@ -91,22 +89,21 @@ namespace osu.Game.Graphics.Cursor return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { - if (!e.IsPressed(MouseButton.Left) && !e.IsPressed(MouseButton.Right)) + if (!e.HasAnyButtonPressed) { activeCursor.AdditiveLayer.FadeOutFromOne(500, Easing.OutQuint); activeCursor.ScaleTo(1, 500, Easing.OutElastic); - } - if (e.Button == MouseButton.Left) - { - if (dragRotationState == DragRotationState.Rotating) + if (dragRotationState != DragRotationState.NotDragging) + { activeCursor.RotateTo(0, 600 * (1 + Math.Abs(activeCursor.Rotation / 720)), Easing.OutElasticHalf); - dragRotationState = DragRotationState.NotDragging; + dragRotationState = DragRotationState.NotDragging; + } } - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override void PopIn() @@ -124,7 +121,7 @@ namespace osu.Game.Graphics.Cursor public class Cursor : Container { private Container cursorContainer; - private Bindable cursorScale; + private Bindable cursorScale; private const float base_scale = 0.15f; public Sprite AdditiveLayer; @@ -150,7 +147,7 @@ namespace osu.Game.Graphics.Cursor }, AdditiveLayer = new Sprite { - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Colour = colour.Pink, Alpha = 0, Texture = textures.Get(@"Cursor/menu-cursor-additive"), @@ -159,9 +156,8 @@ namespace osu.Game.Graphics.Cursor } }; - cursorScale = config.GetBindable(OsuSetting.MenuCursorSize); - cursorScale.ValueChanged += scale => cursorContainer.Scale = new Vector2((float)scale.NewValue * base_scale); - cursorScale.TriggerChange(); + cursorScale = config.GetBindable(OsuSetting.MenuCursorSize); + cursorScale.BindValueChanged(scale => cursorContainer.Scale = new Vector2(scale.NewValue * base_scale), true); } } diff --git a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs index b7ea1ba56a..4c7f7957e9 100644 --- a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs +++ b/osu.Game/Graphics/Cursor/MenuCursorContainer.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 System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Input; +using osu.Framework.Input.StateChanges; namespace osu.Game.Graphics.Cursor { @@ -48,14 +48,26 @@ namespace osu.Game.Graphics.Cursor { base.Update(); - if (!CanShowCursor) + var lastMouseSource = inputManager.CurrentState.Mouse.LastSource; + bool hasValidInput = lastMouseSource != null && !(lastMouseSource is ISourcedFromTouch); + + if (!hasValidInput || !CanShowCursor) { currentTarget?.Cursor?.Hide(); currentTarget = null; return; } - var newTarget = inputManager.HoveredDrawables.OfType().FirstOrDefault(t => t.ProvidingUserCursor) ?? this; + IProvideCursor newTarget = this; + + foreach (var d in inputManager.HoveredDrawables) + { + if (d is IProvideCursor p && p.ProvidingUserCursor) + { + newTarget = p; + break; + } + } if (currentTarget == newTarget) return; diff --git a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs index cfcda892fd..57f39bb8c7 100644 --- a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs @@ -30,22 +30,24 @@ namespace osu.Game.Graphics.Cursor private readonly OsuSpriteText text; private bool instantMovement = true; - public override string TooltipText + public override bool SetContent(object content) { - set + if (!(content is string contentString)) + return false; + + if (contentString == text.Text) return true; + + text.Text = contentString; + + if (IsPresent) { - if (value == text.Text) return; - - text.Text = value; - - if (IsPresent) - { - AutoSizeDuration = 250; - background.FlashColour(OsuColour.Gray(0.4f), 1000, Easing.OutQuint); - } - else - AutoSizeDuration = 0; + AutoSizeDuration = 250; + background.FlashColour(OsuColour.Gray(0.4f), 1000, Easing.OutQuint); } + else + AutoSizeDuration = 0; + + return true; } public OsuTooltip() diff --git a/osu.Game/Graphics/DateTooltip.cs b/osu.Game/Graphics/DateTooltip.cs new file mode 100644 index 0000000000..67fcab43f7 --- /dev/null +++ b/osu.Game/Graphics/DateTooltip.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; +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.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics +{ + public class DateTooltip : VisibilityContainer, ITooltip + { + private readonly OsuSpriteText dateText, timeText; + private readonly Box background; + + public DateTooltip() + { + AutoSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 5; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding(10), + Children = new Drawable[] + { + dateText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + timeText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } + } + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + background.Colour = colours.GreySeafoamDarker; + timeText.Colour = colours.BlueLighter; + } + + protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); + + public bool SetContent(object content) + { + if (!(content is DateTimeOffset date)) + return false; + + dateText.Text = $"{date:d MMMM yyyy} "; + timeText.Text = $"{date:HH:mm:ss \"UTC\"z}"; + return true; + } + + public void Move(Vector2 pos) => Position = pos; + } +} diff --git a/osu.Game/Graphics/DrawableDate.cs b/osu.Game/Graphics/DrawableDate.cs index 125c994c92..259d9c8d6e 100644 --- a/osu.Game/Graphics/DrawableDate.cs +++ b/osu.Game/Graphics/DrawableDate.cs @@ -2,15 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using System; -using Humanizer; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Game.Graphics.Sprites; +using osu.Game.Utils; namespace osu.Game.Graphics { - public class DrawableDate : OsuSpriteText, IHasTooltip + public class DrawableDate : OsuSpriteText, IHasCustomTooltip { private DateTimeOffset date; @@ -29,9 +29,9 @@ namespace osu.Game.Graphics } } - public DrawableDate(DateTimeOffset date) + public DrawableDate(DateTimeOffset date, float textSize = OsuFont.DEFAULT_FONT_SIZE, bool italic = true) { - Font = OsuFont.GetFont(weight: FontWeight.Regular, italics: true); + Font = OsuFont.GetFont(weight: FontWeight.Regular, size: textSize, italics: italic); Date = date; } @@ -71,10 +71,12 @@ namespace osu.Game.Graphics Scheduler.AddDelayed(updateTimeWithReschedule, timeUntilNextUpdate); } - protected virtual string Format() => Date.Humanize(); + protected virtual string Format() => HumanizerUtils.Humanize(Date); private void updateTime() => Text = Format(); - public virtual string TooltipText => string.Format($"{Date:MMMM d, yyyy h:mm tt \"UTC\"z}"); + public ITooltip GetCustomTooltip() => new DateTooltip(); + + public object TooltipContent => Date; } } diff --git a/osu.Game/Graphics/IHasAccentColour.cs b/osu.Game/Graphics/IHasAccentColour.cs index 1a66819379..af497da70f 100644 --- a/osu.Game/Graphics/IHasAccentColour.cs +++ b/osu.Game/Graphics/IHasAccentColour.cs @@ -24,7 +24,7 @@ namespace osu.Game.Graphics /// /// A to which further transforms can be added. public static TransformSequence FadeAccent(this T accentedDrawable, Color4 newColour, double duration = 0, Easing easing = Easing.None) - where T : IHasAccentColour + where T : class, IHasAccentColour => accentedDrawable.TransformTo(nameof(accentedDrawable.AccentColour), newColour, duration, easing); /// diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 63ec24f84f..466d59b08b 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -1,7 +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 osu.Framework.Extensions.Color4Extensions; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; using osuTK.Graphics; namespace osu.Game.Graphics @@ -11,132 +14,186 @@ namespace osu.Game.Graphics public static Color4 Gray(float amt) => new Color4(amt, amt, amt, 1f); public static Color4 Gray(byte amt) => new Color4(amt, amt, amt, 255); - public static Color4 FromHex(string hex) + public Color4 ForDifficultyRating(DifficultyRating difficulty, bool useLighterColour = false) { - if (hex[0] == '#') - hex = hex.Substring(1); - - switch (hex.Length) + switch (difficulty) { + case DifficultyRating.Easy: + return Green; + default: - throw new ArgumentException(@"Invalid hex string length!"); + case DifficultyRating.Normal: + return Blue; - case 3: - return new Color4( - (byte)(Convert.ToByte(hex.Substring(0, 1), 16) * 17), - (byte)(Convert.ToByte(hex.Substring(1, 1), 16) * 17), - (byte)(Convert.ToByte(hex.Substring(2, 1), 16) * 17), - 255); + case DifficultyRating.Hard: + return Yellow; - case 6: - return new Color4( - Convert.ToByte(hex.Substring(0, 2), 16), - Convert.ToByte(hex.Substring(2, 2), 16), - Convert.ToByte(hex.Substring(4, 2), 16), - 255); + case DifficultyRating.Insane: + return Pink; + + case DifficultyRating.Expert: + return useLighterColour ? PurpleLight : Purple; + + case DifficultyRating.ExpertPlus: + return useLighterColour ? Gray9 : Gray0; + } + } + + /// + /// Retrieves the colour for a . + /// + public static Color4 ForRank(ScoreRank rank) + { + switch (rank) + { + case ScoreRank.XH: + case ScoreRank.X: + return Color4Extensions.FromHex(@"de31ae"); + + case ScoreRank.SH: + case ScoreRank.S: + return Color4Extensions.FromHex(@"02b5c3"); + + case ScoreRank.A: + return Color4Extensions.FromHex(@"88da20"); + + case ScoreRank.B: + return Color4Extensions.FromHex(@"e3b130"); + + case ScoreRank.C: + return Color4Extensions.FromHex(@"ff8e5d"); + + default: + return Color4Extensions.FromHex(@"ff5a5a"); + } + } + + /// + /// Retrieves the colour for a . + /// + public Color4 ForHitResult(HitResult judgement) + { + switch (judgement) + { + case HitResult.Perfect: + case HitResult.Great: + return Blue; + + case HitResult.Ok: + case HitResult.Good: + return Green; + + case HitResult.Meh: + return Yellow; + + case HitResult.Miss: + return Red; + + default: + return Color4.White; } } // See https://github.com/ppy/osu-web/blob/master/resources/assets/less/colors.less - public readonly Color4 PurpleLighter = FromHex(@"eeeeff"); - public readonly Color4 PurpleLight = FromHex(@"aa88ff"); - public readonly Color4 PurpleLightAlternative = FromHex(@"cba4da"); - public readonly Color4 Purple = FromHex(@"8866ee"); - public readonly Color4 PurpleDark = FromHex(@"6644cc"); - public readonly Color4 PurpleDarkAlternative = FromHex(@"312436"); - public readonly Color4 PurpleDarker = FromHex(@"441188"); + public readonly Color4 PurpleLighter = Color4Extensions.FromHex(@"eeeeff"); + public readonly Color4 PurpleLight = Color4Extensions.FromHex(@"aa88ff"); + public readonly Color4 PurpleLightAlternative = Color4Extensions.FromHex(@"cba4da"); + public readonly Color4 Purple = Color4Extensions.FromHex(@"8866ee"); + public readonly Color4 PurpleDark = Color4Extensions.FromHex(@"6644cc"); + public readonly Color4 PurpleDarkAlternative = Color4Extensions.FromHex(@"312436"); + public readonly Color4 PurpleDarker = Color4Extensions.FromHex(@"441188"); - public readonly Color4 PinkLighter = FromHex(@"ffddee"); - public readonly Color4 PinkLight = FromHex(@"ff99cc"); - public readonly Color4 Pink = FromHex(@"ff66aa"); - public readonly Color4 PinkDark = FromHex(@"cc5288"); - public readonly Color4 PinkDarker = FromHex(@"bb1177"); + public readonly Color4 PinkLighter = Color4Extensions.FromHex(@"ffddee"); + public readonly Color4 PinkLight = Color4Extensions.FromHex(@"ff99cc"); + public readonly Color4 Pink = Color4Extensions.FromHex(@"ff66aa"); + public readonly Color4 PinkDark = Color4Extensions.FromHex(@"cc5288"); + public readonly Color4 PinkDarker = Color4Extensions.FromHex(@"bb1177"); - public readonly Color4 BlueLighter = FromHex(@"ddffff"); - public readonly Color4 BlueLight = FromHex(@"99eeff"); - public readonly Color4 Blue = FromHex(@"66ccff"); - public readonly Color4 BlueDark = FromHex(@"44aadd"); - public readonly Color4 BlueDarker = FromHex(@"2299bb"); + public readonly Color4 BlueLighter = Color4Extensions.FromHex(@"ddffff"); + public readonly Color4 BlueLight = Color4Extensions.FromHex(@"99eeff"); + public readonly Color4 Blue = Color4Extensions.FromHex(@"66ccff"); + public readonly Color4 BlueDark = Color4Extensions.FromHex(@"44aadd"); + public readonly Color4 BlueDarker = Color4Extensions.FromHex(@"2299bb"); - public readonly Color4 YellowLighter = FromHex(@"ffffdd"); - public readonly Color4 YellowLight = FromHex(@"ffdd55"); - public readonly Color4 Yellow = FromHex(@"ffcc22"); - public readonly Color4 YellowDark = FromHex(@"eeaa00"); - public readonly Color4 YellowDarker = FromHex(@"cc6600"); + public readonly Color4 YellowLighter = Color4Extensions.FromHex(@"ffffdd"); + public readonly Color4 YellowLight = Color4Extensions.FromHex(@"ffdd55"); + public readonly Color4 Yellow = Color4Extensions.FromHex(@"ffcc22"); + public readonly Color4 YellowDark = Color4Extensions.FromHex(@"eeaa00"); + public readonly Color4 YellowDarker = Color4Extensions.FromHex(@"cc6600"); - public readonly Color4 GreenLighter = FromHex(@"eeffcc"); - public readonly Color4 GreenLight = FromHex(@"b3d944"); - public readonly Color4 Green = FromHex(@"88b300"); - public readonly Color4 GreenDark = FromHex(@"668800"); - public readonly Color4 GreenDarker = FromHex(@"445500"); + public readonly Color4 GreenLighter = Color4Extensions.FromHex(@"eeffcc"); + public readonly Color4 GreenLight = Color4Extensions.FromHex(@"b3d944"); + public readonly Color4 Green = Color4Extensions.FromHex(@"88b300"); + public readonly Color4 GreenDark = Color4Extensions.FromHex(@"668800"); + public readonly Color4 GreenDarker = Color4Extensions.FromHex(@"445500"); - public readonly Color4 Sky = FromHex(@"6bb5ff"); - public readonly Color4 GreySkyLighter = FromHex(@"c6e3f4"); - public readonly Color4 GreySkyLight = FromHex(@"8ab3cc"); - public readonly Color4 GreySky = FromHex(@"405461"); - public readonly Color4 GreySkyDark = FromHex(@"303d47"); - public readonly Color4 GreySkyDarker = FromHex(@"21272c"); + public readonly Color4 Sky = Color4Extensions.FromHex(@"6bb5ff"); + public readonly Color4 GreySkyLighter = Color4Extensions.FromHex(@"c6e3f4"); + public readonly Color4 GreySkyLight = Color4Extensions.FromHex(@"8ab3cc"); + public readonly Color4 GreySky = Color4Extensions.FromHex(@"405461"); + public readonly Color4 GreySkyDark = Color4Extensions.FromHex(@"303d47"); + public readonly Color4 GreySkyDarker = Color4Extensions.FromHex(@"21272c"); - public readonly Color4 Seafoam = FromHex(@"05ffa2"); - public readonly Color4 GreySeafoamLighter = FromHex(@"9ebab1"); - public readonly Color4 GreySeafoamLight = FromHex(@"4d7365"); - public readonly Color4 GreySeafoam = FromHex(@"33413c"); - public readonly Color4 GreySeafoamDark = FromHex(@"2c3532"); - public readonly Color4 GreySeafoamDarker = FromHex(@"1e2422"); + public readonly Color4 Seafoam = Color4Extensions.FromHex(@"05ffa2"); + public readonly Color4 GreySeafoamLighter = Color4Extensions.FromHex(@"9ebab1"); + public readonly Color4 GreySeafoamLight = Color4Extensions.FromHex(@"4d7365"); + public readonly Color4 GreySeafoam = Color4Extensions.FromHex(@"33413c"); + public readonly Color4 GreySeafoamDark = Color4Extensions.FromHex(@"2c3532"); + public readonly Color4 GreySeafoamDarker = Color4Extensions.FromHex(@"1e2422"); - public readonly Color4 Cyan = FromHex(@"05f4fd"); - public readonly Color4 GreyCyanLighter = FromHex(@"77b1b3"); - public readonly Color4 GreyCyanLight = FromHex(@"436d6f"); - public readonly Color4 GreyCyan = FromHex(@"293d3e"); - public readonly Color4 GreyCyanDark = FromHex(@"243536"); - public readonly Color4 GreyCyanDarker = FromHex(@"1e2929"); + public readonly Color4 Cyan = Color4Extensions.FromHex(@"05f4fd"); + public readonly Color4 GreyCyanLighter = Color4Extensions.FromHex(@"77b1b3"); + public readonly Color4 GreyCyanLight = Color4Extensions.FromHex(@"436d6f"); + public readonly Color4 GreyCyan = Color4Extensions.FromHex(@"293d3e"); + public readonly Color4 GreyCyanDark = Color4Extensions.FromHex(@"243536"); + public readonly Color4 GreyCyanDarker = Color4Extensions.FromHex(@"1e2929"); - public readonly Color4 Lime = FromHex(@"82ff05"); - public readonly Color4 GreyLimeLighter = FromHex(@"deff87"); - public readonly Color4 GreyLimeLight = FromHex(@"657259"); - public readonly Color4 GreyLime = FromHex(@"3f443a"); - public readonly Color4 GreyLimeDark = FromHex(@"32352e"); - public readonly Color4 GreyLimeDarker = FromHex(@"2e302b"); + public readonly Color4 Lime = Color4Extensions.FromHex(@"82ff05"); + public readonly Color4 GreyLimeLighter = Color4Extensions.FromHex(@"deff87"); + public readonly Color4 GreyLimeLight = Color4Extensions.FromHex(@"657259"); + public readonly Color4 GreyLime = Color4Extensions.FromHex(@"3f443a"); + public readonly Color4 GreyLimeDark = Color4Extensions.FromHex(@"32352e"); + public readonly Color4 GreyLimeDarker = Color4Extensions.FromHex(@"2e302b"); - public readonly Color4 Violet = FromHex(@"bf04ff"); - public readonly Color4 GreyVioletLighter = FromHex(@"ebb8fe"); - public readonly Color4 GreyVioletLight = FromHex(@"685370"); - public readonly Color4 GreyViolet = FromHex(@"46334d"); - public readonly Color4 GreyVioletDark = FromHex(@"2c2230"); - public readonly Color4 GreyVioletDarker = FromHex(@"201823"); + public readonly Color4 Violet = Color4Extensions.FromHex(@"bf04ff"); + public readonly Color4 GreyVioletLighter = Color4Extensions.FromHex(@"ebb8fe"); + public readonly Color4 GreyVioletLight = Color4Extensions.FromHex(@"685370"); + public readonly Color4 GreyViolet = Color4Extensions.FromHex(@"46334d"); + public readonly Color4 GreyVioletDark = Color4Extensions.FromHex(@"2c2230"); + public readonly Color4 GreyVioletDarker = Color4Extensions.FromHex(@"201823"); - public readonly Color4 Carmine = FromHex(@"ff0542"); - public readonly Color4 GreyCarmineLighter = FromHex(@"deaab4"); - public readonly Color4 GreyCarmineLight = FromHex(@"644f53"); - public readonly Color4 GreyCarmine = FromHex(@"342b2d"); - public readonly Color4 GreyCarmineDark = FromHex(@"302a2b"); - public readonly Color4 GreyCarmineDarker = FromHex(@"241d1e"); + public readonly Color4 Carmine = Color4Extensions.FromHex(@"ff0542"); + public readonly Color4 GreyCarmineLighter = Color4Extensions.FromHex(@"deaab4"); + public readonly Color4 GreyCarmineLight = Color4Extensions.FromHex(@"644f53"); + public readonly Color4 GreyCarmine = Color4Extensions.FromHex(@"342b2d"); + public readonly Color4 GreyCarmineDark = Color4Extensions.FromHex(@"302a2b"); + public readonly Color4 GreyCarmineDarker = Color4Extensions.FromHex(@"241d1e"); - public readonly Color4 Gray0 = FromHex(@"000"); - public readonly Color4 Gray1 = FromHex(@"111"); - public readonly Color4 Gray2 = FromHex(@"222"); - public readonly Color4 Gray3 = FromHex(@"333"); - public readonly Color4 Gray4 = FromHex(@"444"); - public readonly Color4 Gray5 = FromHex(@"555"); - public readonly Color4 Gray6 = FromHex(@"666"); - public readonly Color4 Gray7 = FromHex(@"777"); - public readonly Color4 Gray8 = FromHex(@"888"); - public readonly Color4 Gray9 = FromHex(@"999"); - public readonly Color4 GrayA = FromHex(@"aaa"); - public readonly Color4 GrayB = FromHex(@"bbb"); - public readonly Color4 GrayC = FromHex(@"ccc"); - public readonly Color4 GrayD = FromHex(@"ddd"); - public readonly Color4 GrayE = FromHex(@"eee"); - public readonly Color4 GrayF = FromHex(@"fff"); + public readonly Color4 Gray0 = Color4Extensions.FromHex(@"000"); + public readonly Color4 Gray1 = Color4Extensions.FromHex(@"111"); + public readonly Color4 Gray2 = Color4Extensions.FromHex(@"222"); + public readonly Color4 Gray3 = Color4Extensions.FromHex(@"333"); + public readonly Color4 Gray4 = Color4Extensions.FromHex(@"444"); + public readonly Color4 Gray5 = Color4Extensions.FromHex(@"555"); + public readonly Color4 Gray6 = Color4Extensions.FromHex(@"666"); + public readonly Color4 Gray7 = Color4Extensions.FromHex(@"777"); + public readonly Color4 Gray8 = Color4Extensions.FromHex(@"888"); + public readonly Color4 Gray9 = Color4Extensions.FromHex(@"999"); + public readonly Color4 GrayA = Color4Extensions.FromHex(@"aaa"); + public readonly Color4 GrayB = Color4Extensions.FromHex(@"bbb"); + public readonly Color4 GrayC = Color4Extensions.FromHex(@"ccc"); + public readonly Color4 GrayD = Color4Extensions.FromHex(@"ddd"); + public readonly Color4 GrayE = Color4Extensions.FromHex(@"eee"); + public readonly Color4 GrayF = Color4Extensions.FromHex(@"fff"); - public readonly Color4 RedLighter = FromHex(@"ffeded"); - public readonly Color4 RedLight = FromHex(@"ed7787"); - public readonly Color4 Red = FromHex(@"ed1121"); - public readonly Color4 RedDark = FromHex(@"ba0011"); - public readonly Color4 RedDarker = FromHex(@"870000"); + public readonly Color4 RedLighter = Color4Extensions.FromHex(@"ffeded"); + public readonly Color4 RedLight = Color4Extensions.FromHex(@"ed7787"); + public readonly Color4 Red = Color4Extensions.FromHex(@"ed1121"); + public readonly Color4 RedDark = Color4Extensions.FromHex(@"ba0011"); + public readonly Color4 RedDarker = Color4Extensions.FromHex(@"870000"); - public readonly Color4 ChatBlue = FromHex(@"17292e"); + public readonly Color4 ChatBlue = Color4Extensions.FromHex(@"17292e"); - public readonly Color4 ContextMenuGray = FromHex(@"223034"); + public readonly Color4 ContextMenuGray = Color4Extensions.FromHex(@"223034"); } } diff --git a/osu.Game/Graphics/OsuFont.cs b/osu.Game/Graphics/OsuFont.cs index 22250d4a56..7c78141b4d 100644 --- a/osu.Game/Graphics/OsuFont.cs +++ b/osu.Game/Graphics/OsuFont.cs @@ -17,7 +17,9 @@ namespace osu.Game.Graphics /// public static FontUsage Default => GetFont(); - public static FontUsage Numeric => GetFont(Typeface.Venera, weight: FontWeight.Regular); + public static FontUsage Numeric => GetFont(Typeface.Venera, weight: FontWeight.Bold); + + public static FontUsage Torus => GetFont(Typeface.Torus, weight: FontWeight.Regular); /// /// Retrieves a . @@ -28,8 +30,15 @@ namespace osu.Game.Graphics /// Whether the font is italic. /// Whether all characters should be spaced the same distance apart. /// The . - public static FontUsage GetFont(Typeface typeface = Typeface.Exo, float size = DEFAULT_FONT_SIZE, FontWeight weight = FontWeight.Medium, bool italics = false, bool fixedWidth = false) - => new FontUsage(GetFamilyString(typeface), size, GetWeightString(typeface, weight), italics, fixedWidth); + public static FontUsage GetFont(Typeface typeface = Typeface.Torus, float size = DEFAULT_FONT_SIZE, FontWeight weight = FontWeight.Medium, bool italics = false, bool fixedWidth = false) + => new FontUsage(GetFamilyString(typeface), size, GetWeightString(typeface, weight), getItalics(italics), fixedWidth); + + private static bool getItalics(in bool italicsRequested) + { + // right now none of our fonts support italics. + // should add exceptions to this rule if they come up. + return false; + } /// /// Retrieves the string representation of a . @@ -40,11 +49,11 @@ namespace osu.Game.Graphics { switch (typeface) { - case Typeface.Exo: - return "Exo2.0"; - case Typeface.Venera: return "Venera"; + + case Typeface.Torus: + return "Torus"; } return null; @@ -57,7 +66,13 @@ namespace osu.Game.Graphics /// The . /// The string representation of in the specified . public static string GetWeightString(Typeface typeface, FontWeight weight) - => GetWeightString(GetFamilyString(typeface), weight); + { + if (typeface == Typeface.Torus && weight == FontWeight.Medium) + // torus doesn't have a medium; fallback to regular. + weight = FontWeight.Regular; + + return GetWeightString(GetFamilyString(typeface), weight); + } /// /// Retrieves the string representation of a . @@ -65,16 +80,7 @@ namespace osu.Game.Graphics /// The family string. /// The . /// The string representation of in the specified . - public static string GetWeightString(string family, FontWeight weight) - { - string weightString = weight.ToString(); - - // Only exo has an explicit "regular" weight, other fonts do not - if (family != GetFamilyString(Typeface.Exo) && weight == FontWeight.Regular) - weightString = string.Empty; - - return weightString; - } + public static string GetWeightString(string family, FontWeight weight) => weight.ToString(); } public static class OsuFontExtensions @@ -100,17 +106,40 @@ namespace osu.Game.Graphics public enum Typeface { - Exo, Venera, + Torus } public enum FontWeight { - Light, - Regular, - Medium, - SemiBold, - Bold, - Black + /// + /// Equivalent to weight 300. + /// + Light = 300, + + /// + /// Equivalent to weight 400. + /// + Regular = 400, + + /// + /// Equivalent to weight 500. + /// + Medium = 500, + + /// + /// Equivalent to weight 600. + /// + SemiBold = 600, + + /// + /// Equivalent to weight 700. + /// + Bold = 700, + + /// + /// Equivalent to weight 900. + /// + Black = 900 } } diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index 5ad5e5569a..d1f6fd445e 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -9,7 +9,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Platform; @@ -19,10 +19,11 @@ using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; namespace osu.Game.Graphics { - public class ScreenshotManager : Container, IKeyBindingHandler, IHandleGlobalInput + public class ScreenshotManager : Component, IKeyBindingHandler, IHandleGlobalKeyboardInput { private readonly BindableBool cursorVisibility = new BindableBool(true); @@ -35,18 +36,20 @@ namespace osu.Game.Graphics private Bindable screenshotFormat; private Bindable captureMenuCursor; - private GameHost host; + [Resolved] + private GameHost host { get; set; } + private Storage storage; - private NotificationOverlay notificationOverlay; + + [Resolved] + private NotificationOverlay notificationOverlay { get; set; } private SampleChannel shutter; [BackgroundDependencyLoader] - private void load(GameHost host, OsuConfigManager config, Storage storage, NotificationOverlay notificationOverlay, AudioManager audio) + private void load(OsuConfigManager config, Storage storage, AudioManager audio) { - this.host = host; this.storage = storage.GetStorageForDirectory(@"screenshots"); - this.notificationOverlay = notificationOverlay; screenshotFormat = config.GetBindable(OsuSetting.ScreenshotFormat); captureMenuCursor = config.GetBindable(OsuSetting.ScreenshotCaptureMenuCursor); @@ -67,7 +70,9 @@ namespace osu.Game.Graphics return false; } - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } private volatile int screenShotTasks; @@ -83,16 +88,25 @@ namespace osu.Game.Graphics const int frames_to_wait = 3; int framesWaited = 0; - ScheduledDelegate waitDelegate = host.DrawThread.Scheduler.AddDelayed(() => framesWaited++, 0, true); - while (framesWaited < frames_to_wait) - Thread.Sleep(10); - waitDelegate.Cancel(); + using (var framesWaitedEvent = new ManualResetEventSlim(false)) + { + ScheduledDelegate waitDelegate = host.DrawThread.Scheduler.AddDelayed(() => + { + if (framesWaited++ >= frames_to_wait) + // ReSharper disable once AccessToDisposedClosure + framesWaitedEvent.Set(); + }, 10, true); + + framesWaitedEvent.Wait(); + waitDelegate.Cancel(); + } } using (var image = await host.TakeScreenshotAsync()) { - Interlocked.Decrement(ref screenShotTasks); + if (Interlocked.Decrement(ref screenShotTasks) == 0 && cursorVisibility.Value == false) + cursorVisibility.Value = true; var fileName = getFileName(); if (fileName == null) return; @@ -106,11 +120,13 @@ namespace osu.Game.Graphics break; case ScreenshotFormat.Jpg: - image.SaveAsJpeg(stream); + const int jpeg_quality = 92; + + image.SaveAsJpeg(stream, new JpegEncoder { Quality = jpeg_quality }); break; default: - throw new ArgumentOutOfRangeException(nameof(screenshotFormat)); + throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat.Value}."); } notificationOverlay.Post(new SimpleNotification @@ -125,14 +141,6 @@ namespace osu.Game.Graphics } }); - protected override void Update() - { - base.Update(); - - if (cursorVisibility.Value == false && Interlocked.CompareExchange(ref screenShotTasks, 0, 0) == 0) - cursorVisibility.Value = true; - } - private string getFileName() { var dt = DateTime.Now; diff --git a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs index 74e387d60e..85df2d167f 100644 --- a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs +++ b/osu.Game/Graphics/Sprites/GlowingSpriteText.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.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -43,6 +44,24 @@ namespace osu.Game.Graphics.Sprites set => blurredText.Colour = value; } + public Vector2 Spacing + { + get => spriteText.Spacing; + set => spriteText.Spacing = blurredText.Spacing = value; + } + + public bool UseFullGlyphHeight + { + get => spriteText.UseFullGlyphHeight; + set => spriteText.UseFullGlyphHeight = blurredText.UseFullGlyphHeight = value; + } + + public Bindable Current + { + get => spriteText.Current; + set => spriteText.Current = value; + } + public GlowingSpriteText() { AutoSizeAxes = Axes.Both; @@ -55,8 +74,9 @@ namespace osu.Game.Graphics.Sprites Origin = Anchor.Centre, BlurSigma = new Vector2(4), CacheDrawnFrameBuffer = true, + RedrawOnScale = false, RelativeSizeAxes = Axes.Both, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Size = new Vector2(3f), Children = new[] { diff --git a/osu.Game/Graphics/Sprites/LogoAnimation.cs b/osu.Game/Graphics/Sprites/LogoAnimation.cs new file mode 100644 index 0000000000..b1383065fe --- /dev/null +++ b/osu.Game/Graphics/Sprites/LogoAnimation.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Vertices; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Graphics.Sprites +{ + public class LogoAnimation : Sprite + { + [BackgroundDependencyLoader] + private void load(ShaderManager shaders, TextureStore textures) + { + TextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); + RoundedTextureShader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, @"LogoAnimation"); // Masking isn't supported for now + } + + private float animationProgress; + + public float AnimationProgress + { + get => animationProgress; + set + { + if (animationProgress == value) return; + + animationProgress = value; + Invalidate(Invalidation.DrawInfo); + } + } + + public override bool IsPresent => true; + + protected override DrawNode CreateDrawNode() => new LogoAnimationDrawNode(this); + + private class LogoAnimationDrawNode : SpriteDrawNode + { + private LogoAnimation source => (LogoAnimation)Source; + + private float progress; + + public LogoAnimationDrawNode(LogoAnimation source) + : base(source) + { + } + + public override void ApplyState() + { + base.ApplyState(); + + progress = source.animationProgress; + } + + protected override void Blit(Action vertexAction) + { + Shader.GetUniform("progress").UpdateValue(ref progress); + + base.Blit(vertexAction); + } + + protected override bool CanDrawOpaqueInterior => false; + } + } +} diff --git a/osu.Game/Graphics/Sprites/OsuSpriteText.cs b/osu.Game/Graphics/Sprites/OsuSpriteText.cs index ed771bb03f..76e46513ba 100644 --- a/osu.Game/Graphics/Sprites/OsuSpriteText.cs +++ b/osu.Game/Graphics/Sprites/OsuSpriteText.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 osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Transforms; namespace osu.Game.Graphics.Sprites { @@ -15,23 +13,4 @@ namespace osu.Game.Graphics.Sprites Font = OsuFont.Default; } } - - public static class OsuSpriteTextTransformExtensions - { - /// - /// Sets to a new value after a duration. - /// - /// A to which further transforms can be added. - public static TransformSequence TransformTextTo(this T spriteText, string newText, double duration = 0, Easing easing = Easing.None) - where T : OsuSpriteText - => spriteText.TransformTo(nameof(OsuSpriteText.Text), newText, duration, easing); - - /// - /// Sets to a new value after a duration. - /// - /// A to which further transforms can be added. - public static TransformSequence TransformTextTo(this TransformSequence t, string newText, double duration = 0, Easing easing = Easing.None) - where T : OsuSpriteText - => t.Append(o => o.TransformTextTo(newText, duration, easing)); - } } diff --git a/osu.Game/Graphics/UserInterface/BackButton.cs b/osu.Game/Graphics/UserInterface/BackButton.cs index 48bf0848ae..b941e5fcbd 100644 --- a/osu.Game/Graphics/UserInterface/BackButton.cs +++ b/osu.Game/Graphics/UserInterface/BackButton.cs @@ -10,24 +10,32 @@ using osu.Game.Input.Bindings; namespace osu.Game.Graphics.UserInterface { - public class BackButton : VisibilityContainer, IKeyBindingHandler + public class BackButton : VisibilityContainer { public Action Action; private readonly TwoLayerButton button; - public BackButton() + public BackButton(Receptor receptor = null) { Size = TwoLayerButton.SIZE_EXTENDED; Child = button = new TwoLayerButton { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, Text = @"back", Icon = OsuIcon.LeftCircle, Action = () => Action?.Invoke() }; + + if (receptor == null) + { + // if a receptor wasn't provided, create our own locally. + Add(receptor = new Receptor()); + } + + receptor.OnBackPressed = () => button.Click(); } [BackgroundDependencyLoader] @@ -37,19 +45,6 @@ namespace osu.Game.Graphics.UserInterface button.HoverColour = colours.PinkDark; } - public bool OnPressed(GlobalAction action) - { - if (action == GlobalAction.Back) - { - Action?.Invoke(); - return true; - } - - return false; - } - - public bool OnReleased(GlobalAction action) => action == GlobalAction.Back; - protected override void PopIn() { button.MoveToX(0, 400, Easing.OutQuint); @@ -61,5 +56,26 @@ namespace osu.Game.Graphics.UserInterface button.MoveToX(-TwoLayerButton.SIZE_EXTENDED.X / 2, 400, Easing.OutQuint); button.FadeOut(400, Easing.OutQuint); } + + public class Receptor : Drawable, IKeyBindingHandler + { + public Action OnBackPressed; + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.Back: + OnBackPressed?.Invoke(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } + } } } diff --git a/osu.Game/Graphics/UserInterface/Bar.cs b/osu.Game/Graphics/UserInterface/Bar.cs index 2a858ccbcf..0be928cf83 100644 --- a/osu.Game/Graphics/UserInterface/Bar.cs +++ b/osu.Game/Graphics/UserInterface/Bar.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 osuTK; using osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using System; namespace osu.Game.Graphics.UserInterface { @@ -29,7 +29,7 @@ namespace osu.Game.Graphics.UserInterface get => length; set { - length = MathHelper.Clamp(value, 0, 1); + length = Math.Clamp(value, 0, 1); updateBarLength(); } } @@ -110,7 +110,7 @@ namespace osu.Game.Graphics.UserInterface [Flags] public enum BarDirection { - LeftToRight = 1 << 0, + LeftToRight = 1, RightToLeft = 1 << 1, TopToBottom = 1 << 2, BottomToTop = 1 << 3, diff --git a/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs b/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs index d1e55fee24..fb5ff4aad3 100644 --- a/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs +++ b/osu.Game/Graphics/UserInterface/BreadcrumbControl.cs @@ -15,19 +15,20 @@ namespace osu.Game.Graphics.UserInterface public class BreadcrumbControl : OsuTabControl { private const float padding = 10; - private const float item_chevron_size = 10; protected override TabItem CreateTabItem(T value) => new BreadcrumbTabItem(value) { AccentColour = AccentColour, }; - protected override float StripWidth() => base.StripWidth() - (padding + item_chevron_size); + protected override float StripWidth => base.StripWidth - TabContainer.FirstOrDefault()?.Padding.Right ?? 0; public BreadcrumbControl() { Height = 32; TabContainer.Spacing = new Vector2(padding, 0f); + SwitchTabOnRemove = false; + Current.ValueChanged += index => { foreach (var t in TabContainer.Children.OfType()) @@ -35,14 +36,16 @@ namespace osu.Game.Graphics.UserInterface var tIndex = TabContainer.IndexOf(t); var tabIndex = TabContainer.IndexOf(TabMap[index.NewValue]); - t.State = tIndex < tabIndex ? Visibility.Hidden : Visibility.Visible; - t.Chevron.FadeTo(tIndex <= tabIndex ? 0f : 1f, 500, Easing.OutQuint); + t.State = tIndex > tabIndex ? Visibility.Hidden : Visibility.Visible; + t.Chevron.FadeTo(tIndex >= tabIndex ? 0f : 1f, 500, Easing.OutQuint); } }; } - private class BreadcrumbTabItem : OsuTabItem, IStateful + public class BreadcrumbTabItem : OsuTabItem, IStateful { + protected virtual float ChevronSize => 10; + public event Action StateChanged; public readonly SpriteIcon Chevron; @@ -52,7 +55,6 @@ namespace osu.Game.Graphics.UserInterface public override bool HandleNonPositionalInput => State == Visibility.Visible; public override bool HandlePositionalInput => State == Visibility.Visible; - public override bool IsRemovable => true; private Visibility state; @@ -91,12 +93,12 @@ namespace osu.Game.Graphics.UserInterface { Text.Font = Text.Font.With(size: 18); Text.Margin = new MarginPadding { Vertical = 8 }; - Padding = new MarginPadding { Right = padding + item_chevron_size }; + Padding = new MarginPadding { Right = padding + ChevronSize }; Add(Chevron = new SpriteIcon { Anchor = Anchor.CentreRight, Origin = Anchor.CentreLeft, - Size = new Vector2(item_chevron_size), + Size = new Vector2(ChevronSize), Icon = FontAwesome.Solid.ChevronRight, Margin = new MarginPadding { Left = padding }, Alpha = 0f, diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index b50bf14bab..9b53ee7b2d 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -20,9 +20,10 @@ namespace osu.Game.Graphics.UserInterface { public class DialogButton : OsuClickableContainer { + private const float idle_width = 0.8f; private const float hover_width = 0.9f; + private const float hover_duration = 500; - private const float glow_fade_duration = 250; private const float click_duration = 200; public readonly BindableBool Selected = new BindableBool(); @@ -99,7 +100,7 @@ namespace osu.Game.Graphics.UserInterface RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, Anchor = Anchor.Centre, - Width = 0.8f, + Width = idle_width, Masking = true, MaskingSmoothness = 2, EdgeEffect = new EdgeEffectParameters @@ -199,26 +200,50 @@ namespace osu.Game.Graphics.UserInterface public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => backgroundContainer.ReceivePositionalInputAt(screenSpacePos); + private bool clickAnimating; + protected override bool OnClick(ClickEvent e) { - colourContainer.ResizeTo(new Vector2(1.5f, 1f), click_duration, Easing.In); - flash(); - - this.Delay(click_duration).Schedule(delegate + var flash = new Box { - colourContainer.ResizeTo(new Vector2(0.8f, 1f)); - spriteText.Spacing = Vector2.Zero; - glowContainer.FadeOut(); - }); + RelativeSizeAxes = Axes.Both, + Colour = ButtonColour, + Blending = BlendingParameters.Additive, + Alpha = 0.05f + }; + + colourContainer.Add(flash); + flash.FadeOutFromOne(100).Expire(); + + clickAnimating = true; + colourContainer.ResizeWidthTo(colourContainer.Width * 1.05f, 100, Easing.OutQuint) + .OnComplete(_ => + { + clickAnimating = false; + Selected.TriggerChange(); + }); return base.OnClick(e); } + protected override bool OnMouseDown(MouseDownEvent e) + { + colourContainer.ResizeWidthTo(hover_width * 0.98f, click_duration * 4, Easing.OutQuad); + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (Selected.Value) + colourContainer.ResizeWidthTo(hover_width, click_duration, Easing.In); + base.OnMouseUp(e); + } + protected override bool OnHover(HoverEvent e) { base.OnHover(e); - Selected.Value = true; + return true; } @@ -230,36 +255,23 @@ namespace osu.Game.Graphics.UserInterface private void selectionChanged(ValueChangedEvent args) { + if (clickAnimating) + return; + if (args.NewValue) { spriteText.TransformSpacingTo(hoverSpacing, hover_duration, Easing.OutElastic); - colourContainer.ResizeTo(new Vector2(hover_width, 1f), hover_duration, Easing.OutElastic); - glowContainer.FadeIn(glow_fade_duration, Easing.Out); + colourContainer.ResizeWidthTo(hover_width, hover_duration, Easing.OutElastic); + glowContainer.FadeIn(hover_duration, Easing.OutQuint); } else { - colourContainer.ResizeTo(new Vector2(0.8f, 1f), hover_duration, Easing.OutElastic); + colourContainer.ResizeWidthTo(idle_width, hover_duration, Easing.OutElastic); spriteText.TransformSpacingTo(Vector2.Zero, hover_duration, Easing.OutElastic); - glowContainer.FadeOut(glow_fade_duration, Easing.Out); + glowContainer.FadeOut(hover_duration, Easing.OutQuint); } } - private void flash() - { - var flash = new Box - { - RelativeSizeAxes = Axes.Both - }; - - colourContainer.Add(flash); - - flash.Colour = ButtonColour; - flash.Blending = BlendingMode.Additive; - flash.Alpha = 0.3f; - flash.FadeOutFromOne(click_duration); - flash.Expire(); - } - private void updateGlow() { leftGlow.Colour = ColourInfo.GradientHorizontal(new Color4(ButtonColour.R, ButtonColour.G, ButtonColour.B, 0f), ButtonColour); diff --git a/osu.Game/Graphics/UserInterface/DownloadButton.cs b/osu.Game/Graphics/UserInterface/DownloadButton.cs index 41b90d3802..da6c95299e 100644 --- a/osu.Game/Graphics/UserInterface/DownloadButton.cs +++ b/osu.Game/Graphics/UserInterface/DownloadButton.cs @@ -19,7 +19,8 @@ namespace osu.Game.Graphics.UserInterface private readonly SpriteIcon checkmark; private readonly Box background; - private OsuColour colours; + [Resolved] + private OsuColour colours { get; set; } public DownloadButton() { @@ -49,10 +50,8 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - this.colours = colours; - State.BindValueChanged(updateState, true); } @@ -64,16 +63,19 @@ namespace osu.Game.Graphics.UserInterface background.FadeColour(colours.Gray4, 500, Easing.InOutExpo); icon.MoveToX(0, 500, Easing.InOutExpo); checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo); + TooltipText = "Download"; break; case DownloadState.Downloading: background.FadeColour(colours.Blue, 500, Easing.InOutExpo); icon.MoveToX(0, 500, Easing.InOutExpo); checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo); + TooltipText = "Downloading..."; break; case DownloadState.Downloaded: background.FadeColour(colours.Yellow, 500, Easing.InOutExpo); + TooltipText = "Importing"; break; case DownloadState.LocallyAvailable: diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs new file mode 100644 index 0000000000..abaae7b43c --- /dev/null +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -0,0 +1,150 @@ +// 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; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Sprites; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + public class DrawableOsuMenuItem : Menu.DrawableMenuItem + { + public const int MARGIN_HORIZONTAL = 17; + public const int MARGIN_VERTICAL = 4; + private const int text_size = 17; + private const int transition_length = 80; + + private SampleChannel sampleClick; + private SampleChannel sampleHover; + + private TextContainer text; + + public DrawableOsuMenuItem(MenuItem item) + : base(item) + { + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleHover = audio.Samples.Get(@"UI/generic-hover"); + sampleClick = audio.Samples.Get(@"UI/generic-select"); + + BackgroundColour = Color4.Transparent; + BackgroundColourHover = Color4Extensions.FromHex(@"172023"); + + updateTextColour(); + + Item.Action.BindDisabledChanged(_ => updateState(), true); + } + + private void updateTextColour() + { + switch ((Item as OsuMenuItem)?.Type) + { + default: + case MenuItemType.Standard: + text.Colour = Color4.White; + break; + + case MenuItemType.Destructive: + text.Colour = Color4.Red; + break; + + case MenuItemType.Highlighted: + text.Colour = Color4Extensions.FromHex(@"ffcc22"); + break; + } + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private void updateState() + { + Alpha = Item.Action.Disabled ? 0.2f : 1; + + if (IsHovered && !Item.Action.Disabled) + { + sampleHover.Play(); + text.BoldText.FadeIn(transition_length, Easing.OutQuint); + text.NormalText.FadeOut(transition_length, Easing.OutQuint); + } + else + { + text.BoldText.FadeOut(transition_length, Easing.OutQuint); + text.NormalText.FadeIn(transition_length, Easing.OutQuint); + } + } + + protected override bool OnClick(ClickEvent e) + { + sampleClick.Play(); + return base.OnClick(e); + } + + protected sealed override Drawable CreateContent() => text = CreateTextContainer(); + protected virtual TextContainer CreateTextContainer() => new TextContainer(); + + protected class TextContainer : Container, IHasText + { + public string Text + { + get => NormalText.Text; + set + { + NormalText.Text = value; + BoldText.Text = value; + } + } + + public readonly SpriteText NormalText; + public readonly SpriteText BoldText; + + public TextContainer() + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + AutoSizeAxes = Axes.Both; + + Children = new Drawable[] + { + NormalText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: text_size), + Margin = new MarginPadding { Horizontal = MARGIN_HORIZONTAL, Vertical = MARGIN_VERTICAL }, + }, + BoldText = new OsuSpriteText + { + AlwaysPresent = true, + Alpha = 0, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold), + Margin = new MarginPadding { Horizontal = MARGIN_HORIZONTAL, Vertical = MARGIN_VERTICAL }, + } + }; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.cs new file mode 100644 index 0000000000..3dc99f2dbe --- /dev/null +++ b/osu.Game/Graphics/UserInterface/DrawableStatefulMenuItem.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 osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public class DrawableStatefulMenuItem : DrawableOsuMenuItem + { + protected new StatefulMenuItem Item => (StatefulMenuItem)base.Item; + + public DrawableStatefulMenuItem(StatefulMenuItem item) + : base(item) + { + } + + protected override TextContainer CreateTextContainer() => new ToggleTextContainer(Item); + + private class ToggleTextContainer : TextContainer + { + private readonly StatefulMenuItem menuItem; + private readonly Bindable state; + private readonly SpriteIcon stateIcon; + + public ToggleTextContainer(StatefulMenuItem menuItem) + { + this.menuItem = menuItem; + + state = menuItem.State.GetBoundCopy(); + + Add(stateIcon = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(10), + Margin = new MarginPadding { Horizontal = MARGIN_HORIZONTAL }, + AlwaysPresent = true, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + state.BindValueChanged(updateState, true); + } + + protected override void Update() + { + base.Update(); + + // Todo: This is bad. This can maybe be done better with a refactor of DrawableOsuMenuItem. + stateIcon.X = BoldText.DrawWidth + 10; + } + + private void updateState(ValueChangedEvent state) + { + var icon = menuItem.GetIconForState(state.NewValue); + + if (icon == null) + stateIcon.Alpha = 0; + else + { + stateIcon.Alpha = 1; + stateIcon.Icon = icon.Value; + } + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/ExpandingBar.cs b/osu.Game/Graphics/UserInterface/ExpandingBar.cs index 439a6002d8..60cb35b4c4 100644 --- a/osu.Game/Graphics/UserInterface/ExpandingBar.cs +++ b/osu.Game/Graphics/UserInterface/ExpandingBar.cs @@ -13,17 +13,17 @@ namespace osu.Game.Graphics.UserInterface /// public class ExpandingBar : Circle { - private bool isCollapsed; + private bool expanded = true; - public bool IsCollapsed + public bool Expanded { - get => isCollapsed; + get => expanded; set { - if (value == isCollapsed) + if (value == expanded) return; - isCollapsed = value; + expanded = value; updateState(); } } @@ -83,19 +83,21 @@ namespace osu.Game.Graphics.UserInterface updateState(); } - public void Collapse() => IsCollapsed = true; + public void Collapse() => Expanded = false; - public void Expand() => IsCollapsed = false; + public void Expand() => Expanded = true; private void updateState() { - float newSize = IsCollapsed ? CollapsedSize : ExpandedSize; - Easing easingType = IsCollapsed ? Easing.Out : Easing.OutElastic; + float newSize = expanded ? ExpandedSize : CollapsedSize; + Easing easingType = expanded ? Easing.OutElastic : Easing.Out; if (RelativeSizeAxes == Axes.X) this.ResizeHeightTo(newSize, 400, easingType); else this.ResizeWidthTo(newSize, 400, easingType); + + this.FadeTo(expanded ? 1 : 0.5f, 100, Easing.OutQuint); } } } diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index 8c00cae08a..5a1eb53fe1 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -18,7 +18,9 @@ namespace osu.Game.Graphics.UserInterface public string Link { get; set; } private Color4 hoverColour; - private GameHost host; + + [Resolved] + private GameHost host { get; set; } public ExternalLinkButton(string link = null) { @@ -32,10 +34,9 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colours, GameHost host) + private void load(OsuColour colours) { hoverColour = colours.Yellow; - this.host = host; } protected override bool OnHover(HoverEvent e) @@ -57,6 +58,6 @@ namespace osu.Game.Graphics.UserInterface return true; } - public string TooltipText => "View in browser"; + public string TooltipText => "view in browser"; } } diff --git a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs index f873db0dcb..f77a3109c9 100644 --- a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs +++ b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs @@ -2,22 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using osuTK.Graphics; -using System; using osu.Framework.Allocation; using osu.Framework.Input.Events; using osu.Framework.Platform; using osu.Game.Input.Bindings; using osuTK.Input; +using osu.Framework.Input.Bindings; namespace osu.Game.Graphics.UserInterface { /// /// A textbox which holds focus eagerly. /// - public class FocusedTextBox : OsuTextBox + public class FocusedTextBox : OsuTextBox, IKeyBindingHandler { - public Action Exit; - private bool focus; private bool allowImmediateFocus => host?.OnScreenKeyboardOverlapsGameWindow != true; @@ -38,13 +36,12 @@ namespace osu.Game.Graphics.UserInterface } } - private GameHost host; + [Resolved] + private GameHost host { get; set; } [BackgroundDependencyLoader] - private void load(GameHost host) + private void load() { - this.host = host; - BackgroundUnfocused = new Color4(10, 10, 10, 255); BackgroundFocused = new Color4(10, 10, 10, 255); } @@ -63,13 +60,15 @@ namespace osu.Game.Graphics.UserInterface if (!HasFocus) return false; if (e.Key == Key.Escape) - return false; // disable the framework-level handling of escape key for confority (we use GlobalAction.Back). + return false; // disable the framework-level handling of escape key for conformity (we use GlobalAction.Back). return base.OnKeyDown(e); } - public override bool OnPressed(GlobalAction action) + public bool OnPressed(GlobalAction action) { + if (!HasFocus) return false; + if (action == GlobalAction.Back) { if (Text.Length > 0) @@ -79,13 +78,11 @@ namespace osu.Game.Graphics.UserInterface } } - return base.OnPressed(action); + return false; } - protected override void KillFocus() + public void OnReleased(GlobalAction action) { - base.KillFocus(); - Exit?.Invoke(); } public override bool RequestsFocus => HoldFocus; diff --git a/osu.Game/Graphics/UserInterface/GradientLineTabControl.cs b/osu.Game/Graphics/UserInterface/GradientLineTabControl.cs new file mode 100644 index 0000000000..1d67c4e033 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/GradientLineTabControl.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 osu.Framework.Graphics.UserInterface; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; +using osu.Framework.Graphics.Colour; + +namespace osu.Game.Graphics.UserInterface +{ + public abstract class GradientLineTabControl : PageTabControl + { + protected Color4 LineColour + { + get => line.Colour; + set => line.Colour = value; + } + + private readonly GradientLine line; + + protected GradientLineTabControl() + { + RelativeSizeAxes = Axes.X; + + AddInternal(line = new GradientLine + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }); + } + + protected override Dropdown CreateDropdown() => null; + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(20, 0), + }; + + private class GradientLine : GridContainer + { + public GradientLine() + { + RelativeSizeAxes = Axes.X; + Size = new Vector2(0.8f, 1f); + + Content = new[] + { + new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(Color4.Transparent, Colour) + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(Colour, Color4.Transparent) + }, + } + }; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs index 70d988f60e..803facae04 100644 --- a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs @@ -1,11 +1,13 @@ -// 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.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Extensions; using osu.Framework.Input.Events; +using osuTK.Input; namespace osu.Game.Graphics.UserInterface { @@ -16,15 +18,27 @@ namespace osu.Game.Graphics.UserInterface public class HoverClickSounds : HoverSounds { private SampleChannel sampleClick; + private readonly MouseButton[] buttons; - public HoverClickSounds(HoverSampleSet sampleSet = HoverSampleSet.Normal) + /// + /// a container which plays sounds on hover and click for any specified s. + /// + /// Set of click samples to play. + /// + /// Array of button codes which should trigger the click sound. + /// If this optional parameter is omitted or set to null, the click sound will only be played on left click. + /// + public HoverClickSounds(HoverSampleSet sampleSet = HoverSampleSet.Normal, MouseButton[] buttons = null) : base(sampleSet) { + this.buttons = buttons ?? new[] { MouseButton.Left }; } protected override bool OnClick(ClickEvent e) { - sampleClick?.Play(); + if (buttons.Contains(e.Button) && Contains(e.ScreenSpaceMousePosition)) + sampleClick?.Play(); + return base.OnClick(e); } diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs index f1ac8ced6e..40899e7e95 100644 --- a/osu.Game/Graphics/UserInterface/HoverSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverSounds.cs @@ -9,6 +9,7 @@ using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Framework.Threading; namespace osu.Game.Graphics.UserInterface { @@ -20,6 +21,11 @@ namespace osu.Game.Graphics.UserInterface { private SampleChannel sampleHover; + /// + /// Length of debounce for hover sound playback, in milliseconds. Default is 50ms. + /// + public double HoverDebounceTime { get; } = 50; + protected readonly HoverSampleSet SampleSet; public HoverSounds(HoverSampleSet sampleSet = HoverSampleSet.Normal) @@ -28,9 +34,17 @@ namespace osu.Game.Graphics.UserInterface RelativeSizeAxes = Axes.Both; } + private ScheduledDelegate playDelegate; + protected override bool OnHover(HoverEvent e) { - sampleHover?.Play(); + playDelegate?.Cancel(); + + if (HoverDebounceTime <= 0) + sampleHover?.Play(); + else + playDelegate = Scheduler.AddDelayed(() => sampleHover?.Play(), HoverDebounceTime); + return base.OnHover(e); } diff --git a/osu.Game/Graphics/UserInterface/IconButton.cs b/osu.Game/Graphics/UserInterface/IconButton.cs index 27427581fd..858f517985 100644 --- a/osu.Game/Graphics/UserInterface/IconButton.cs +++ b/osu.Game/Graphics/UserInterface/IconButton.cs @@ -16,7 +16,7 @@ namespace osu.Game.Graphics.UserInterface private Color4? iconColour; /// - /// The icon colour. This does not affect . + /// The icon colour. This does not affect Colour. /// public Color4 IconColour { @@ -24,7 +24,7 @@ namespace osu.Game.Graphics.UserInterface set { iconColour = value; - icon.Colour = value; + icon.FadeColour(value); } } @@ -49,7 +49,7 @@ namespace osu.Game.Graphics.UserInterface } /// - /// The icon scale. This does not affect . + /// The icon scale. This does not affect Scale. /// public Vector2 IconScale { diff --git a/osu.Game/Graphics/UserInterface/LineGraph.cs b/osu.Game/Graphics/UserInterface/LineGraph.cs index 714e953816..42b523fc5c 100644 --- a/osu.Game/Graphics/UserInterface/LineGraph.cs +++ b/osu.Game/Graphics/UserInterface/LineGraph.cs @@ -4,11 +4,11 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Caching; using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Lines; +using osu.Framework.Layout; using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface @@ -83,17 +83,11 @@ namespace osu.Game.Graphics.UserInterface PathRadius = 1 } }); + + AddLayout(pathCached); } - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.DrawSize) > 0) - pathCached.Invalidate(); - - return base.Invalidate(invalidation, source, shallPropagate); - } - - private Cached pathCached = new Cached(); + private readonly LayoutValue pathCached = new LayoutValue(Invalidation.DrawSize); protected override void Update() { diff --git a/osu.Game/Graphics/UserInterface/LoadingAnimation.cs b/osu.Game/Graphics/UserInterface/LoadingAnimation.cs deleted file mode 100644 index 5a8a0da135..0000000000 --- a/osu.Game/Graphics/UserInterface/LoadingAnimation.cs +++ /dev/null @@ -1,64 +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.Framework.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Graphics.UserInterface -{ - /// - /// A loading spinner. - /// - public class LoadingAnimation : VisibilityContainer - { - private readonly SpriteIcon spinner; - private readonly SpriteIcon spinnerShadow; - - private const float spin_duration = 600; - private const float transition_duration = 200; - - public LoadingAnimation() - { - Size = new Vector2(20); - - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - - Children = new Drawable[] - { - spinnerShadow = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Position = new Vector2(1, 1), - Colour = Color4.Black, - Alpha = 0.4f, - Icon = FontAwesome.Solid.CircleNotch - }, - spinner = new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Icon = FontAwesome.Solid.CircleNotch - } - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - spinner.Spin(spin_duration, RotationDirection.Clockwise); - spinnerShadow.Spin(spin_duration, RotationDirection.Clockwise); - } - - protected override void PopIn() => this.FadeIn(transition_duration * 2, Easing.OutQuint); - - protected override void PopOut() => this.FadeOut(transition_duration, Easing.OutQuint); - } -} diff --git a/osu.Game/Graphics/UserInterface/LoadingButton.cs b/osu.Game/Graphics/UserInterface/LoadingButton.cs new file mode 100644 index 0000000000..81dc023d7e --- /dev/null +++ b/osu.Game/Graphics/UserInterface/LoadingButton.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 osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Graphics.UserInterface +{ + public abstract class LoadingButton : OsuHoverContainer + { + private bool isLoading; + + public bool IsLoading + { + get => isLoading; + set + { + isLoading = value; + + Enabled.Value = !isLoading; + + if (value) + { + loading.Show(); + OnLoadStarted(); + } + else + { + loading.Hide(); + OnLoadFinished(); + } + } + } + + public Vector2 LoadingAnimationSize + { + get => loading.Size; + set => loading.Size = value; + } + + private readonly LoadingSpinner loading; + + protected LoadingButton() + { + AddRange(new[] + { + CreateContent(), + loading = new LoadingSpinner + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(12) + } + }); + } + + protected override bool OnClick(ClickEvent e) + { + if (!Enabled.Value) + return false; + + try + { + return base.OnClick(e); + } + finally + { + // run afterwards as this will disable this button. + IsLoading = true; + } + } + + protected virtual void OnLoadStarted() + { + } + + protected virtual void OnLoadFinished() + { + } + + protected abstract Drawable CreateContent(); + } +} diff --git a/osu.Game/Graphics/UserInterface/LoadingLayer.cs b/osu.Game/Graphics/UserInterface/LoadingLayer.cs new file mode 100644 index 0000000000..c8c4424bee --- /dev/null +++ b/osu.Game/Graphics/UserInterface/LoadingLayer.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; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// A layer that will show a loading spinner and completely block input to an area. + /// Also optionally dims target elements. + /// Useful for disabling all elements in a form and showing we are waiting on a response, for instance. + /// + public class LoadingLayer : LoadingSpinner + { + private readonly Drawable dimTarget; + + /// + /// Constuct a new loading spinner. + /// + /// An optional target to dim when displayed. + /// Whether the spinner should have a surrounding black box for visibility. + public LoadingLayer(Drawable dimTarget = null, bool withBox = true) + : base(withBox) + { + RelativeSizeAxes = Axes.Both; + Size = new Vector2(1); + + this.dimTarget = dimTarget; + + MainContents.RelativeSizeAxes = Axes.None; + } + + public override bool HandleNonPositionalInput => false; + + protected override bool Handle(UIEvent e) + { + switch (e) + { + // blocking scroll can cause weird behaviour when this layer is used within a ScrollContainer. + case ScrollEvent _: + return false; + + // blocking touch events causes the ISourcedFromTouch versions to not be fired, potentially impeding behaviour of drawables *above* the loading layer that may utilise these. + // note that this will not work well if touch handling elements are beneath this loading layer (something to consider for the future). + case TouchEvent _: + return false; + } + + return true; + } + + protected override void PopIn() + { + dimTarget?.FadeColour(OsuColour.Gray(0.5f), TRANSITION_DURATION, Easing.OutQuint); + base.PopIn(); + } + + protected override void PopOut() + { + dimTarget?.FadeColour(Color4.White, TRANSITION_DURATION, Easing.OutQuint); + base.PopOut(); + } + + protected override void Update() + { + base.Update(); + MainContents.Size = new Vector2(Math.Clamp(Math.Min(DrawWidth, DrawHeight) * 0.25f, 30, 100)); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (State.Value == Visibility.Visible) + { + // ensure we don't leave the target in a bad state. + dimTarget?.FadeColour(Color4.White, TRANSITION_DURATION, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/LoadingSpinner.cs b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs new file mode 100644 index 0000000000..8174c4d5fe --- /dev/null +++ b/osu.Game/Graphics/UserInterface/LoadingSpinner.cs @@ -0,0 +1,110 @@ +// 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.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// A loading spinner. + /// + public class LoadingSpinner : VisibilityContainer + { + private readonly SpriteIcon spinner; + + protected override bool StartHidden => true; + + protected Container MainContents; + + public const float TRANSITION_DURATION = 500; + + private const float spin_duration = 900; + + /// + /// Constuct a new loading spinner. + /// + /// Whether the spinner should have a surrounding black box for visibility. + /// Whether colours should be inverted (black spinner instead of white). + public LoadingSpinner(bool withBox = false, bool inverted = false) + { + Size = new Vector2(60); + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Child = MainContents = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 20, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + Colour = inverted ? Color4.White : Color4.Black, + RelativeSizeAxes = Axes.Both, + Alpha = withBox ? 0.7f : 0 + }, + spinner = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = inverted ? Color4.Black : Color4.White, + Scale = new Vector2(withBox ? 0.6f : 1), + RelativeSizeAxes = Axes.Both, + Icon = FontAwesome.Solid.CircleNotch + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + rotate(); + } + + protected override void Update() + { + base.Update(); + + MainContents.CornerRadius = MainContents.DrawWidth / 4; + } + + protected override void PopIn() + { + if (Alpha < 0.5f) + // reset animation if the user can't see us. + rotate(); + + MainContents.ScaleTo(1, TRANSITION_DURATION, Easing.OutQuint); + this.FadeIn(TRANSITION_DURATION * 2, Easing.OutQuint); + } + + protected override void PopOut() + { + MainContents.ScaleTo(0.8f, TRANSITION_DURATION / 2, Easing.In); + this.FadeOut(TRANSITION_DURATION, Easing.OutQuint); + } + + private void rotate() + { + spinner.Spin(spin_duration * 3.5f, RotationDirection.Clockwise); + + MainContents.RotateTo(0).Then() + .RotateTo(90, spin_duration, Easing.InOutQuart).Then() + .RotateTo(180, spin_duration, Easing.InOutQuart).Then() + .RotateTo(270, spin_duration, Easing.InOutQuart).Then() + .RotateTo(360, spin_duration, Easing.InOutQuart).Then() + .Loop(); + } + } +} diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index 1a8fea4ff9..cfcf034d1c 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -64,7 +64,7 @@ namespace osu.Game.Graphics.UserInterface { RelativeSizeAxes = Axes.Both, Colour = HoverColour, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Alpha = 0, }, } @@ -107,10 +107,10 @@ namespace osu.Game.Graphics.UserInterface return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { Content.ScaleTo(1, 1000, Easing.OutElastic); - return base.OnMouseUp(e); + base.OnMouseUp(e); } } } diff --git a/osu.Game/Graphics/UserInterface/OsuButton.cs b/osu.Game/Graphics/UserInterface/OsuButton.cs index 7a27f825f6..9cf8f02024 100644 --- a/osu.Game/Graphics/UserInterface/OsuButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuButton.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.Diagnostics; 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.Sprites; using osu.Framework.Graphics.UserInterface; @@ -19,53 +21,106 @@ namespace osu.Game.Graphics.UserInterface /// public class OsuButton : Button { - private Box hover; + public string Text + { + get => SpriteText?.Text; + set + { + if (SpriteText != null) + SpriteText.Text = value; + } + } - public OsuButton() + private Color4? backgroundColour; + + public Color4 BackgroundColour + { + set + { + backgroundColour = value; + Background.FadeColour(value); + } + } + + protected override Container Content { get; } + + protected Box Hover; + protected Box Background; + protected SpriteText SpriteText; + + public OsuButton(HoverSampleSet? hoverSounds = HoverSampleSet.Loud) { Height = 40; - Content.Masking = true; - Content.CornerRadius = 5; + AddInternal(Content = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = 5, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + Background = new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, + Hover = new Box + { + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = Color4.White.Opacity(.1f), + Blending = BlendingParameters.Additive, + Depth = float.MinValue + }, + SpriteText = CreateText(), + } + }); + + if (hoverSounds.HasValue) + AddInternal(new HoverClickSounds(hoverSounds.Value)); + + Enabled.BindValueChanged(enabledChanged, true); } [BackgroundDependencyLoader] private void load(OsuColour colours) { - BackgroundColour = colours.BlueDark; - - AddRange(new Drawable[] - { - hover = new Box - { - RelativeSizeAxes = Axes.Both, - Blending = BlendingMode.Additive, - Colour = Color4.White.Opacity(0.1f), - Alpha = 0, - Depth = -1 - }, - new HoverClickSounds(HoverSampleSet.Loud), - }); + if (backgroundColour == null) + BackgroundColour = colours.BlueDark; Enabled.ValueChanged += enabledChanged; Enabled.TriggerChange(); } - private void enabledChanged(ValueChangedEvent e) + protected override bool OnClick(ClickEvent e) { - this.FadeColour(e.NewValue ? Color4.White : Color4.Gray, 200, Easing.OutQuint); + if (Enabled.Value) + { + Debug.Assert(backgroundColour != null); + Background.FlashColour(backgroundColour.Value, 200); + } + + return base.OnClick(e); } protected override bool OnHover(HoverEvent e) { - hover.FadeIn(200); + if (Enabled.Value) + Hover.FadeIn(200, Easing.OutQuint); + return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - hover.FadeOut(200); base.OnHoverLost(e); + + Hover.FadeOut(300); } protected override bool OnMouseDown(MouseDownEvent e) @@ -74,18 +129,23 @@ namespace osu.Game.Graphics.UserInterface return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { Content.ScaleTo(1, 1000, Easing.OutElastic); - return base.OnMouseUp(e); + base.OnMouseUp(e); } - protected override SpriteText CreateText() => new OsuSpriteText + protected virtual SpriteText CreateText() => new OsuSpriteText { Depth = -1, Origin = Anchor.Centre, Anchor = Anchor.Centre, Font = OsuFont.GetFont(weight: FontWeight.Bold) }; + + private void enabledChanged(ValueChangedEvent e) + { + this.FadeColour(e.NewValue ? Color4.White : Color4.Gray, 200, Easing.OutQuint); + } } } diff --git a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs index 47324ee646..6593531099 100644 --- a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs @@ -72,17 +72,11 @@ namespace osu.Game.Graphics.UserInterface Current.DisabledChanged += disabled => labelText.Alpha = Nub.Alpha = disabled ? 0.3f : 1; } - protected override void LoadComplete() + [BackgroundDependencyLoader] + private void load(AudioManager audio) { - base.LoadComplete(); - - Current.ValueChanged += enabled => - { - if (enabled.NewValue) - sampleChecked?.Play(); - else - sampleUnchecked?.Play(); - }; + sampleChecked = audio.Samples.Get(@"UI/check-on"); + sampleUnchecked = audio.Samples.Get(@"UI/check-off"); } protected override bool OnHover(HoverEvent e) @@ -99,11 +93,13 @@ namespace osu.Game.Graphics.UserInterface base.OnHoverLost(e); } - [BackgroundDependencyLoader] - private void load(AudioManager audio) + protected override void OnUserChange(bool value) { - sampleChecked = audio.Samples.Get(@"UI/check-on"); - sampleUnchecked = audio.Samples.Get(@"UI/check-off"); + base.OnUserChange(value); + if (value) + sampleChecked?.Play(); + else + sampleUnchecked?.Play(); } } } diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs index cea8427296..8c7b44f952 100644 --- a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterface { @@ -25,6 +26,8 @@ namespace osu.Game.Graphics.UserInterface }; ItemsContainer.Padding = new MarginPadding { Vertical = DrawableOsuMenuItem.MARGIN_VERTICAL }; + + MaxHeight = 250; } [BackgroundDependencyLoader] @@ -35,5 +38,7 @@ namespace osu.Game.Graphics.UserInterface protected override void AnimateOpen() => this.FadeIn(fade_duration, Easing.OutQuint); protected override void AnimateClose() => this.FadeOut(fade_duration, Easing.OutQuint); + + protected override Menu CreateSubMenu() => new OsuContextMenu(); } } diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index fc3a7229fa..cc76c12975 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -17,6 +17,8 @@ namespace osu.Game.Graphics.UserInterface { public class OsuDropdown : Dropdown, IHasAccentColour { + private const float corner_radius = 4; + private Color4 accentColour; public Color4 AccentColour @@ -57,9 +59,11 @@ namespace osu.Game.Graphics.UserInterface // todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring public OsuDropdownMenu() { - CornerRadius = 4; + CornerRadius = corner_radius; BackgroundColour = Color4.Black.Opacity(0.5f); + MaskingContainer.CornerRadius = corner_radius; + // todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring ItemsContainer.Padding = new MarginPadding(5); } @@ -138,7 +142,7 @@ namespace osu.Game.Graphics.UserInterface Foreground.Padding = new MarginPadding(2); Masking = true; - CornerRadius = 6; + CornerRadius = corner_radius; } [BackgroundDependencyLoader] @@ -237,7 +241,7 @@ namespace osu.Game.Graphics.UserInterface AutoSizeAxes = Axes.None; Margin = new MarginPadding { Bottom = 4 }; - CornerRadius = 4; + CornerRadius = corner_radius; Height = 40; Foreground.Children = new Drawable[] diff --git a/osu.Game/Graphics/UserInterface/OsuEnumDropdown.cs b/osu.Game/Graphics/UserInterface/OsuEnumDropdown.cs index e132027787..528d7d60f8 100644 --- a/osu.Game/Graphics/UserInterface/OsuEnumDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuEnumDropdown.cs @@ -6,12 +6,10 @@ using System; namespace osu.Game.Graphics.UserInterface { public class OsuEnumDropdown : OsuDropdown + where T : struct, Enum { public OsuEnumDropdown() { - if (!typeof(T).IsEnum) - throw new InvalidOperationException("OsuEnumDropdown only supports enums as the generic type argument"); - Items = (T[])Enum.GetValues(typeof(T)); } } diff --git a/osu.Game/Graphics/UserInterface/OsuMenu.cs b/osu.Game/Graphics/UserInterface/OsuMenu.cs index c4c6950eb1..e7bf4f66ee 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenu.cs @@ -1,18 +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.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osuTK; namespace osu.Game.Graphics.UserInterface @@ -45,7 +39,16 @@ namespace osu.Game.Graphics.UserInterface } } - protected override DrawableMenuItem CreateDrawableMenuItem(MenuItem item) => new DrawableOsuMenuItem(item); + protected override DrawableMenuItem CreateDrawableMenuItem(MenuItem item) + { + switch (item) + { + case StatefulMenuItem stateful: + return new DrawableStatefulMenuItem(stateful); + } + + return new DrawableOsuMenuItem(item); + } protected override ScrollContainer CreateScrollContainer(Direction direction) => new OsuScrollContainer(direction); @@ -53,122 +56,5 @@ namespace osu.Game.Graphics.UserInterface { Anchor = Direction == Direction.Horizontal ? Anchor.BottomLeft : Anchor.TopRight }; - - protected class DrawableOsuMenuItem : DrawableMenuItem - { - private const int margin_horizontal = 17; - private const int text_size = 17; - private const int transition_length = 80; - public const int MARGIN_VERTICAL = 4; - - private SampleChannel sampleClick; - private SampleChannel sampleHover; - - private TextContainer text; - - public DrawableOsuMenuItem(MenuItem item) - : base(item) - { - } - - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - sampleHover = audio.Samples.Get(@"UI/generic-hover"); - sampleClick = audio.Samples.Get(@"UI/generic-select"); - - BackgroundColour = Color4.Transparent; - BackgroundColourHover = OsuColour.FromHex(@"172023"); - - updateTextColour(); - } - - private void updateTextColour() - { - switch ((Item as OsuMenuItem)?.Type) - { - default: - case MenuItemType.Standard: - text.Colour = Color4.White; - break; - - case MenuItemType.Destructive: - text.Colour = Color4.Red; - break; - - case MenuItemType.Highlighted: - text.Colour = OsuColour.FromHex(@"ffcc22"); - break; - } - } - - protected override bool OnHover(HoverEvent e) - { - sampleHover.Play(); - text.BoldText.FadeIn(transition_length, Easing.OutQuint); - text.NormalText.FadeOut(transition_length, Easing.OutQuint); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - text.BoldText.FadeOut(transition_length, Easing.OutQuint); - text.NormalText.FadeIn(transition_length, Easing.OutQuint); - base.OnHoverLost(e); - } - - protected override bool OnClick(ClickEvent e) - { - sampleClick.Play(); - return base.OnClick(e); - } - - protected sealed override Drawable CreateContent() => text = CreateTextContainer(); - protected virtual TextContainer CreateTextContainer() => new TextContainer(); - - protected class TextContainer : Container, IHasText - { - public string Text - { - get => NormalText.Text; - set - { - NormalText.Text = value; - BoldText.Text = value; - } - } - - public readonly SpriteText NormalText; - public readonly SpriteText BoldText; - - public TextContainer() - { - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] - { - NormalText = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: text_size), - Margin = new MarginPadding { Horizontal = margin_horizontal, Vertical = MARGIN_VERTICAL }, - }, - BoldText = new OsuSpriteText - { - AlwaysPresent = true, - Alpha = 0, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold), - Margin = new MarginPadding { Horizontal = margin_horizontal, Vertical = MARGIN_VERTICAL }, - } - }; - } - } - } } } diff --git a/osu.Game/Graphics/UserInterface/OsuMenuItem.cs b/osu.Game/Graphics/UserInterface/OsuMenuItem.cs index b7aa666302..0fe41937ce 100644 --- a/osu.Game/Graphics/UserInterface/OsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/OsuMenuItem.cs @@ -11,9 +11,8 @@ namespace osu.Game.Graphics.UserInterface public readonly MenuItemType Type; public OsuMenuItem(string text, MenuItemType type = MenuItemType.Standard) - : base(text) + : this(text, type, null) { - Type = type; } public OsuMenuItem(string text, MenuItemType type, Action action) diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index 418ad038f7..ac6f5ceb1b 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -18,13 +18,20 @@ namespace osu.Game.Graphics.UserInterface { public class OsuPasswordTextBox : OsuTextBox, ISuppressKeyEventLogging { - protected override Drawable GetDrawableCharacter(char c) => new PasswordMaskChar(CalculatedTextSize); + protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer + { + AutoSizeAxes = Axes.Both, + Child = new PasswordMaskChar(CalculatedTextSize), + }; + + protected override bool AllowUniqueCharacterSamples => false; protected override bool AllowClipboardExport => false; private readonly CapsWarning warning; - private GameHost host; + [Resolved] + private GameHost host { get; set; } public OsuPasswordTextBox() { @@ -38,12 +45,6 @@ namespace osu.Game.Graphics.UserInterface }); } - [BackgroundDependencyLoader] - private void load(GameHost host) - { - this.host = host; - } - protected override bool OnKeyDown(KeyDownEvent e) { if (e.Key == Key.CapsLock) @@ -104,7 +105,7 @@ namespace osu.Game.Graphics.UserInterface private class CapsWarning : SpriteIcon, IHasTooltip { - public string TooltipText => @"Caps lock is active"; + public string TooltipText => @"caps lock is active"; public CapsWarning() { diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index 5c706781e6..d0356e77c7 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -9,6 +9,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; @@ -17,7 +18,7 @@ using osu.Framework.Input.Events; namespace osu.Game.Graphics.UserInterface { public class OsuSliderBar : SliderBar, IHasTooltip, IHasAccentColour - where T : struct, IEquatable, IComparable, IConvertible + where T : struct, IEquatable, IComparable, IConvertible { /// /// Maximum number of decimal digits to be displayed in the tooltip. @@ -31,9 +32,15 @@ namespace osu.Game.Graphics.UserInterface protected readonly Nub Nub; private readonly Box leftBox; private readonly Box rightBox; + private readonly Container nubContainer; public virtual string TooltipText { get; private set; } + /// + /// Whether to format the tooltip as a percentage or the actual value. + /// + public bool DisplayAsPercentage { get; set; } + private Color4 accentColour; public Color4 AccentColour @@ -72,10 +79,15 @@ namespace osu.Game.Graphics.UserInterface Origin = Anchor.CentreRight, Alpha = 0.5f, }, - Nub = new Nub + nubContainer = new Container { - Origin = Anchor.TopCentre, - Expanded = true, + RelativeSizeAxes = Axes.Both, + Child = Nub = new Nub + { + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.X, + Expanded = true, + }, }, new HoverClickSounds() }; @@ -90,6 +102,13 @@ namespace osu.Game.Graphics.UserInterface AccentColour = colours.Pink; } + protected override void Update() + { + base.Update(); + + nubContainer.Padding = new MarginPadding { Horizontal = RangePadding }; + } + protected override void LoadComplete() { base.LoadComplete(); @@ -114,10 +133,10 @@ namespace osu.Game.Graphics.UserInterface return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { Nub.Current.Value = false; - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override void OnUserChange(T value) @@ -151,18 +170,18 @@ namespace osu.Game.Graphics.UserInterface private void updateTooltipText(T value) { if (CurrentNumber.IsInteger) - TooltipText = ((int)Convert.ChangeType(value, typeof(int))).ToString("N0"); + TooltipText = value.ToInt32(NumberFormatInfo.InvariantInfo).ToString("N0"); else { - double floatValue = (double)Convert.ChangeType(value, typeof(double)); - double floatMinValue = (double)Convert.ChangeType(CurrentNumber.MinValue, typeof(double)); - double floatMaxValue = (double)Convert.ChangeType(CurrentNumber.MaxValue, typeof(double)); + double floatValue = value.ToDouble(NumberFormatInfo.InvariantInfo); - if (floatMaxValue == 1 && floatMinValue >= -1) - TooltipText = floatValue.ToString("P0"); + if (DisplayAsPercentage) + { + TooltipText = floatValue.ToString("0%"); + } else { - var decimalPrecision = normalise((decimal)Convert.ChangeType(CurrentNumber.Precision, typeof(decimal)), max_decimal_digits); + var decimalPrecision = normalise(CurrentNumber.Precision.ToDecimal(NumberFormatInfo.InvariantInfo), max_decimal_digits); // Find the number of significant digits (we could have less than 5 after normalize()) var significantDigits = findPrecision(decimalPrecision); @@ -175,15 +194,15 @@ namespace osu.Game.Graphics.UserInterface protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - leftBox.Scale = new Vector2(MathHelper.Clamp( - Nub.DrawPosition.X - Nub.DrawWidth / 2, 0, DrawWidth), 1); - rightBox.Scale = new Vector2(MathHelper.Clamp( - DrawWidth - Nub.DrawPosition.X - Nub.DrawWidth / 2, 0, DrawWidth), 1); + leftBox.Scale = new Vector2(Math.Clamp( + RangePadding + Nub.DrawPosition.X - Nub.DrawWidth / 2, 0, DrawWidth), 1); + rightBox.Scale = new Vector2(Math.Clamp( + DrawWidth - Nub.DrawPosition.X - RangePadding - Nub.DrawWidth / 2, 0, DrawWidth), 1); } protected override void UpdateValue(float value) { - Nub.MoveToX(RangePadding + UsableWidth * value, 250, Easing.OutQuint); + Nub.MoveToX(value, 250, Easing.OutQuint); } /// diff --git a/osu.Game/Graphics/UserInterface/OsuTabControl.cs b/osu.Game/Graphics/UserInterface/OsuTabControl.cs index 11f41b1a48..dbcce9a84a 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControl.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControl.cs @@ -10,44 +10,67 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface { public class OsuTabControl : TabControl { + private Color4 accentColour; + + public const float HORIZONTAL_SPACING = 10; + + public virtual Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + + if (Dropdown is IHasAccentColour dropdown) + dropdown.AccentColour = value; + foreach (var i in TabContainer.Children.OfType()) + i.AccentColour = value; + } + } + private readonly Box strip; - protected override Dropdown CreateDropdown() => new OsuTabDropdown(); + protected override Dropdown CreateDropdown() => new OsuTabDropdown(); protected override TabItem CreateTabItem(T value) => new OsuTabItem(value); - protected virtual float StripWidth() => TabContainer.Children.Sum(c => c.IsPresent ? c.DrawWidth + TabContainer.Spacing.X : 0) - TabContainer.Spacing.X; - protected virtual float StripHeight() => 1; + protected virtual float StripWidth => TabContainer.Children.Sum(c => c.IsPresent ? c.DrawWidth + TabContainer.Spacing.X : 0) - TabContainer.Spacing.X; + + /// + /// Whether entries should be automatically populated if is an type. + /// + protected virtual bool AddEnumEntriesAutomatically => true; private static bool isEnumType => typeof(T).IsEnum; public OsuTabControl() { - TabContainer.Spacing = new Vector2(10f, 0f); + TabContainer.Spacing = new Vector2(HORIZONTAL_SPACING, 0f); AddInternal(strip = new Box { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Height = StripHeight(), + Height = 1, Colour = Color4.White.Opacity(0), }); - if (isEnumType) + if (isEnumType && AddEnumEntriesAutomatically) + { foreach (var val in (T[])Enum.GetValues(typeof(T))) AddItem(val); + } } [BackgroundDependencyLoader] @@ -57,42 +80,19 @@ namespace osu.Game.Graphics.UserInterface AccentColour = colours.Blue; } - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - accentColour = value; - if (Dropdown is IHasAccentColour dropdown) - dropdown.AccentColour = value; - foreach (var i in TabContainer.Children.OfType()) - i.AccentColour = value; - } - } - public Color4 StripColour { get => strip.Colour; set => strip.Colour = value; } - protected override TabFillFlowContainer CreateTabFlow() => new OsuTabFillFlowContainer - { - Direction = FillDirection.Full, - RelativeSizeAxes = Axes.Both, - Depth = -1, - Masking = true - }; - protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); // dont bother calculating if the strip is invisible if (strip.Colour.MaxAlpha > 0) - strip.Width = Interpolation.ValueAt(MathHelper.Clamp(Clock.ElapsedFrameTime, 0, 1000), strip.Width, StripWidth(), 0, 500, Easing.OutQuint); + strip.Width = Interpolation.ValueAt(Math.Clamp(Clock.ElapsedFrameTime, 0, 1000), strip.Width, StripWidth, 0, 500, Easing.OutQuint); } public class OsuTabItem : TabItem, IHasAccentColour @@ -115,29 +115,29 @@ namespace osu.Game.Graphics.UserInterface private const float transition_length = 500; - private void fadeActive() + protected void FadeHovered() { Bar.FadeIn(transition_length, Easing.OutQuint); Text.FadeColour(Color4.White, transition_length, Easing.OutQuint); } - private void fadeInactive() + protected void FadeUnhovered() { - Bar.FadeOut(transition_length, Easing.OutQuint); - Text.FadeColour(AccentColour, transition_length, Easing.OutQuint); + Bar.FadeTo(IsHovered ? 1 : 0, transition_length, Easing.OutQuint); + Text.FadeColour(IsHovered ? Color4.White : AccentColour, transition_length, Easing.OutQuint); } protected override bool OnHover(HoverEvent e) { if (!Active.Value) - fadeActive(); + FadeHovered(); return true; } protected override void OnHoverLost(HoverLostEvent e) { if (!Active.Value) - fadeInactive(); + FadeUnhovered(); } [BackgroundDependencyLoader] @@ -174,113 +174,19 @@ namespace osu.Game.Graphics.UserInterface }, new HoverClickSounds() }; - - Active.BindValueChanged(active => Text.Font = Text.Font.With(Typeface.Exo, weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium), true); } - protected override void OnActivated() => fadeActive(); - - protected override void OnDeactivated() => fadeInactive(); - } - - // todo: this needs to go - private class OsuTabDropdown : OsuDropdown - { - public OsuTabDropdown() + protected override void OnActivated() { - RelativeSizeAxes = Axes.X; + Text.Font = Text.Font.With(weight: FontWeight.Bold); + FadeHovered(); } - protected override DropdownMenu CreateMenu() => new OsuTabDropdownMenu(); - - protected override DropdownHeader CreateHeader() => new OsuTabDropdownHeader + protected override void OnDeactivated() { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight - }; - - private class OsuTabDropdownMenu : OsuDropdownMenu - { - public OsuTabDropdownMenu() - { - Anchor = Anchor.TopRight; - Origin = Anchor.TopRight; - - BackgroundColour = Color4.Black.Opacity(0.7f); - MaxHeight = 400; - } - - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuTabDropdownMenuItem(item) { AccentColour = AccentColour }; - - private class DrawableOsuTabDropdownMenuItem : DrawableOsuDropdownMenuItem - { - public DrawableOsuTabDropdownMenuItem(MenuItem item) - : base(item) - { - ForegroundColourHover = Color4.Black; - } - } + Text.Font = Text.Font.With(weight: FontWeight.Medium); + FadeUnhovered(); } - - protected class OsuTabDropdownHeader : OsuDropdownHeader - { - public override Color4 AccentColour - { - get => base.AccentColour; - set - { - base.AccentColour = value; - Foreground.Colour = value; - } - } - - public OsuTabDropdownHeader() - { - RelativeSizeAxes = Axes.None; - AutoSizeAxes = Axes.X; - - BackgroundColour = Color4.Black.Opacity(0.5f); - - Background.Height = 0.5f; - Background.CornerRadius = 5; - Background.Masking = true; - - Foreground.RelativeSizeAxes = Axes.None; - Foreground.AutoSizeAxes = Axes.X; - Foreground.RelativeSizeAxes = Axes.Y; - Foreground.Margin = new MarginPadding(5); - - Foreground.Children = new Drawable[] - { - new SpriteIcon - { - Icon = FontAwesome.Solid.EllipsisH, - Size = new Vector2(14), - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - } - }; - - Padding = new MarginPadding { Left = 5, Right = 5 }; - } - - protected override bool OnHover(HoverEvent e) - { - Foreground.Colour = BackgroundColour; - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - Foreground.Colour = BackgroundColourHover; - base.OnHoverLost(e); - } - } - } - - private class OsuTabFillFlowContainer : TabFillFlowContainer - { - protected override int Compare(Drawable x, Drawable y) => CompareReverseChildID(x, y); } } } diff --git a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs index d158186899..bdc95ee048 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs @@ -21,7 +21,6 @@ namespace osu.Game.Graphics.UserInterface { private readonly Box box; private readonly SpriteText text; - private readonly SpriteIcon icon; private Color4? accentColour; @@ -32,12 +31,6 @@ namespace osu.Game.Graphics.UserInterface { accentColour = value; - if (Current.Value) - { - text.Colour = AccentColour; - icon.Colour = AccentColour; - } - updateFade(); } } @@ -52,6 +45,8 @@ namespace osu.Game.Graphics.UserInterface public OsuTabControlCheckbox() { + SpriteIcon icon; + AutoSizeAxes = Axes.Both; Children = new Drawable[] @@ -81,13 +76,16 @@ namespace osu.Game.Graphics.UserInterface Colour = Color4.White, Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, - } + }, + new HoverClickSounds() }; Current.ValueChanged += selected => { icon.Icon = selected.NewValue ? FontAwesome.Regular.CheckCircle : FontAwesome.Regular.Circle; text.Font = text.Font.With(weight: selected.NewValue ? FontWeight.Bold : FontWeight.Medium); + + updateFade(); }; } @@ -114,8 +112,8 @@ namespace osu.Game.Graphics.UserInterface private void updateFade() { - box.FadeTo(IsHovered ? 1 : 0, transition_length, Easing.OutQuint); - text.FadeColour(IsHovered ? Color4.White : AccentColour, transition_length, Easing.OutQuint); + box.FadeTo(Current.Value || IsHovered ? 1 : 0, transition_length, Easing.OutQuint); + text.FadeColour(Current.Value || IsHovered ? Color4.White : AccentColour, transition_length, Easing.OutQuint); } } } diff --git a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs new file mode 100644 index 0000000000..24b9ca8d90 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/OsuTabDropdown.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 osuTK; +using osuTK.Graphics; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; + +namespace osu.Game.Graphics.UserInterface +{ + public class OsuTabDropdown : OsuDropdown + { + public OsuTabDropdown() + { + RelativeSizeAxes = Axes.X; + } + + protected override DropdownMenu CreateMenu() => new OsuTabDropdownMenu(); + + protected override DropdownHeader CreateHeader() => new OsuTabDropdownHeader + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight + }; + + private class OsuTabDropdownMenu : OsuDropdownMenu + { + public OsuTabDropdownMenu() + { + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + + BackgroundColour = Color4.Black.Opacity(0.7f); + MaxHeight = 400; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuTabDropdownMenuItem(item) { AccentColour = AccentColour }; + + private class DrawableOsuTabDropdownMenuItem : DrawableOsuDropdownMenuItem + { + public DrawableOsuTabDropdownMenuItem(MenuItem item) + : base(item) + { + ForegroundColourHover = Color4.Black; + } + } + } + + protected class OsuTabDropdownHeader : OsuDropdownHeader + { + public override Color4 AccentColour + { + get => base.AccentColour; + set + { + base.AccentColour = value; + Foreground.Colour = value; + } + } + + public OsuTabDropdownHeader() + { + RelativeSizeAxes = Axes.None; + AutoSizeAxes = Axes.X; + + BackgroundColour = Color4.Black.Opacity(0.5f); + + Background.Height = 0.5f; + Background.CornerRadius = 5; + Background.Masking = true; + + Foreground.RelativeSizeAxes = Axes.None; + Foreground.AutoSizeAxes = Axes.X; + Foreground.RelativeSizeAxes = Axes.Y; + Foreground.Margin = new MarginPadding(5); + + Foreground.Children = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.EllipsisH, + Size = new Vector2(14), + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + } + }; + + Padding = new MarginPadding { Left = 5, Right = 5 }; + } + + protected override bool OnHover(HoverEvent e) + { + Foreground.Colour = BackgroundColour; + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + Foreground.Colour = BackgroundColourHover; + base.OnHoverLost(e); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 89de91bc9b..1ec4dfc91a 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -1,23 +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 osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.Sprites; using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Input.Bindings; +using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Game.Input.Bindings; +using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osuTK; namespace osu.Game.Graphics.UserInterface { - public class OsuTextBox : TextBox, IKeyBindingHandler + public class OsuTextBox : BasicTextBox { + private readonly SampleChannel[] textAddedSamples = new SampleChannel[4]; + private SampleChannel capsTextAddedSample; + private SampleChannel textRemovedSample; + private SampleChannel textCommittedSample; + private SampleChannel caretMovedSample; + + /// + /// Whether to allow playing a different samples based on the type of character. + /// If set to false, the same sample will be used for all characters. + /// + protected virtual bool AllowUniqueCharacterSamples => true; + protected override float LeftRightPadding => 10; + protected override float CaretWidth => 3; + protected override SpriteText CreatePlaceholder() => new OsuSpriteText { Font = OsuFont.GetFont(italics: true), @@ -36,11 +57,52 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colour) + private void load(OsuColour colour, AudioManager audio) { BackgroundUnfocused = Color4.Black.Opacity(0.5f); BackgroundFocused = OsuColour.Gray(0.3f).Opacity(0.8f); BackgroundCommit = BorderColour = colour.Yellow; + + for (int i = 0; i < textAddedSamples.Length; i++) + textAddedSamples[i] = audio.Samples.Get($@"Keyboard/key-press-{1 + i}"); + + capsTextAddedSample = audio.Samples.Get(@"Keyboard/key-caps"); + textRemovedSample = audio.Samples.Get(@"Keyboard/key-delete"); + textCommittedSample = audio.Samples.Get(@"Keyboard/key-confirm"); + caretMovedSample = audio.Samples.Get(@"Keyboard/key-movement"); + } + + protected override Color4 SelectionColour => new Color4(249, 90, 255, 255); + + protected override void OnUserTextAdded(string added) + { + base.OnUserTextAdded(added); + + if (added.Any(char.IsUpper) && AllowUniqueCharacterSamples) + capsTextAddedSample?.Play(); + else + textAddedSamples[RNG.Next(0, 3)]?.Play(); + } + + protected override void OnUserTextRemoved(string removed) + { + base.OnUserTextRemoved(removed); + + textRemovedSample?.Play(); + } + + protected override void OnTextCommitted(bool textChanged) + { + base.OnTextCommitted(textChanged); + + textCommittedSample?.Play(); + } + + protected override void OnCaretMoved(bool selecting) + { + base.OnCaretMoved(selecting); + + caretMovedSample?.Play(); } protected override void OnFocus(FocusEvent e) @@ -56,19 +118,96 @@ namespace osu.Game.Graphics.UserInterface base.OnFocusLost(e); } - protected override Drawable GetDrawableCharacter(char c) => new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: CalculatedTextSize) }; - - public virtual bool OnPressed(GlobalAction action) + protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer { - if (action == GlobalAction.Back) + AutoSizeAxes = Axes.Both, + Child = new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: CalculatedTextSize) }, + }; + + protected override Caret CreateCaret() => new OsuCaret + { + CaretWidth = CaretWidth, + SelectionColour = SelectionColour, + }; + + private class OsuCaret : Caret + { + private const float caret_move_time = 60; + + private readonly CaretBeatSyncedContainer beatSync; + + public OsuCaret() { - KillFocus(); - return true; + RelativeSizeAxes = Axes.Y; + Size = new Vector2(1, 0.9f); + + Colour = Color4.Transparent; + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + Masking = true; + CornerRadius = 1; + InternalChild = beatSync = new CaretBeatSyncedContainer + { + RelativeSizeAxes = Axes.Both, + }; } - return false; - } + public override void Hide() => this.FadeOut(200); - public bool OnReleased(GlobalAction action) => false; + public float CaretWidth { get; set; } + + public Color4 SelectionColour { get; set; } + + public override void DisplayAt(Vector2 position, float? selectionWidth) + { + beatSync.HasSelection = selectionWidth != null; + + if (selectionWidth != null) + { + this.MoveTo(new Vector2(position.X, position.Y), 60, Easing.Out); + this.ResizeWidthTo(selectionWidth.Value + CaretWidth / 2, caret_move_time, Easing.Out); + this.FadeColour(SelectionColour, 200, Easing.Out); + } + else + { + this.MoveTo(new Vector2(position.X - CaretWidth / 2, position.Y), 60, Easing.Out); + this.ResizeWidthTo(CaretWidth, caret_move_time, Easing.Out); + this.FadeColour(Color4.White, 200, Easing.Out); + } + } + + private class CaretBeatSyncedContainer : BeatSyncedContainer + { + private bool hasSelection; + + public bool HasSelection + { + set + { + hasSelection = value; + if (value) + + this.FadeTo(0.5f, 200, Easing.Out); + } + } + + public CaretBeatSyncedContainer() + { + MinimumBeatLength = 300; + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!hasSelection) + this.FadeTo(0.7f).FadeTo(0.4f, timingPoint.BeatLength, Easing.InOutSine); + } + } + } } } diff --git a/osu.Game/Graphics/UserInterface/PageTabControl.cs b/osu.Game/Graphics/UserInterface/PageTabControl.cs index 156a556b5e..d05a08108a 100644 --- a/osu.Game/Graphics/UserInterface/PageTabControl.cs +++ b/osu.Game/Graphics/UserInterface/PageTabControl.cs @@ -24,7 +24,13 @@ namespace osu.Game.Graphics.UserInterface Height = 30; } - public class PageTabItem : TabItem + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = colours.Yellow; + } + + public class PageTabItem : TabItem, IHasAccentColour { private const float transition_duration = 100; @@ -32,6 +38,18 @@ namespace osu.Game.Graphics.UserInterface protected readonly SpriteText Text; + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + box.Colour = accentColour; + } + } + public PageTabItem(T value) : base(value) { @@ -45,7 +63,7 @@ namespace osu.Game.Graphics.UserInterface Margin = new MarginPadding { Top = 8, Bottom = 8 }, Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, - Text = (value as Enum)?.GetDescription() ?? value.ToString(), + Text = CreateText(), Font = OsuFont.GetFont(size: 14) }, box = new Box @@ -60,14 +78,10 @@ namespace osu.Game.Graphics.UserInterface new HoverClickSounds() }; - Active.BindValueChanged(active => Text.Font = Text.Font.With(Typeface.Exo, weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium), true); + Active.BindValueChanged(active => Text.Font = Text.Font.With(Typeface.Torus, weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium), true); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - box.Colour = colours.Yellow; - } + protected virtual string CreateText() => (Value as Enum)?.GetDescription() ?? Value.ToString(); protected override bool OnHover(HoverEvent e) { diff --git a/osu.Game/Graphics/UserInterface/PercentageCounter.cs b/osu.Game/Graphics/UserInterface/PercentageCounter.cs index 8254bdda7c..2d53ec066b 100644 --- a/osu.Game/Graphics/UserInterface/PercentageCounter.cs +++ b/osu.Game/Graphics/UserInterface/PercentageCounter.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Utils; namespace osu.Game.Graphics.UserInterface { @@ -22,26 +24,17 @@ namespace osu.Game.Graphics.UserInterface public PercentageCounter() { - DisplayedCountSpriteText.Font = DisplayedCountSpriteText.Font.With(fixedWidth: true); Current.Value = DisplayedCount = 1.0f; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) => AccentColour = colours.BlueLighter; - - protected override string FormatCount(double count) - { - return $@"{count:P2}"; - } + protected override string FormatCount(double count) => count.FormatAccuracy(); protected override double GetProportionalDuration(double currentValue, double newValue) { return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; } - public override void Increment(double amount) - { - Current.Value = Current.Value + amount; - } + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f, fixedWidth: true)); } } diff --git a/osu.Game/Graphics/UserInterface/ProcessingOverlay.cs b/osu.Game/Graphics/UserInterface/ProcessingOverlay.cs deleted file mode 100644 index d75e78e2d9..0000000000 --- a/osu.Game/Graphics/UserInterface/ProcessingOverlay.cs +++ /dev/null @@ -1,56 +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.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osuTK.Graphics; - -namespace osu.Game.Graphics.UserInterface -{ - /// - /// An overlay that will consume all available space and block input when required. - /// Useful for disabling all elements in a form and showing we are waiting on a response, for instance. - /// - public class ProcessingOverlay : VisibilityContainer - { - private const float transition_duration = 200; - - public ProcessingOverlay() - { - RelativeSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChildren = new Drawable[] - { - new Box - { - Colour = Color4.Black, - RelativeSizeAxes = Axes.Both, - Alpha = 0.9f, - }, - new LoadingAnimation { State = { Value = Visibility.Visible } } - }; - } - - protected override bool Handle(UIEvent e) - { - return true; - } - - protected override void PopIn() - { - this.FadeIn(transition_duration * 2, Easing.OutQuint); - } - - protected override void PopOut() - { - this.FadeOut(transition_duration, Easing.OutQuint); - } - } -} diff --git a/osu.Game/Graphics/UserInterface/RollingCounter.cs b/osu.Game/Graphics/UserInterface/RollingCounter.cs index cd244ed7e6..b96181416d 100644 --- a/osu.Game/Graphics/UserInterface/RollingCounter.cs +++ b/osu.Game/Graphics/UserInterface/RollingCounter.cs @@ -7,20 +7,24 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.Sprites; using System; using System.Collections.Generic; +using osu.Framework.Allocation; using osu.Framework.Bindables; -using osuTK.Graphics; +using osu.Framework.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterface { - public abstract class RollingCounter : Container, IHasAccentColour + public abstract class RollingCounter : Container, IHasCurrentValue where T : struct, IEquatable { - /// - /// The current value. - /// - public Bindable Current = new Bindable(); + private readonly BindableWithCurrent current = new BindableWithCurrent(); - protected SpriteText DisplayedCountSpriteText; + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private SpriteText displayedCountSpriteText; /// /// If true, the roll-up duration will be proportional to change in value. @@ -46,57 +50,44 @@ namespace osu.Game.Graphics.UserInterface public virtual T DisplayedCount { get => displayedCount; - set { if (EqualityComparer.Default.Equals(displayedCount, value)) return; displayedCount = value; - DisplayedCountSpriteText.Text = FormatCount(value); + UpdateDisplay(); } } - public abstract void Increment(T amount); - - public float TextSize - { - get => DisplayedCountSpriteText.Font.Size; - set => DisplayedCountSpriteText.Font = DisplayedCountSpriteText.Font.With(size: value); - } - - public Color4 AccentColour - { - get => DisplayedCountSpriteText.Colour; - set => DisplayedCountSpriteText.Colour = value; - } - /// /// Skeleton of a numeric counter which value rolls over time. /// protected RollingCounter() { - Children = new Drawable[] - { - DisplayedCountSpriteText = new OsuSpriteText { Font = OsuFont.Numeric } - }; - - TextSize = 40; AutoSizeAxes = Axes.Both; + } - DisplayedCount = Current.Value; + [BackgroundDependencyLoader] + private void load() + { + displayedCountSpriteText = CreateSpriteText(); - Current.ValueChanged += val => - { - if (IsLoaded) TransformCount(displayedCount, val.NewValue); - }; + UpdateDisplay(); + Child = displayedCountSpriteText; + } + + protected void UpdateDisplay() + { + if (displayedCountSpriteText != null) + displayedCountSpriteText.Text = FormatCount(DisplayedCount); } protected override void LoadComplete() { base.LoadComplete(); - DisplayedCountSpriteText.Text = FormatCount(Current.Value); + Current.BindValueChanged(val => TransformCount(DisplayedCount, val.NewValue), true); } /// @@ -167,5 +158,10 @@ namespace osu.Game.Graphics.UserInterface this.TransformTo(nameof(DisplayedCount), newValue, rollingTotalDuration, RollingEasing); } + + protected virtual OsuSpriteText CreateSpriteText() => new OsuSpriteText + { + Font = OsuFont.Numeric.With(size: 40f), + }; } } diff --git a/osu.Game/Graphics/UserInterface/ScoreCounter.cs b/osu.Game/Graphics/UserInterface/ScoreCounter.cs index 63062cdc9d..d75e49a4ce 100644 --- a/osu.Game/Graphics/UserInterface/ScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/ScoreCounter.cs @@ -1,39 +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.Framework.Allocation; +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 class ScoreCounter : RollingCounter + public abstract class ScoreCounter : RollingCounter, IScoreCounter { protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; - public bool UseCommaSeparator; - /// - /// How many leading zeroes the counter has. + /// Whether comma separators should be displayed. /// - public uint LeadingZeroes - { - get; - protected set; - } + public bool UseCommaSeparator { get; } + + public Bindable RequiredDisplayDigits { get; } = new Bindable(); /// /// Displays score. /// /// How many leading zeroes the counter will have. - public ScoreCounter(uint leading = 0) + /// Whether comma separators should be displayed. + protected ScoreCounter(int leading = 0, bool useCommaSeparator = false) { - DisplayedCountSpriteText.Font = DisplayedCountSpriteText.Font.With(fixedWidth: true); - LeadingZeroes = leading; - } + UseCommaSeparator = useCommaSeparator; - [BackgroundDependencyLoader] - private void load(OsuColour colours) => AccentColour = colours.BlueLighter; + RequiredDisplayDigits.Value = leading; + RequiredDisplayDigits.BindValueChanged(_ => UpdateDisplay()); + } protected override double GetProportionalDuration(double currentValue, double newValue) { @@ -42,17 +40,18 @@ namespace osu.Game.Graphics.UserInterface protected override string FormatCount(double count) { - string format = new string('0', (int)LeadingZeroes); + string format = new string('0', RequiredDisplayDigits.Value); + if (UseCommaSeparator) + { for (int i = format.Length - 3; i > 0; i -= 3) format = format.Insert(i, @","); + } return ((long)count).ToString(format); } - public override void Increment(double amount) - { - Current.Value = Current.Value + amount; - } + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true)); } } diff --git a/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs b/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs index 3e0a6c3265..e85525b2f8 100644 --- a/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs +++ b/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs @@ -17,7 +17,8 @@ namespace osu.Game.Graphics.UserInterface stack.ScreenPushed += onPushed; stack.ScreenExited += onExited; - onPushed(null, stack.CurrentScreen); + if (stack.CurrentScreen != null) + onPushed(null, stack.CurrentScreen); Current.ValueChanged += current => current.NewValue.MakeCurrent(); } diff --git a/osu.Game/Graphics/UserInterface/ScreenTitle.cs b/osu.Game/Graphics/UserInterface/ScreenTitle.cs deleted file mode 100644 index 7b39238e5e..0000000000 --- a/osu.Game/Graphics/UserInterface/ScreenTitle.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 osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Graphics.UserInterface -{ - public abstract class ScreenTitle : CompositeDrawable, IHasAccentColour - { - public const float ICON_WIDTH = ICON_SIZE + icon_spacing; - - protected const float ICON_SIZE = 25; - - private SpriteIcon iconSprite; - private readonly OsuSpriteText titleText, pageText; - - private const float icon_spacing = 10; - - protected IconUsage Icon - { - set - { - if (iconSprite == null) - throw new InvalidOperationException($"Cannot use {nameof(Icon)} with a custom {nameof(CreateIcon)} function."); - - iconSprite.Icon = value; - } - } - - protected string Title - { - set => titleText.Text = value; - } - - protected string Section - { - set => pageText.Text = value; - } - - public Color4 AccentColour - { - get => pageText.Colour; - set => pageText.Colour = value; - } - - protected virtual Drawable CreateIcon() => iconSprite = new SpriteIcon - { - Size = new Vector2(ICON_SIZE), - }; - - protected ScreenTitle() - { - AutoSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(icon_spacing, 0), - Children = new[] - { - CreateIcon(), - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(6, 0), - Children = new[] - { - titleText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Light), - }, - pageText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Light), - } - } - } - } - }, - }; - } - } -} diff --git a/osu.Game/Graphics/UserInterface/SearchTextBox.cs b/osu.Game/Graphics/UserInterface/SearchTextBox.cs index c3efe2ed45..fe92054d25 100644 --- a/osu.Game/Graphics/UserInterface/SearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/SearchTextBox.cs @@ -14,33 +14,39 @@ namespace osu.Game.Graphics.UserInterface { protected virtual bool AllowCommit => false; - public override bool HandleLeftRightArrows => false; - public SearchTextBox() { Height = 35; - AddRange(new Drawable[] + Add(new SpriteIcon { - new SpriteIcon - { - Icon = FontAwesome.Solid.Search, - Origin = Anchor.CentreRight, - Anchor = Anchor.CentreRight, - Margin = new MarginPadding { Right = 10 }, - Size = new Vector2(20), - } + Icon = FontAwesome.Solid.Search, + Origin = Anchor.CentreRight, + Anchor = Anchor.CentreRight, + Margin = new MarginPadding { Right = 10 }, + Size = new Vector2(20), }); + TextFlow.Padding = new MarginPadding { Right = 35 }; PlaceholderText = "type to search"; } public override bool OnPressed(PlatformAction action) { - // Shift+delete is handled via PlatformAction on macOS. this is not so useful in the context of a SearchTextBox - // as we do not allow arrow key navigation in the first place (ie. the care should always be at the end of text) - // Avoid handling it here to allow other components to potentially consume the shortcut. - if (action.ActionType == PlatformActionType.CharNext && action.ActionMethod == PlatformActionMethod.Delete) - return false; + switch (action.ActionType) + { + case PlatformActionType.LineEnd: + case PlatformActionType.LineStart: + return false; + + // Shift+delete is handled via PlatformAction on macOS. this is not so useful in the context of a SearchTextBox + // as we do not allow arrow key navigation in the first place (ie. the caret should always be at the end of text) + // Avoid handling it here to allow other components to potentially consume the shortcut. + case PlatformActionType.CharNext: + if (action.ActionMethod == PlatformActionMethod.Delete) + return false; + + break; + } return base.OnPressed(action); } diff --git a/osu.Game/Graphics/UserInterface/SeekLimitedSearchTextBox.cs b/osu.Game/Graphics/UserInterface/SeekLimitedSearchTextBox.cs new file mode 100644 index 0000000000..6a9e8a5b8c --- /dev/null +++ b/osu.Game/Graphics/UserInterface/SeekLimitedSearchTextBox.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. + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// A which does not handle left/right arrow keys for seeking. + /// + public class SeekLimitedSearchTextBox : SearchTextBox + { + public override bool HandleLeftRightArrows => false; + } +} diff --git a/osu.Game/Graphics/UserInterface/ShowMoreButton.cs b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs new file mode 100644 index 0000000000..924c7913f3 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/ShowMoreButton.cs @@ -0,0 +1,134 @@ +// 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.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using System.Collections.Generic; + +namespace osu.Game.Graphics.UserInterface +{ + public class ShowMoreButton : LoadingButton + { + private const int duration = 200; + + public string Text + { + get => text.Text; + set => text.Text = value; + } + + protected override IEnumerable EffectTargets => new[] { background }; + + private ChevronIcon leftIcon; + private ChevronIcon rightIcon; + private SpriteText text; + private Box background; + private FillFlowContainer textContainer; + + public ShowMoreButton() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Background2; + HoverColour = colourProvider.Background1; + } + + protected override Drawable CreateContent() => new CircularContainer + { + Masking = true, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + textContainer = new FillFlowContainer + { + AlwaysPresent = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10), + Margin = new MarginPadding + { + Horizontal = 20, + Vertical = 5 + }, + Children = new Drawable[] + { + leftIcon = new ChevronIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = "show more".ToUpper(), + }, + rightIcon = new ChevronIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + } + } + }; + + protected override void OnLoadStarted() => textContainer.FadeOut(duration, Easing.OutQuint); + + protected override void OnLoadFinished() => textContainer.FadeIn(duration, Easing.OutQuint); + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + leftIcon.SetHoveredState(true); + rightIcon.SetHoveredState(true); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + leftIcon.SetHoveredState(false); + rightIcon.SetHoveredState(false); + } + + public class ChevronIcon : SpriteIcon + { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + public ChevronIcon() + { + Size = new Vector2(7.5f); + Icon = FontAwesome.Solid.ChevronDown; + } + + [BackgroundDependencyLoader] + private void load() + { + Colour = colourProvider.Foreground1; + } + + public void SetHoveredState(bool hovered) => + this.FadeColour(hovered ? colourProvider.Light1 : colourProvider.Foreground1, 200, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Graphics/UserInterface/SimpleComboCounter.cs b/osu.Game/Graphics/UserInterface/SimpleComboCounter.cs deleted file mode 100644 index 4717401c75..0000000000 --- a/osu.Game/Graphics/UserInterface/SimpleComboCounter.cs +++ /dev/null @@ -1,39 +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.Allocation; - -namespace osu.Game.Graphics.UserInterface -{ - /// - /// Used as an accuracy counter. Represented visually as a percentage. - /// - public class SimpleComboCounter : RollingCounter - { - protected override double RollingDuration => 750; - - public SimpleComboCounter() - { - Current.Value = DisplayedCount = 0; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) => AccentColour = colours.BlueLighter; - - protected override string FormatCount(int count) - { - return $@"{count}x"; - } - - protected override double GetProportionalDuration(int currentValue, int newValue) - { - return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; - } - - public override void Increment(int amount) - { - Current.Value = Current.Value + amount; - } - } -} diff --git a/osu.Game/Graphics/UserInterface/StarCounter.cs b/osu.Game/Graphics/UserInterface/StarCounter.cs index 3ee572602b..b13d6485ac 100644 --- a/osu.Game/Graphics/UserInterface/StarCounter.cs +++ b/osu.Game/Graphics/UserInterface/StarCounter.cs @@ -4,7 +4,7 @@ using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using System; using System.Linq; using osu.Framework.Graphics.Sprites; @@ -13,7 +13,7 @@ namespace osu.Game.Graphics.UserInterface { public class StarCounter : Container { - private readonly Container stars; + private readonly FillFlowContainer stars; /// /// Maximum amount of stars displayed. @@ -23,34 +23,29 @@ namespace osu.Game.Graphics.UserInterface /// public int StarCount { get; } - private double animationDelay => 80; + /// + /// The added delay for each subsequent star to be animated. + /// + protected virtual double AnimationDelay => 80; - private double scalingDuration => 1000; - private Easing scalingEasing => Easing.OutElasticHalf; - private float minStarScale => 0.4f; - - private double fadingDuration => 100; - private float minStarAlpha => 0.5f; - - private const float star_size = 20; private const float star_spacing = 4; - private float countStars; + private float current; /// /// Amount of stars represented. /// - public float CountStars + public float Current { - get => countStars; + get => current; set { - if (countStars == value) return; + if (current == value) return; if (IsLoaded) - transformCount(value); - countStars = value; + animate(value); + current = value; } } @@ -71,11 +66,13 @@ namespace osu.Game.Graphics.UserInterface AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(star_spacing), - ChildrenEnumerable = Enumerable.Range(0, StarCount).Select(i => new Star { Alpha = minStarAlpha }) + ChildrenEnumerable = Enumerable.Range(0, StarCount).Select(i => CreateStar()) } }; } + public virtual Star CreateStar() => new DefaultStar(); + protected override void LoadComplete() { base.LoadComplete(); @@ -86,63 +83,60 @@ namespace osu.Game.Graphics.UserInterface public void ResetCount() { - countStars = 0; + current = 0; StopAnimation(); } public void ReplayAnimation() { - var t = countStars; + var t = current; ResetCount(); - CountStars = t; + Current = t; } public void StopAnimation() { - int i = 0; - + animate(current); foreach (var star in stars.Children) + star.FinishTransforms(true); + } + + private float getStarScale(int i, float value) => i + 1 <= value ? 1.0f : Interpolation.ValueAt(value, 0, 1.0f, i, i + 1); + + private void animate(float newValue) + { + for (var i = 0; i < stars.Children.Count; i++) { + var star = stars.Children[i]; + star.ClearTransforms(true); - star.FadeTo(i < countStars ? 1.0f : minStarAlpha); - star.Icon.ScaleTo(getStarScale(i, countStars)); - i++; + + double delay = (current <= newValue ? Math.Max(i - current, 0) : Math.Max(current - 1 - i, 0)) * AnimationDelay; + + using (star.BeginDelayedSequence(delay, true)) + star.DisplayAt(getStarScale(i, newValue)); } } - private float getStarScale(int i, float value) + public class DefaultStar : Star { - if (value <= i) - return minStarScale; + private const double scaling_duration = 1000; - return i + 1 <= value ? 1.0f : Interpolation.ValueAt(value, minStarScale, 1.0f, i, i + 1); - } + private const double fading_duration = 100; - private void transformCount(float newValue) - { - int i = 0; + private const Easing scaling_easing = Easing.OutElasticHalf; - foreach (var star in stars.Children) - { - star.ClearTransforms(true); + private const float min_star_scale = 0.4f; - var delay = (countStars <= newValue ? Math.Max(i - countStars, 0) : Math.Max(countStars - 1 - i, 0)) * animationDelay; - star.Delay(delay).FadeTo(i < newValue ? 1.0f : minStarAlpha, fadingDuration); - star.Icon.Delay(delay).ScaleTo(getStarScale(i, newValue), scalingDuration, scalingEasing); + private const float star_size = 20; - i++; - } - } - - private class Star : Container - { public readonly SpriteIcon Icon; - public Star() + public DefaultStar() { Size = new Vector2(star_size); - Child = Icon = new SpriteIcon + InternalChild = Icon = new SpriteIcon { Size = new Vector2(star_size), Icon = FontAwesome.Solid.Star, @@ -150,6 +144,19 @@ namespace osu.Game.Graphics.UserInterface Origin = Anchor.Centre, }; } + + public override void DisplayAt(float scale) + { + scale = Math.Clamp(scale, min_star_scale, 1); + + this.FadeTo(scale, fading_duration); + Icon.ScaleTo(scale, scaling_duration, scaling_easing); + } + } + + public abstract class Star : CompositeDrawable + { + public abstract void DisplayAt(float scale); } } } diff --git a/osu.Game/Graphics/UserInterface/StatefulMenuItem.cs b/osu.Game/Graphics/UserInterface/StatefulMenuItem.cs new file mode 100644 index 0000000000..0d7b36e51b --- /dev/null +++ b/osu.Game/Graphics/UserInterface/StatefulMenuItem.cs @@ -0,0 +1,105 @@ +// 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.Sprites; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// An which contains and displays a state. + /// + public abstract class StatefulMenuItem : OsuMenuItem + { + /// + /// The current state that should be displayed. + /// + public readonly Bindable State = new Bindable(); + + /// + /// 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. + protected StatefulMenuItem(string text, Func changeStateFunc, MenuItemType type = MenuItemType.Standard) + : this(text, changeStateFunc, type, null) + { + } + + /// + /// 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 StatefulMenuItem(string text, Func changeStateFunc, MenuItemType type, Action action) + : base(text, type) + { + Action.Value = () => + { + State.Value = changeStateFunc?.Invoke(State.Value) ?? State.Value; + action?.Invoke(State.Value); + }; + } + + /// + /// Retrieves the icon to be displayed for a state. + /// + /// The state to retrieve the relevant icon for. + /// The icon to be displayed for . + public abstract IconUsage? GetIconForState(object state); + } + + public abstract class StatefulMenuItem : StatefulMenuItem + where T : struct + { + /// + /// The current state that should be displayed. + /// + public new readonly Bindable State = new Bindable(); + + /// + /// 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. + protected StatefulMenuItem(string text, Func changeStateFunc, MenuItemType type = MenuItemType.Standard) + : this(text, changeStateFunc, type, null) + { + } + + /// + /// 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 StatefulMenuItem(string text, Func changeStateFunc, MenuItemType type, Action action) + : base(text, o => changeStateFunc?.Invoke((T)o) ?? o, type, o => action?.Invoke((T)o)) + { + base.State.BindValueChanged(state => + { + if (state.NewValue == null) + base.State.Value = default(T); + + State.Value = (T)base.State.Value; + }, true); + + State.BindValueChanged(state => base.State.Value = state.NewValue); + } + + public sealed override IconUsage? GetIconForState(object state) => GetIconForState((T)state); + + /// + /// Retrieves the icon to be displayed for a state. + /// + /// The state to retrieve the relevant icon for. + /// The icon to be displayed for . + public abstract IconUsage? GetIconForState(T state); + } +} diff --git a/osu.Game/Graphics/UserInterface/TernaryState.cs b/osu.Game/Graphics/UserInterface/TernaryState.cs new file mode 100644 index 0000000000..d4de28044f --- /dev/null +++ b/osu.Game/Graphics/UserInterface/TernaryState.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. + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// An on/off state with an extra indeterminate state. + /// + public enum TernaryState + { + /// + /// The current state is false. + /// + False, + + /// + /// The current state is a combination of and . + /// The state becomes if the is pressed. + /// + Indeterminate, + + /// + /// The current state is true. + /// + True + } +} diff --git a/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.cs new file mode 100644 index 0000000000..acf4065f49 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/TernaryStateMenuItem.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 osu.Framework.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// An with three possible states. + /// + public class TernaryStateMenuItem : StatefulMenuItem + { + /// + /// Creates a new . + /// + /// The text to display. + /// 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) + { + } + + public override IconUsage? GetIconForState(TernaryState state) + { + switch (state) + { + case TernaryState.Indeterminate: + return FontAwesome.Solid.DotCircle; + + case TernaryState.True: + return FontAwesome.Solid.Check; + } + + 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/ToggleMenuItem.cs b/osu.Game/Graphics/UserInterface/ToggleMenuItem.cs new file mode 100644 index 0000000000..f9ff9859dd --- /dev/null +++ b/osu.Game/Graphics/UserInterface/ToggleMenuItem.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 osu.Framework.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterface +{ + /// + /// An which displays an enabled or disabled state. + /// + public class ToggleMenuItem : StatefulMenuItem + { + /// + /// Creates a new . + /// + /// The text to display. + /// The type of action which this performs. + public ToggleMenuItem(string text, MenuItemType type = MenuItemType.Standard) + : this(text, type, null) + { + } + + /// + /// Creates a new . + /// + /// The text to display. + /// The type of action which this performs. + /// A delegate to be invoked when this is pressed. + public ToggleMenuItem(string text, MenuItemType type, Action action) + : base(text, value => !value, type, action) + { + } + + public override IconUsage? GetIconForState(bool state) => state ? (IconUsage?)FontAwesome.Solid.Check : null; + } +} diff --git a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs index c74d00cd1e..120149d8c1 100644 --- a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs +++ b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs @@ -15,6 +15,7 @@ using System; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Game.Screens.Select; namespace osu.Game.Graphics.UserInterface { @@ -28,7 +29,9 @@ namespace osu.Game.Graphics.UserInterface private const int transform_time = 600; private const int pulse_length = 250; - private const float shear = 0.1f; + private const float shear_width = 5f; + + private static readonly Vector2 shear = new Vector2(shear_width / Footer.HEIGHT, 0); public static readonly Vector2 SIZE_EXTENDED = new Vector2(140, 50); public static readonly Vector2 SIZE_RETRACTED = new Vector2(100, 50); @@ -56,7 +59,7 @@ namespace osu.Game.Graphics.UserInterface c1.Origin = c1.Anchor = value.HasFlag(Anchor.x2) ? Anchor.TopLeft : Anchor.TopRight; c2.Origin = c2.Anchor = value.HasFlag(Anchor.x2) ? Anchor.TopRight : Anchor.TopLeft; - X = value.HasFlag(Anchor.x2) ? SIZE_RETRACTED.X * shear * 0.5f : 0; + X = value.HasFlag(Anchor.x2) ? SIZE_RETRACTED.X * shear.X * 0.5f : 0; Remove(c1); Remove(c2); @@ -70,6 +73,7 @@ namespace osu.Game.Graphics.UserInterface public TwoLayerButton() { Size = SIZE_RETRACTED; + Shear = shear; Children = new Drawable[] { @@ -82,7 +86,6 @@ namespace osu.Game.Graphics.UserInterface new Container { RelativeSizeAxes = Axes.Both, - Shear = new Vector2(shear, 0), Masking = true, MaskingSmoothness = 2, EdgeEffect = new EdgeEffectParameters @@ -105,6 +108,7 @@ namespace osu.Game.Graphics.UserInterface { Anchor = Anchor.Centre, Origin = Anchor.Centre, + Shear = -shear, }, } }, @@ -119,7 +123,6 @@ namespace osu.Game.Graphics.UserInterface new Container { RelativeSizeAxes = Axes.Both, - Shear = new Vector2(shear, 0), Masking = true, MaskingSmoothness = 2, EdgeEffect = new EdgeEffectParameters @@ -144,6 +147,7 @@ namespace osu.Game.Graphics.UserInterface { Origin = Anchor.Centre, Anchor = Anchor.Centre, + Shear = -shear, } } }, @@ -188,7 +192,6 @@ namespace osu.Game.Graphics.UserInterface var flash = new Box { RelativeSizeAxes = Axes.Both, - Shear = new Vector2(shear, 0), Colour = Color4.White.Opacity(0.5f), }; Add(flash); @@ -227,7 +230,7 @@ namespace osu.Game.Graphics.UserInterface }; } - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); diff --git a/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs new file mode 100644 index 0000000000..a1cd074619 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs @@ -0,0 +1,297 @@ +// 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 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.Input.Events; +using osu.Framework.Platform; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class DirectorySelector : CompositeDrawable + { + private FillFlowContainer directoryFlow; + + [Resolved] + private GameHost host { get; set; } + + [Cached] + public readonly Bindable CurrentPath = new Bindable(); + + public DirectorySelector(string initialPath = null) + { + CurrentPath.Value = new DirectoryInfo(initialPath ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + } + + [BackgroundDependencyLoader] + private void load() + { + Padding = new MarginPadding(10); + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 50), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + new CurrentDirectoryDisplay + { + RelativeSizeAxes = Axes.Both, + }, + }, + new Drawable[] + { + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = directoryFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + } + } + } + } + }; + + CurrentPath.BindValueChanged(updateDisplay, true); + } + + private void updateDisplay(ValueChangedEvent directory) + { + directoryFlow.Clear(); + + try + { + if (directory.NewValue == null) + { + var drives = DriveInfo.GetDrives(); + + foreach (var drive in drives) + directoryFlow.Add(new DirectoryPiece(drive.RootDirectory)); + } + else + { + directoryFlow.Add(new ParentDirectoryPiece(CurrentPath.Value.Parent)); + + directoryFlow.AddRange(GetEntriesForPath(CurrentPath.Value)); + } + } + catch (Exception) + { + CurrentPath.Value = directory.OldValue; + this.FlashColour(Color4.Red, 300); + } + } + + protected virtual IEnumerable GetEntriesForPath(DirectoryInfo path) + { + foreach (var dir in path.GetDirectories().OrderBy(d => d.Name)) + { + if ((dir.Attributes & FileAttributes.Hidden) == 0) + yield return new DirectoryPiece(dir); + } + } + + private class CurrentDirectoryDisplay : CompositeDrawable + { + [Resolved] + private Bindable currentDirectory { get; set; } + + private FillFlowContainer flow; + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + flow = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Spacing = new Vector2(5), + Height = DisplayPiece.HEIGHT, + Direction = FillDirection.Horizontal, + }, + }; + + currentDirectory.BindValueChanged(updateDisplay, true); + } + + private void updateDisplay(ValueChangedEvent dir) + { + flow.Clear(); + + List pathPieces = new List(); + + DirectoryInfo ptr = dir.NewValue; + + while (ptr != null) + { + pathPieces.Insert(0, new CurrentDisplayPiece(ptr)); + ptr = ptr.Parent; + } + + flow.ChildrenEnumerable = new Drawable[] + { + new OsuSpriteText { Text = "Current Directory: ", Font = OsuFont.Default.With(size: DisplayPiece.HEIGHT), }, + new ComputerPiece(), + }.Concat(pathPieces); + } + + private class ComputerPiece : CurrentDisplayPiece + { + protected override IconUsage? Icon => null; + + public ComputerPiece() + : base(null, "Computer") + { + } + } + + private class CurrentDisplayPiece : DirectoryPiece + { + public CurrentDisplayPiece(DirectoryInfo directory, string displayName = null) + : base(directory, displayName) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Flow.Add(new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(FONT_SIZE / 2) + }); + } + + protected override IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar) ? base.Icon : null; + } + } + + private class ParentDirectoryPiece : DirectoryPiece + { + protected override IconUsage? Icon => FontAwesome.Solid.Folder; + + public ParentDirectoryPiece(DirectoryInfo directory) + : base(directory, "..") + { + } + } + + protected class DirectoryPiece : DisplayPiece + { + protected readonly DirectoryInfo Directory; + + [Resolved] + private Bindable currentDirectory { get; set; } + + public DirectoryPiece(DirectoryInfo directory, string displayName = null) + : base(displayName) + { + Directory = directory; + } + + protected override bool OnClick(ClickEvent e) + { + currentDirectory.Value = Directory; + return true; + } + + protected override string FallbackName => Directory.Name; + + protected override IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar) + ? FontAwesome.Solid.Database + : FontAwesome.Regular.Folder; + } + + protected abstract class DisplayPiece : CompositeDrawable + { + public const float HEIGHT = 20; + + protected const float FONT_SIZE = 16; + + private readonly string displayName; + + protected FillFlowContainer Flow; + + protected DisplayPiece(string displayName = null) + { + this.displayName = displayName; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AutoSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.GreySeafoamDarker, + RelativeSizeAxes = Axes.Both, + }, + Flow = new FillFlowContainer + { + AutoSizeAxes = Axes.X, + Height = 20, + Margin = new MarginPadding { Vertical = 2, Horizontal = 5 }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + } + }; + + if (Icon.HasValue) + { + Flow.Add(new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = Icon.Value, + Size = new Vector2(FONT_SIZE) + }); + } + + Flow.Add(new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = displayName ?? FallbackName, + Font = OsuFont.Default.With(size: FONT_SIZE) + }); + } + + protected abstract string FallbackName { get; } + + protected abstract IconUsage? Icon { get; } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/FileSelector.cs new file mode 100644 index 0000000000..e10b8f7033 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/FileSelector.cs @@ -0,0 +1,94 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class FileSelector : DirectorySelector + { + private readonly string[] validFileExtensions; + + [Cached] + public readonly Bindable CurrentFile = new Bindable(); + + public FileSelector(string initialPath = null, string[] validFileExtensions = null) + : base(initialPath) + { + this.validFileExtensions = validFileExtensions ?? Array.Empty(); + } + + protected override IEnumerable GetEntriesForPath(DirectoryInfo path) + { + foreach (var dir in base.GetEntriesForPath(path)) + yield return dir; + + IEnumerable files = path.GetFiles(); + + if (validFileExtensions.Length > 0) + files = files.Where(f => validFileExtensions.Contains(f.Extension)); + + foreach (var file in files.OrderBy(d => d.Name)) + { + if ((file.Attributes & FileAttributes.Hidden) == 0) + yield return new FilePiece(file); + } + } + + protected class FilePiece : DisplayPiece + { + private readonly FileInfo file; + + [Resolved] + private Bindable currentFile { get; set; } + + public FilePiece(FileInfo file) + { + this.file = file; + } + + protected override bool OnClick(ClickEvent e) + { + currentFile.Value = file; + return true; + } + + protected override string FallbackName => file.Name; + + protected override IconUsage? Icon + { + get + { + switch (file.Extension) + { + case ".ogg": + case ".mp3": + case ".wav": + return FontAwesome.Regular.FileAudio; + + case ".jpg": + case ".jpeg": + case ".png": + return FontAwesome.Regular.FileImage; + + case ".mp4": + case ".avi": + case ".mov": + case ".flv": + return FontAwesome.Regular.FileVideo; + + default: + return FontAwesome.Regular.File; + } + } + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledComponent.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledComponent.cs new file mode 100644 index 0000000000..dd6a902989 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledComponent.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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public abstract class LabelledComponent : LabelledDrawable, IHasCurrentValue + where TDrawable : Drawable, IHasCurrentValue + { + protected LabelledComponent(bool padded) + : base(padded) + { + } + + public Bindable Current + { + get => Component.Current; + set => Component.Current = value; + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs new file mode 100644 index 0000000000..ec68223a3d --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs @@ -0,0 +1,134 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public abstract class LabelledDrawable : CompositeDrawable + where T : Drawable + { + protected const float CONTENT_PADDING_VERTICAL = 10; + protected const float CONTENT_PADDING_HORIZONTAL = 15; + protected const float CORNER_RADIUS = 15; + + /// + /// The component that is being displayed. + /// + protected readonly T Component; + + private readonly OsuTextFlowContainer labelText; + private readonly OsuTextFlowContainer descriptionText; + + /// + /// Creates a new . + /// + /// Whether the component should be padded or should be expanded to the bounds of this . + protected LabelledDrawable(bool padded) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + CornerRadius = CORNER_RADIUS; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("1c2125"), + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = padded + ? new MarginPadding { Horizontal = CONTENT_PADDING_HORIZONTAL, Vertical = CONTENT_PADDING_VERTICAL } + : new MarginPadding { Left = CONTENT_PADDING_HORIZONTAL }, + Spacing = new Vector2(0, 12), + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] + { + new Drawable[] + { + labelText = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Bold)) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = 20 } + }, + new Container + { + // top right works better when the vertical height of the component changes smoothly (avoids weird layout animations). + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = Component = CreateComponent().With(d => + { + d.Anchor = Anchor.CentreRight; + d.Origin = Anchor.CentreRight; + }) + } + }, + }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } + }, + descriptionText = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold, italics: true)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Bottom = padded ? 0 : CONTENT_PADDING_VERTICAL }, + Alpha = 0, + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour osuColour) + { + descriptionText.Colour = osuColour.Yellow; + } + + public string Label + { + set => labelText.Text = value; + } + + public string Description + { + set + { + descriptionText.Text = value; + + if (!string.IsNullOrEmpty(value)) + descriptionText.Show(); + else + descriptionText.Hide(); + } + } + + /// + /// Creates the component that should be displayed. + /// + /// The component. + protected abstract T CreateComponent(); + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.cs new file mode 100644 index 0000000000..cba94e314b --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledSliderBar.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; +using osu.Framework.Graphics; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class LabelledSliderBar : LabelledComponent, TNumber> + where TNumber : struct, IEquatable, IComparable, IConvertible + { + public LabelledSliderBar() + : base(true) + { + } + + protected override SettingsSlider CreateComponent() => new SettingsSlider + { + TransferValueOnCommit = true, + RelativeSizeAxes = Axes.X, + }; + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledSwitchButton.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledSwitchButton.cs new file mode 100644 index 0000000000..c374d80830 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledSwitchButton.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.Graphics.UserInterfaceV2 +{ + public class LabelledSwitchButton : LabelledComponent + { + public LabelledSwitchButton() + : base(true) + { + } + + protected override SwitchButton CreateComponent() => new SwitchButton(); + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs new file mode 100644 index 0000000000..4aeda74be8 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.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.UserInterface; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class LabelledTextBox : LabelledComponent + { + public event TextBox.OnCommitHandler OnCommit; + + public LabelledTextBox() + : base(false) + { + } + + public bool ReadOnly + { + set => Component.ReadOnly = value; + } + + public string PlaceholderText + { + set => Component.PlaceholderText = value; + } + + public string Text + { + set => Component.Text = value; + } + + public Container TabbableContentContainer + { + set => Component.TabbableContentContainer = value; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Component.BorderColour = colours.Blue; + } + + protected virtual OsuTextBox CreateTextBox() => new OsuTextBox + { + CommitOnFocusLost = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + CornerRadius = CORNER_RADIUS, + }; + + protected override OsuTextBox CreateComponent() => CreateTextBox().With(t => + { + t.OnCommit += (sender, newText) => OnCommit?.Invoke(sender, newText); + }); + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs b/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs new file mode 100644 index 0000000000..a7fd25b554 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs @@ -0,0 +1,118 @@ +// 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.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class SwitchButton : Checkbox + { + private const float border_thickness = 4.5f; + private const float padding = 1.25f; + + private readonly Box fill; + private readonly Container switchContainer; + private readonly Drawable switchCircle; + private readonly CircularBorderContainer circularContainer; + + private Color4 enabledColour; + private Color4 disabledColour; + + public SwitchButton() + { + Size = new Vector2(45, 20); + + InternalChild = circularContainer = new CircularBorderContainer + { + RelativeSizeAxes = Axes.Both, + BorderColour = Color4.White, + BorderThickness = border_thickness, + Masking = true, + Children = new Drawable[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both, + AlwaysPresent = true, + Alpha = 0 + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(border_thickness + padding), + Child = switchContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Child = switchCircle = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Masking = true, + Child = new Box { RelativeSizeAxes = Axes.Both } + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + enabledColour = colours.BlueDark; + disabledColour = colours.Gray3; + + switchContainer.Colour = enabledColour; + fill.Colour = disabledColour; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(updateState, true); + FinishTransforms(true); + } + + private void updateState(ValueChangedEvent state) + { + switchCircle.MoveToX(state.NewValue ? switchContainer.DrawWidth - switchCircle.DrawWidth : 0, 200, Easing.OutQuint); + fill.FadeTo(state.NewValue ? 1 : 0, 250, Easing.OutQuint); + + updateBorder(); + } + + protected override bool OnHover(HoverEvent e) + { + updateBorder(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateBorder(); + base.OnHoverLost(e); + } + + private void updateBorder() + { + circularContainer.TransformBorderTo((Current.Value ? enabledColour : disabledColour).Lighten(IsHovered ? 0.3f : 0)); + } + + private class CircularBorderContainer : CircularContainer + { + public void TransformBorderTo(SRGBColour colour) + => this.TransformTo(nameof(BorderColour), colour, 250, Easing.OutQuint); + } + } +} diff --git a/osu.Game/IO/Archives/ArchiveReader.cs b/osu.Game/IO/Archives/ArchiveReader.cs index 4ee7a19ebc..f74574e60c 100644 --- a/osu.Game/IO/Archives/ArchiveReader.cs +++ b/osu.Game/IO/Archives/ArchiveReader.cs @@ -41,11 +41,9 @@ namespace osu.Game.IO.Archives return null; byte[] buffer = new byte[input.Length]; - await input.ReadAsync(buffer, 0, buffer.Length); + await input.ReadAsync(buffer); return buffer; } } - - public abstract Stream GetUnderlyingStream(); } } diff --git a/osu.Game/IO/Archives/LegacyByteArrayReader.cs b/osu.Game/IO/Archives/LegacyByteArrayReader.cs new file mode 100644 index 0000000000..0c3620403f --- /dev/null +++ b/osu.Game/IO/Archives/LegacyByteArrayReader.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.IO; + +namespace osu.Game.IO.Archives +{ + /// + /// Allows reading a single file from the provided stream. + /// + public class LegacyByteArrayReader : ArchiveReader + { + private readonly byte[] content; + + public LegacyByteArrayReader(byte[] content, string filename) + : base(filename) + { + this.content = content; + } + + public override Stream GetStream(string name) => new MemoryStream(content); + + public override void Dispose() + { + } + + public override IEnumerable Filenames => new[] { Name }; + } +} diff --git a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs index eff02ae7a5..dfae58aed7 100644 --- a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs +++ b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs @@ -28,7 +28,5 @@ namespace osu.Game.IO.Archives } public override IEnumerable Filenames => Directory.GetFiles(path, "*", SearchOption.AllDirectories).Select(f => f.Replace(path, string.Empty).Trim(Path.DirectorySeparatorChar)).ToArray(); - - public override Stream GetUnderlyingStream() => null; } } diff --git a/osu.Game/IO/Archives/LegacyFileArchiveReader.cs b/osu.Game/IO/Archives/LegacyFileArchiveReader.cs index bd5f9cbd07..72e5a21079 100644 --- a/osu.Game/IO/Archives/LegacyFileArchiveReader.cs +++ b/osu.Game/IO/Archives/LegacyFileArchiveReader.cs @@ -28,7 +28,5 @@ namespace osu.Game.IO.Archives } public override IEnumerable Filenames => new[] { Name }; - - public override Stream GetUnderlyingStream() => null; } } diff --git a/osu.Game/IO/Archives/ZipArchiveReader.cs b/osu.Game/IO/Archives/ZipArchiveReader.cs index d934ac54c4..80dfa104f3 100644 --- a/osu.Game/IO/Archives/ZipArchiveReader.cs +++ b/osu.Game/IO/Archives/ZipArchiveReader.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using osu.Framework.IO.Stores; using SharpCompress.Archives.Zip; namespace osu.Game.IO.Archives @@ -43,8 +44,6 @@ namespace osu.Game.IO.Archives archiveStream.Dispose(); } - public override IEnumerable Filenames => archive.Entries.Select(e => e.Key).ToArray(); - - public override Stream GetUnderlyingStream() => archiveStream; + public override IEnumerable Filenames => archive.Entries.Select(e => e.Key).ExcludeSystemFileNames(); } } diff --git a/osu.Game/IO/FileStore.cs b/osu.Game/IO/FileStore.cs index 370d6786f5..bf4e881ed0 100644 --- a/osu.Game/IO/FileStore.cs +++ b/osu.Game/IO/FileStore.cs @@ -50,7 +50,16 @@ namespace osu.Game.IO string path = info.StoragePath; // we may be re-adding a file to fix missing store entries. - if (!Storage.Exists(path)) + bool requiresCopy = !Storage.Exists(path); + + if (!requiresCopy) + { + // even if the file already exists, check the existing checksum for safety. + using (var stream = Storage.GetStream(path)) + requiresCopy |= stream.ComputeSHA2Hash() != hash; + } + + if (requiresCopy) { data.Seek(0, SeekOrigin.Begin); diff --git a/osu.Game/IO/Legacy/SerializationReader.cs b/osu.Game/IO/Legacy/SerializationReader.cs index 7a84c11930..17cbd19838 100644 --- a/osu.Game/IO/Legacy/SerializationReader.cs +++ b/osu.Game/IO/Legacy/SerializationReader.cs @@ -116,13 +116,13 @@ namespace osu.Game.IO.Legacy } /// Reads a generic Dictionary from the buffer. - public IDictionary ReadDictionary() + public IDictionary ReadDictionary() { int count = ReadInt32(); if (count < 0) return null; - IDictionary d = new Dictionary(); - for (int i = 0; i < count; i++) d[(T)ReadObject()] = (U)ReadObject(); + IDictionary d = new Dictionary(); + for (int i = 0; i < count; i++) d[(TKey)ReadObject()] = (TValue)ReadObject(); return d; } @@ -192,7 +192,7 @@ namespace osu.Game.IO.Legacy } } - public class DynamicDeserializer + public static class DynamicDeserializer { private static VersionConfigToNamespaceAssemblyObjectBinder versionBinder; private static BinaryFormatter formatter; @@ -226,9 +226,7 @@ namespace osu.Game.IO.Legacy public override Type BindToType(string assemblyName, string typeName) { - Type typeToDeserialize; - - if (cache.TryGetValue(assemblyName + typeName, out typeToDeserialize)) + if (cache.TryGetValue(assemblyName + typeName, out var typeToDeserialize)) return typeToDeserialize; List tmpTypes = new List(); diff --git a/osu.Game/IO/Legacy/SerializationWriter.cs b/osu.Game/IO/Legacy/SerializationWriter.cs index f30e4492af..bb8014fe54 100644 --- a/osu.Game/IO/Legacy/SerializationWriter.cs +++ b/osu.Game/IO/Legacy/SerializationWriter.cs @@ -102,7 +102,7 @@ namespace osu.Game.IO.Legacy } /// Writes a generic IDictionary to the buffer. - public void Write(IDictionary d) + public void Write(IDictionary d) { if (d == null) { @@ -112,7 +112,7 @@ namespace osu.Game.IO.Legacy { Write(d.Count); - foreach (KeyValuePair kvp in d) + foreach (KeyValuePair kvp in d) { WriteObject(kvp.Key); WriteObject(kvp.Value); @@ -130,91 +130,91 @@ namespace osu.Game.IO.Legacy } else { - switch (obj.GetType().Name) + switch (obj) { - case "Boolean": + case bool boolObj: Write((byte)ObjType.boolType); - Write((bool)obj); + Write(boolObj); break; - case "Byte": + case byte byteObj: Write((byte)ObjType.byteType); - Write((byte)obj); + Write(byteObj); break; - case "UInt16": + case ushort ushortObj: Write((byte)ObjType.uint16Type); - Write((ushort)obj); + Write(ushortObj); break; - case "UInt32": + case uint uintObj: Write((byte)ObjType.uint32Type); - Write((uint)obj); + Write(uintObj); break; - case "UInt64": + case ulong ulongObj: Write((byte)ObjType.uint64Type); - Write((ulong)obj); + Write(ulongObj); break; - case "SByte": + case sbyte sbyteObj: Write((byte)ObjType.sbyteType); - Write((sbyte)obj); + Write(sbyteObj); break; - case "Int16": + case short shortObj: Write((byte)ObjType.int16Type); - Write((short)obj); + Write(shortObj); break; - case "Int32": + case int intObj: Write((byte)ObjType.int32Type); - Write((int)obj); + Write(intObj); break; - case "Int64": + case long longObj: Write((byte)ObjType.int64Type); - Write((long)obj); + Write(longObj); break; - case "Char": + case char charObj: Write((byte)ObjType.charType); - base.Write((char)obj); + base.Write(charObj); break; - case "String": + case string stringObj: Write((byte)ObjType.stringType); - base.Write((string)obj); + base.Write(stringObj); break; - case "Single": + case float floatObj: Write((byte)ObjType.singleType); - Write((float)obj); + Write(floatObj); break; - case "Double": + case double doubleObj: Write((byte)ObjType.doubleType); - Write((double)obj); + Write(doubleObj); break; - case "Decimal": + case decimal decimalObj: Write((byte)ObjType.decimalType); - Write((decimal)obj); + Write(decimalObj); break; - case "DateTime": + case DateTime dateTimeObj: Write((byte)ObjType.dateTimeType); - Write((DateTime)obj); + Write(dateTimeObj); break; - case "Byte[]": + case byte[] byteArray: Write((byte)ObjType.byteArrayType); - base.Write((byte[])obj); + base.Write(byteArray); break; - case "Char[]": + case char[] charArray: Write((byte)ObjType.charArrayType); - base.Write((char[])obj); + base.Write(charArray); break; default: diff --git a/osu.Game/IO/LineBufferedReader.cs b/osu.Game/IO/LineBufferedReader.cs new file mode 100644 index 0000000000..018321dc9a --- /dev/null +++ b/osu.Game/IO/LineBufferedReader.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 System.Collections.Generic; +using System.IO; +using System.Text; + +namespace osu.Game.IO +{ + /// + /// A -like decorator (with more limited API) for s + /// that allows lines to be peeked without consuming. + /// + public class LineBufferedReader : IDisposable + { + private readonly StreamReader streamReader; + private readonly Queue lineBuffer; + + public LineBufferedReader(Stream stream, bool leaveOpen = false) + { + streamReader = new StreamReader(stream, Encoding.UTF8, true, 1024, leaveOpen); + lineBuffer = new Queue(); + } + + /// + /// Reads the next line from the stream without consuming it. + /// Subsequent calls to without a will return the same string. + /// + public string PeekLine() + { + if (lineBuffer.Count > 0) + return lineBuffer.Peek(); + + var line = streamReader.ReadLine(); + if (line != null) + lineBuffer.Enqueue(line); + return line; + } + + /// + /// Reads the next line from the stream and consumes it. + /// If a line was peeked, that same line will then be consumed and returned. + /// + public string ReadLine() => lineBuffer.Count > 0 ? lineBuffer.Dequeue() : streamReader.ReadLine(); + + /// + /// Reads the stream to its end and returns the text read. + /// This includes any peeked but unconsumed lines. + /// + public string ReadToEnd() + { + var remainingText = streamReader.ReadToEnd(); + if (lineBuffer.Count == 0) + return remainingText; + + var builder = new StringBuilder(); + + // this might not be completely correct due to varying platform line endings + while (lineBuffer.Count > 0) + builder.AppendLine(lineBuffer.Dequeue()); + builder.Append(remainingText); + + return builder.ToString(); + } + + public void Dispose() + { + streamReader?.Dispose(); + } + } +} diff --git a/osu.Game/IO/MigratableStorage.cs b/osu.Game/IO/MigratableStorage.cs new file mode 100644 index 0000000000..1b76725b04 --- /dev/null +++ b/osu.Game/IO/MigratableStorage.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.IO; +using System.Linq; +using System.Threading; +using osu.Framework.Platform; + +namespace osu.Game.IO +{ + /// + /// A that is migratable to different locations. + /// + public abstract class MigratableStorage : WrappedStorage + { + /// + /// A relative list of directory paths which should not be migrated. + /// + public virtual string[] IgnoreDirectories => Array.Empty(); + + /// + /// A relative list of file paths which should not be migrated. + /// + public virtual string[] IgnoreFiles => Array.Empty(); + + protected MigratableStorage(Storage storage, string subPath = null) + : base(storage, subPath) + { + } + + /// + /// A general purpose migration method to move the storage to a different location. + /// The target storage of the migration. + /// + public virtual void Migrate(Storage newStorage) + { + var source = new DirectoryInfo(GetFullPath(".")); + var destination = new DirectoryInfo(newStorage.GetFullPath(".")); + + // using Uri is the easiest way to check equality and contains (https://stackoverflow.com/a/7710620) + var sourceUri = new Uri(source.FullName + Path.DirectorySeparatorChar); + var destinationUri = new Uri(destination.FullName + Path.DirectorySeparatorChar); + + if (sourceUri == destinationUri) + throw new ArgumentException("Destination provided is already the current location", destination.FullName); + + if (sourceUri.IsBaseOf(destinationUri)) + throw new ArgumentException("Destination provided is inside the source", destination.FullName); + + // ensure the new location has no files present, else hard abort + if (destination.Exists) + { + if (destination.GetFiles().Length > 0 || destination.GetDirectories().Length > 0) + throw new ArgumentException("Destination provided already has files or directories present", destination.FullName); + } + + CopyRecursive(source, destination); + ChangeTargetStorage(newStorage); + DeleteRecursive(source); + } + + protected void DeleteRecursive(DirectoryInfo target, bool topLevelExcludes = true) + { + foreach (System.IO.FileInfo fi in target.GetFiles()) + { + if (topLevelExcludes && IgnoreFiles.Contains(fi.Name)) + continue; + + AttemptOperation(() => fi.Delete()); + } + + foreach (DirectoryInfo dir in target.GetDirectories()) + { + if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name)) + continue; + + AttemptOperation(() => dir.Delete(true)); + } + + if (target.GetFiles().Length == 0 && target.GetDirectories().Length == 0) + AttemptOperation(target.Delete); + } + + protected void CopyRecursive(DirectoryInfo source, DirectoryInfo destination, bool topLevelExcludes = true) + { + // based off example code https://docs.microsoft.com/en-us/dotnet/api/system.io.directoryinfo + if (!destination.Exists) + Directory.CreateDirectory(destination.FullName); + + foreach (System.IO.FileInfo fi in source.GetFiles()) + { + if (topLevelExcludes && IgnoreFiles.Contains(fi.Name)) + continue; + + AttemptOperation(() => fi.CopyTo(Path.Combine(destination.FullName, fi.Name), true)); + } + + foreach (DirectoryInfo dir in source.GetDirectories()) + { + if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name)) + continue; + + CopyRecursive(dir, destination.CreateSubdirectory(dir.Name), false); + } + } + + /// + /// Attempt an IO operation multiple times and only throw if none of the attempts succeed. + /// + /// The action to perform. + /// The number of attempts (250ms wait between each). + protected static void AttemptOperation(Action action, int attempts = 10) + { + while (true) + { + try + { + action(); + return; + } + catch (Exception) + { + if (attempts-- == 0) + throw; + } + + Thread.Sleep(250); + } + } + } +} diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs new file mode 100644 index 0000000000..8097f61ea4 --- /dev/null +++ b/osu.Game/IO/OsuStorage.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 System.Diagnostics; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Configuration; + +namespace osu.Game.IO +{ + public class OsuStorage : MigratableStorage + { + /// + /// Indicates the error (if any) that occurred when initialising the custom storage during initial startup. + /// + public readonly OsuStorageError Error; + + /// + /// The custom storage path as selected by the user. + /// + [CanBeNull] + public string CustomStoragePath => storageConfig.Get(StorageConfig.FullPath); + + /// + /// The default storage path to be used if a custom storage path hasn't been selected or is not accessible. + /// + [NotNull] + public string DefaultStoragePath => defaultStorage.GetFullPath("."); + + private readonly GameHost host; + private readonly StorageConfigManager storageConfig; + private readonly Storage defaultStorage; + + public override string[] IgnoreDirectories => new[] { "cache" }; + + public override string[] IgnoreFiles => new[] + { + "framework.ini", + "storage.ini" + }; + + public OsuStorage(GameHost host, Storage defaultStorage) + : base(defaultStorage, string.Empty) + { + this.host = host; + this.defaultStorage = defaultStorage; + + storageConfig = new StorageConfigManager(defaultStorage); + + if (!string.IsNullOrEmpty(CustomStoragePath)) + TryChangeToCustomStorage(out Error); + } + + /// + /// Resets the custom storage path, changing the target storage to the default location. + /// + public void ResetCustomStoragePath() + { + storageConfig.Set(StorageConfig.FullPath, string.Empty); + storageConfig.Save(); + + ChangeTargetStorage(defaultStorage); + } + + /// + /// Attempts to change to the user's custom storage path. + /// + /// The error that occurred. + /// Whether the custom storage path was used successfully. If not, will be populated with the reason. + public bool TryChangeToCustomStorage(out OsuStorageError error) + { + Debug.Assert(!string.IsNullOrEmpty(CustomStoragePath)); + + error = OsuStorageError.None; + Storage lastStorage = UnderlyingStorage; + + try + { + Storage userStorage = host.GetStorage(CustomStoragePath); + + if (!userStorage.ExistsDirectory(".") || !userStorage.GetFiles(".").Any()) + error = OsuStorageError.AccessibleButEmpty; + + ChangeTargetStorage(userStorage); + } + catch + { + error = OsuStorageError.NotAccessible; + ChangeTargetStorage(lastStorage); + } + + return error == OsuStorageError.None; + } + + protected override void ChangeTargetStorage(Storage newStorage) + { + base.ChangeTargetStorage(newStorage); + Logger.Storage = UnderlyingStorage.GetStorageForDirectory("logs"); + } + + public override void Migrate(Storage newStorage) + { + base.Migrate(newStorage); + storageConfig.Set(StorageConfig.FullPath, newStorage.GetFullPath(".")); + storageConfig.Save(); + } + } + + public enum OsuStorageError + { + /// + /// No error. + /// + None, + + /// + /// Occurs when the target storage directory is accessible but does not already contain game files. + /// Only happens when the user changes the storage directory and then moves the files manually or mounts a different device to the same path. + /// + AccessibleButEmpty, + + /// + /// Occurs when the target storage directory cannot be accessed at all. + /// + NotAccessible, + } +} diff --git a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs index 6d244bff60..50b28ea74b 100644 --- a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs +++ b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs @@ -9,12 +9,12 @@ using Newtonsoft.Json.Linq; namespace osu.Game.IO.Serialization.Converters { /// - /// A type of that serializes a alongside + /// A type of that serializes an alongside /// a lookup table for the types contained. The lookup table is used in deserialization to /// reconstruct the objects with their original types. /// - /// The type of objects contained in the this attribute is attached to. - public class TypedListConverter : JsonConverter + /// The type of objects contained in the this attribute is attached to. + public class TypedListConverter : JsonConverter> { private readonly bool requiresTypeVersion; @@ -36,20 +36,30 @@ namespace osu.Game.IO.Serialization.Converters this.requiresTypeVersion = requiresTypeVersion; } - public override bool CanConvert(Type objectType) => objectType == typeof(List); - - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override IReadOnlyList ReadJson(JsonReader reader, Type objectType, IReadOnlyList existingValue, bool hasExistingValue, JsonSerializer serializer) { var list = new List(); var obj = JObject.Load(reader); - var lookupTable = serializer.Deserialize>(obj["lookup_table"].CreateReader()); - foreach (var tok in obj["items"]) + if (obj["$lookup_table"] == null) + return list; + + var lookupTable = serializer.Deserialize>(obj["$lookup_table"].CreateReader()); + if (lookupTable == null) + return list; + + if (obj["$items"] == null) + return list; + + foreach (var tok in obj["$items"]) { var itemReader = tok.CreateReader(); - var typeName = lookupTable[(int)tok["type"]]; + if (tok["$type"] == null) + throw new JsonException("Expected $type token."); + + var typeName = lookupTable[(int)tok["$type"]]; var instance = (T)Activator.CreateInstance(Type.GetType(typeName)); serializer.Populate(itemReader, instance); @@ -59,14 +69,12 @@ namespace osu.Game.IO.Serialization.Converters return list; } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, IReadOnlyList value, JsonSerializer serializer) { - var list = (List)value; - var lookupTable = new List(); var objects = new List(); - foreach (var item in list) + foreach (var item in value) { var type = item.GetType(); var assemblyName = type.Assembly.GetName(); @@ -84,16 +92,16 @@ namespace osu.Game.IO.Serialization.Converters } var itemObject = JObject.FromObject(item, serializer); - itemObject.AddFirst(new JProperty("type", typeId)); + itemObject.AddFirst(new JProperty("$type", typeId)); objects.Add(itemObject); } writer.WriteStartObject(); - writer.WritePropertyName("lookup_table"); + writer.WritePropertyName("$lookup_table"); serializer.Serialize(writer, lookupTable); - writer.WritePropertyName("items"); + writer.WritePropertyName("$items"); serializer.Serialize(writer, objects); writer.WriteEndObject(); diff --git a/osu.Game/IO/Serialization/Converters/Vector2Converter.cs b/osu.Game/IO/Serialization/Converters/Vector2Converter.cs index bf5edeef94..46447b607b 100644 --- a/osu.Game/IO/Serialization/Converters/Vector2Converter.cs +++ b/osu.Game/IO/Serialization/Converters/Vector2Converter.cs @@ -11,26 +11,22 @@ namespace osu.Game.IO.Serialization.Converters /// /// A type of that serializes only the X and Y coordinates of a . /// - public class Vector2Converter : JsonConverter + public class Vector2Converter : JsonConverter { - public override bool CanConvert(Type objectType) => objectType == typeof(Vector2); - - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + 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, object value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, Vector2 value, JsonSerializer serializer) { - var vector = (Vector2)value; - writer.WriteStartObject(); writer.WritePropertyName("x"); - writer.WriteValue(vector.X); + writer.WriteValue(value.X); writer.WritePropertyName("y"); - writer.WriteValue(vector.Y); + writer.WriteValue(value.Y); writer.WriteEndObject(); } diff --git a/osu.Game/IO/WrappedStorage.cs b/osu.Game/IO/WrappedStorage.cs new file mode 100644 index 0000000000..5b2549d2ee --- /dev/null +++ b/osu.Game/IO/WrappedStorage.cs @@ -0,0 +1,94 @@ +// 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 osu.Framework.Platform; + +namespace osu.Game.IO +{ + /// + /// A storage which wraps another storage and delegates implementation, potentially mutating the lookup path. + /// + public class WrappedStorage : Storage + { + protected Storage UnderlyingStorage { get; private set; } + + private readonly string subPath; + + public WrappedStorage(Storage underlyingStorage, string subPath = null) + : base(string.Empty) + { + ChangeTargetStorage(underlyingStorage); + + this.subPath = subPath; + } + + protected virtual string MutatePath(string path) + { + if (path == null) + return null; + + return !string.IsNullOrEmpty(subPath) ? Path.Combine(subPath, path) : path; + } + + protected virtual void ChangeTargetStorage(Storage newStorage) + { + UnderlyingStorage = newStorage; + } + + public override string GetFullPath(string path, bool createIfNotExisting = false) => + UnderlyingStorage.GetFullPath(MutatePath(path), createIfNotExisting); + + public override bool Exists(string path) => + UnderlyingStorage.Exists(MutatePath(path)); + + public override bool ExistsDirectory(string path) => + UnderlyingStorage.ExistsDirectory(MutatePath(path)); + + public override void DeleteDirectory(string path) => + UnderlyingStorage.DeleteDirectory(MutatePath(path)); + + public override void Delete(string path) => + UnderlyingStorage.Delete(MutatePath(path)); + + public override IEnumerable GetDirectories(string path) => + ToLocalRelative(UnderlyingStorage.GetDirectories(MutatePath(path))); + + public IEnumerable ToLocalRelative(IEnumerable paths) + { + string localRoot = GetFullPath(string.Empty); + + foreach (var path in paths) + yield return Path.GetRelativePath(localRoot, UnderlyingStorage.GetFullPath(path)); + } + + public override IEnumerable GetFiles(string path, string pattern = "*") => + ToLocalRelative(UnderlyingStorage.GetFiles(MutatePath(path), pattern)); + + public override Stream GetStream(string path, FileAccess access = FileAccess.Read, FileMode mode = FileMode.OpenOrCreate) => + UnderlyingStorage.GetStream(MutatePath(path), access, mode); + + public override string GetDatabaseConnectionString(string name) => + UnderlyingStorage.GetDatabaseConnectionString(MutatePath(name)); + + public override void DeleteDatabase(string name) => UnderlyingStorage.DeleteDatabase(MutatePath(name)); + + public override void OpenPathInNativeExplorer(string path) => UnderlyingStorage.OpenPathInNativeExplorer(MutatePath(path)); + + public override Storage GetStorageForDirectory(string path) + { + if (string.IsNullOrEmpty(path)) + throw new ArgumentException("Must be non-null and not empty string", nameof(path)); + + if (!path.EndsWith(Path.DirectorySeparatorChar)) + path += Path.DirectorySeparatorChar; + + // create non-existing path. + GetFullPath(path, true); + + return new WrappedStorage(this, path); + } + } +} diff --git a/osu.Game/IPC/ArchiveImportIPCChannel.cs b/osu.Game/IPC/ArchiveImportIPCChannel.cs index 484db932f8..029908ec9d 100644 --- a/osu.Game/IPC/ArchiveImportIPCChannel.cs +++ b/osu.Game/IPC/ArchiveImportIPCChannel.cs @@ -32,7 +32,7 @@ namespace osu.Game.IPC { if (importer == null) { - //we want to contact a remote osu! to handle the import. + // we want to contact a remote osu! to handle the import. await SendMessageAsync(new ArchiveImportMessage { Path = path }); return; } diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index f34b8f14b0..94edc33099 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -30,9 +30,10 @@ namespace osu.Game.Input.Bindings /// /// A reference to identify the current . Used to lookup mappings. Null for global mappings. /// An optional variant for the specified . Used when a ruleset has more than one possible keyboard layouts. - /// Specify how to deal with multiple matches of s and s. - public DatabasedKeyBindingContainer(RulesetInfo ruleset = null, int? variant = null, SimultaneousBindingMode simultaneousMode = SimultaneousBindingMode.None) - : base(simultaneousMode) + /// Specify how to deal with multiple matches of s and s. + /// Specify how to deal with exact matches. + public DatabasedKeyBindingContainer(RulesetInfo ruleset = null, int? variant = null, SimultaneousBindingMode simultaneousMode = SimultaneousBindingMode.None, KeyCombinationMatchingMode matchingMode = KeyCombinationMatchingMode.Any) + : base(simultaneousMode, matchingMode) { this.ruleset = ruleset; this.variant = variant; @@ -61,6 +62,14 @@ namespace osu.Game.Input.Bindings store.KeyBindingChanged -= ReloadMappings; } - protected override void ReloadMappings() => KeyBindings = store.Query(ruleset?.ID, variant).ToList(); + protected override void ReloadMappings() + { + 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; + else + KeyBindings = store.Query(ruleset?.ID, variant).ToList(); + } } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 669fd62e45..3de4bb1f9d 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -10,20 +10,22 @@ using osu.Framework.Input.Bindings; namespace osu.Game.Input.Bindings { - public class GlobalActionContainer : DatabasedKeyBindingContainer, IHandleGlobalInput + public class GlobalActionContainer : DatabasedKeyBindingContainer, IHandleGlobalKeyboardInput { private readonly Drawable handler; public GlobalActionContainer(OsuGameBase game) + : base(matchingMode: KeyCombinationMatchingMode.Modifiers) { if (game is IKeyBindingHandler) handler = game; } - public override IEnumerable DefaultKeyBindings => GlobalKeyBindings.Concat(InGameKeyBindings); + public override IEnumerable DefaultKeyBindings => GlobalKeyBindings.Concat(InGameKeyBindings).Concat(AudioControlKeyBindings).Concat(EditorKeyBindings); public IEnumerable GlobalKeyBindings => new[] { + new KeyBinding(InputKey.F6, GlobalAction.ToggleNowPlaying), new KeyBinding(InputKey.F8, GlobalAction.ToggleChat), new KeyBinding(InputKey.F9, GlobalAction.ToggleSocial), new KeyBinding(InputKey.F10, GlobalAction.ToggleGameplayMouseButtons), @@ -32,27 +34,57 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.R }, GlobalAction.ResetInputSettings), new KeyBinding(new[] { InputKey.Control, InputKey.T }, GlobalAction.ToggleToolbar), new KeyBinding(new[] { InputKey.Control, InputKey.O }, GlobalAction.ToggleSettings), - new KeyBinding(InputKey.Up, GlobalAction.IncreaseVolume), - new KeyBinding(InputKey.MouseWheelUp, GlobalAction.IncreaseVolume), - new KeyBinding(InputKey.Down, GlobalAction.DecreaseVolume), - new KeyBinding(InputKey.MouseWheelDown, GlobalAction.DecreaseVolume), - new KeyBinding(InputKey.F4, GlobalAction.ToggleMute), + new KeyBinding(new[] { InputKey.Control, InputKey.D }, GlobalAction.ToggleDirect), + new KeyBinding(new[] { InputKey.Control, InputKey.N }, GlobalAction.ToggleNotifications), new KeyBinding(InputKey.Escape, GlobalAction.Back), new KeyBinding(InputKey.ExtraMouseButton1, GlobalAction.Back), + new KeyBinding(new[] { InputKey.Alt, InputKey.Home }, GlobalAction.Home), + + new KeyBinding(InputKey.Up, GlobalAction.SelectPrevious), + new KeyBinding(InputKey.Down, GlobalAction.SelectNext), + new KeyBinding(InputKey.Space, GlobalAction.Select), new KeyBinding(InputKey.Enter, GlobalAction.Select), new KeyBinding(InputKey.KeypadEnter, GlobalAction.Select), }; + public IEnumerable EditorKeyBindings => new[] + { + new KeyBinding(new[] { InputKey.F1 }, GlobalAction.EditorComposeMode), + new KeyBinding(new[] { InputKey.F2 }, GlobalAction.EditorDesignMode), + new KeyBinding(new[] { InputKey.F3 }, GlobalAction.EditorTimingMode), + new KeyBinding(new[] { InputKey.F4 }, GlobalAction.EditorSetupMode), + }; + public IEnumerable InGameKeyBindings => new[] { new KeyBinding(InputKey.Space, GlobalAction.SkipCutscene), + new KeyBinding(InputKey.ExtraMouseButton2, GlobalAction.SkipCutscene), new KeyBinding(InputKey.Tilde, GlobalAction.QuickRetry), new KeyBinding(new[] { InputKey.Control, InputKey.Tilde }, GlobalAction.QuickExit), new KeyBinding(new[] { InputKey.Control, InputKey.Plus }, GlobalAction.IncreaseScrollSpeed), new KeyBinding(new[] { InputKey.Control, InputKey.Minus }, GlobalAction.DecreaseScrollSpeed), + new KeyBinding(InputKey.MouseMiddle, GlobalAction.PauseGameplay), + new KeyBinding(InputKey.Control, GlobalAction.HoldForHUD), + }; + + 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), + + new KeyBinding(InputKey.TrackPrevious, GlobalAction.MusicPrev), + new KeyBinding(InputKey.F1, GlobalAction.MusicPrev), + new KeyBinding(InputKey.TrackNext, GlobalAction.MusicNext), + new KeyBinding(InputKey.F5, GlobalAction.MusicNext), + new KeyBinding(InputKey.PlayPause, GlobalAction.MusicPlay), + new KeyBinding(InputKey.F3, GlobalAction.MusicPlay) }; protected override IEnumerable KeyBindingInputQueue => @@ -115,5 +147,49 @@ namespace osu.Game.Input.Bindings [Description("Quick exit (Hold)")] QuickExit, + + // Game-wide beatmap music controller keybindings + [Description("Next track")] + MusicNext, + + [Description("Previous track")] + MusicPrev, + + [Description("Play / pause")] + MusicPlay, + + [Description("Toggle now playing overlay")] + ToggleNowPlaying, + + [Description("Previous Selection")] + SelectPrevious, + + [Description("Next Selection")] + SelectNext, + + [Description("Home")] + Home, + + [Description("Toggle notifications")] + ToggleNotifications, + + [Description("Pause")] + PauseGameplay, + + // Editor + [Description("Setup Mode")] + EditorSetupMode, + + [Description("Compose Mode")] + EditorComposeMode, + + [Description("Design Mode")] + EditorDesignMode, + + [Description("Timing Mode")] + EditorTimingMode, + + [Description("Hold for HUD")] + HoldForHUD, } } diff --git a/osu.Game/Input/ConfineMouseTracker.cs b/osu.Game/Input/ConfineMouseTracker.cs new file mode 100644 index 0000000000..3dadae6317 --- /dev/null +++ b/osu.Game/Input/ConfineMouseTracker.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.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Graphics; +using osu.Framework.Input; +using osu.Game.Configuration; + +namespace osu.Game.Input +{ + /// + /// Connects with . + /// If is true, we should also confine the mouse cursor if it has been + /// requested with . + /// + public class ConfineMouseTracker : Component + { + private Bindable frameworkConfineMode; + private Bindable osuConfineMode; + private IBindable localUserPlaying; + + [BackgroundDependencyLoader] + private void load(OsuGame game, FrameworkConfigManager frameworkConfigManager, OsuConfigManager osuConfigManager) + { + frameworkConfineMode = frameworkConfigManager.GetBindable(FrameworkSetting.ConfineMouseMode); + osuConfineMode = osuConfigManager.GetBindable(OsuSetting.ConfineMouseMode); + localUserPlaying = game.LocalUserPlaying.GetBoundCopy(); + + osuConfineMode.ValueChanged += _ => updateConfineMode(); + localUserPlaying.BindValueChanged(_ => updateConfineMode(), true); + } + + private void updateConfineMode() + { + // confine mode is unavailable on some platforms + if (frameworkConfineMode.Disabled) + return; + + switch (osuConfineMode.Value) + { + case OsuConfineMouseMode.Never: + frameworkConfineMode.Value = ConfineMouseMode.Never; + break; + + case OsuConfineMouseMode.Fullscreen: + frameworkConfineMode.Value = ConfineMouseMode.Fullscreen; + break; + + case OsuConfineMouseMode.DuringGameplay: + frameworkConfineMode.Value = localUserPlaying.Value ? ConfineMouseMode.Always : ConfineMouseMode.Never; + break; + + case OsuConfineMouseMode.Always: + frameworkConfineMode.Value = ConfineMouseMode.Always; + break; + } + } + } +} diff --git a/osu.Game/Input/GameIdleTracker.cs b/osu.Game/Input/GameIdleTracker.cs new file mode 100644 index 0000000000..260be7e5c9 --- /dev/null +++ b/osu.Game/Input/GameIdleTracker.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.Framework.Input; + +namespace osu.Game.Input +{ + public class GameIdleTracker : IdleTracker + { + private InputManager inputManager; + + public GameIdleTracker(int time) + : base(time) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + inputManager = GetContainingInputManager(); + } + + protected override bool AllowIdle => inputManager.FocusedDrawable == null; + } +} diff --git a/osu.Game/Input/IdleTracker.cs b/osu.Game/Input/IdleTracker.cs index cbc446a126..63a6348b57 100644 --- a/osu.Game/Input/IdleTracker.cs +++ b/osu.Game/Input/IdleTracker.cs @@ -12,7 +12,7 @@ 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, IHandleGlobalInput + public class IdleTracker : Component, IKeyBindingHandler, IHandleGlobalKeyboardInput { private readonly double timeToIdle; @@ -50,7 +50,7 @@ namespace osu.Game.Input public bool OnPressed(PlatformAction action) => updateLastInteractionTime(); - public bool OnReleased(PlatformAction action) => updateLastInteractionTime(); + public void OnReleased(PlatformAction action) => updateLastInteractionTime(); protected override bool Handle(UIEvent e) { diff --git a/osu.Game/Input/KeyBindingStore.cs b/osu.Game/Input/KeyBindingStore.cs index caddb1ae0d..198ab6883d 100644 --- a/osu.Game/Input/KeyBindingStore.cs +++ b/osu.Game/Input/KeyBindingStore.cs @@ -46,6 +46,7 @@ namespace osu.Game.Input continue; foreach (var insertable in group.Skip(count).Take(aimCount - count)) + { // insert any defaults which are missing. usage.Context.DatabasedKeyBinding.Add(new DatabasedKeyBinding { @@ -54,6 +55,10 @@ namespace osu.Game.Input RulesetID = rulesetId, Variant = variant }); + + // required to ensure stable insert order (https://github.com/dotnet/efcore/issues/11686) + usage.Context.SaveChanges(); + } } } } diff --git a/osu.Game/Input/OsuConfineMouseMode.cs b/osu.Game/Input/OsuConfineMouseMode.cs new file mode 100644 index 0000000000..32b456395c --- /dev/null +++ b/osu.Game/Input/OsuConfineMouseMode.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.ComponentModel; +using osu.Framework.Input; + +namespace osu.Game.Input +{ + /// + /// Determines the situations in which the mouse cursor should be confined to the window. + /// Expands upon by providing the option to confine during gameplay. + /// + public enum OsuConfineMouseMode + { + /// + /// The mouse cursor will be free to move outside the game window. + /// + Never, + + /// + /// The mouse cursor will be locked to the window bounds while in fullscreen mode. + /// + Fullscreen, + + /// + /// The mouse cursor will be locked to the window bounds during gameplay, + /// but may otherwise move freely. + /// + [Description("During Gameplay")] + DuringGameplay, + + /// + /// The mouse cursor will always be locked to the window bounds while the game has focus. + /// + Always + } +} diff --git a/osu.Game/Migrations/20190913104727_AddBeatmapVideo.Designer.cs b/osu.Game/Migrations/20190913104727_AddBeatmapVideo.Designer.cs new file mode 100644 index 0000000000..826233a2b0 --- /dev/null +++ b/osu.Game/Migrations/20190913104727_AddBeatmapVideo.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("20190913104727_AddBeatmapVideo")] + partial class AddBeatmapVideo + { + 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/20190913104727_AddBeatmapVideo.cs b/osu.Game/Migrations/20190913104727_AddBeatmapVideo.cs new file mode 100644 index 0000000000..9ed0943acd --- /dev/null +++ b/osu.Game/Migrations/20190913104727_AddBeatmapVideo.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace osu.Game.Migrations +{ + public partial class AddBeatmapVideo : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "VideoFile", + table: "BeatmapMetadata", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "VideoFile", + table: "BeatmapMetadata"); + } + } +} diff --git a/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.Designer.cs new file mode 100644 index 0000000000..22316b0380 --- /dev/null +++ b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.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("20200302094919_RefreshVolumeBindings")] + partial class RefreshVolumeBindings + { + 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/20200302094919_RefreshVolumeBindings.cs b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.cs new file mode 100644 index 0000000000..ec4475971c --- /dev/null +++ b/osu.Game/Migrations/20200302094919_RefreshVolumeBindings.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace osu.Game.Migrations +{ + public partial class RefreshVolumeBindings : 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/20201019224408_AddEpilepsyWarning.Designer.cs b/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.Designer.cs new file mode 100644 index 0000000000..1c05de832e --- /dev/null +++ b/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.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("20201019224408_AddEpilepsyWarning")] + partial class AddEpilepsyWarning + { + 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.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/20201019224408_AddEpilepsyWarning.cs b/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.cs new file mode 100644 index 0000000000..be6968aa5d --- /dev/null +++ b/osu.Game/Migrations/20201019224408_AddEpilepsyWarning.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace osu.Game.Migrations +{ + public partial class AddEpilepsyWarning : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EpilepsyWarning", + table: "BeatmapInfo", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EpilepsyWarning", + table: "BeatmapInfo"); + } + } +} diff --git a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs index 761dca2801..ec4461ca56 100644 --- a/osu.Game/Migrations/OsuDbContextModelSnapshot.cs +++ b/osu.Game/Migrations/OsuDbContextModelSnapshot.cs @@ -14,7 +14,7 @@ namespace osu.Game.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "2.2.4-servicing-10062"); + .HasAnnotation("ProductVersion", "2.2.6-servicing-10079"); modelBuilder.Entity("osu.Game.Beatmaps.BeatmapDifficulty", b => { @@ -43,7 +43,7 @@ namespace osu.Game.Migrations b.Property("ID") .ValueGeneratedOnAdd(); - b.Property("AudioLeadIn"); + b.Property("AudioLeadIn"); b.Property("BPM"); @@ -57,6 +57,8 @@ namespace osu.Game.Migrations b.Property("DistanceSpacing"); + b.Property("EpilepsyWarning"); + b.Property("GridSize"); b.Property("Hash"); @@ -139,6 +141,8 @@ namespace osu.Game.Migrations b.Property("TitleUnicode"); + b.Property("VideoFile"); + b.HasKey("ID"); b.ToTable("BeatmapMetadata"); diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index d722c7a98a..b916339a53 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -7,8 +7,11 @@ using System.Diagnostics; using System.Net; using System.Net.Http; using System.Threading; +using System.Threading.Tasks; using Newtonsoft.Json.Linq; using osu.Framework.Bindables; +using osu.Framework.Extensions.ExceptionExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Configuration; @@ -20,6 +23,7 @@ namespace osu.Game.Online.API public class APIAccess : Component, IAPIProvider { private readonly OsuConfigManager config; + private readonly OAuth authentication; public string Endpoint => @"https://osu.ppy.sh"; @@ -74,26 +78,8 @@ namespace osu.Game.Online.API private void onTokenChanged(ValueChangedEvent e) => config.Set(OsuSetting.Token, config.Get(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty); - private readonly List components = new List(); - internal new void Schedule(Action action) => base.Schedule(action); - /// - /// Register a component to receive API events. - /// Fires once immediately to ensure a correct state. - /// - /// - public void Register(IOnlineComponent component) - { - Schedule(() => components.Add(component)); - component.APIStateChanged(this, state); - } - - public void Unregister(IOnlineComponent component) - { - Schedule(() => components.Remove(component)); - } - public string AccessToken => authentication.RequestAccessToken(); /// @@ -105,7 +91,7 @@ namespace osu.Game.Online.API { while (!cancellationToken.IsCancellationRequested) { - switch (State) + switch (State.Value) { case APIState.Failing: //todo: replace this with a ping request. @@ -124,15 +110,15 @@ namespace osu.Game.Online.API case APIState.Offline: case APIState.Connecting: - //work to restore a connection... + // work to restore a connection... if (!HasLogin) { - State = APIState.Offline; + state.Value = APIState.Offline; Thread.Sleep(50); continue; } - State = APIState.Connecting; + 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); @@ -151,29 +137,33 @@ namespace osu.Game.Online.API userReq.Success += u => { LocalUser.Value = u; + + // todo: save/pull from settings + LocalUser.Value.Status.Value = new UserStatusOnline(); + failureCount = 0; //we're connected! - State = APIState.Online; + state.Value = APIState.Online; }; if (!handleRequest(userReq)) { - if (State == APIState.Connecting) - State = APIState.Failing; + if (State.Value == APIState.Connecting) + state.Value = APIState.Failing; continue; } // The Success callback event is fired on the main thread, so we should wait for that to run before proceeding. // Without this, we will end up circulating this Connecting loop multiple times and queueing up many web requests // before actually going online. - while (State > APIState.Offline && State < APIState.Online) + while (State.Value > APIState.Offline && State.Value < APIState.Online) Thread.Sleep(500); break; } - //hard bail if we can't get a valid access token. + // hard bail if we can't get a valid access token. if (authentication.RequestAccessToken() == null) { Logout(); @@ -198,9 +188,25 @@ namespace osu.Game.Online.API } } + public void Perform(APIRequest request) + { + try + { + request.Perform(this); + } + catch (Exception e) + { + // todo: fix exception handling + request.Fail(e); + } + } + + public Task PerformAsync(APIRequest request) => + Task.Factory.StartNew(() => Perform(request), TaskCreationOptions.LongRunning); + public void Login(string username, string password) { - Debug.Assert(State == APIState.Offline); + Debug.Assert(State.Value == APIState.Offline); ProvidedUsername = username; this.password = password; @@ -208,7 +214,7 @@ namespace osu.Game.Online.API public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { - Debug.Assert(State == APIState.Offline); + Debug.Assert(State.Value == APIState.Offline); var req = new RegistrationRequest { @@ -227,12 +233,12 @@ namespace osu.Game.Online.API { try { - return JObject.Parse(req.ResponseString).SelectToken("form_error", true).ToObject(); + return JObject.Parse(req.GetResponseString()).SelectToken("form_error", true).AsNonNull().ToObject(); } catch { // if we couldn't deserialize the error message let's throw the original exception outwards. - throw e; + e.Rethrow(); } } @@ -251,8 +257,8 @@ namespace osu.Game.Online.API { req.Perform(this); - //we could still be in initialisation, at which point we don't want to say we're Online yet. - if (IsLoggedIn) State = APIState.Online; + // we could still be in initialisation, at which point we don't want to say we're Online yet. + if (IsLoggedIn) state.Value = APIState.Online; failureCount = 0; return true; @@ -269,27 +275,12 @@ namespace osu.Game.Online.API } } - private APIState state; + private readonly Bindable state = new Bindable(); - public APIState State - { - get => state; - private set - { - if (state == value) - return; - - APIState oldState = state; - state = value; - - log.Add($@"We just went {state}!"); - Schedule(() => - { - components.ForEach(c => c.APIStateChanged(this, state)); - OnStateChange?.Invoke(oldState, state); - }); - } - } + /// + /// The current connectivity state of the API. + /// + public IBindable State => state; private bool handleWebException(WebException we) { @@ -316,12 +307,12 @@ namespace osu.Game.Online.API log.Add($@"API failure count is now {failureCount}"); if (failureCount < 3) - //we might try again at an api level. + // we might try again at an api level. return false; - if (State == APIState.Online) + if (State.Value == APIState.Online) { - State = APIState.Failing; + state.Value = APIState.Failing; flushQueue(); } @@ -338,10 +329,6 @@ namespace osu.Game.Online.API lock (queue) queue.Enqueue(request); } - public event StateChangeDelegate OnStateChange; - - public delegate void StateChangeDelegate(APIState oldState, APIState newState); - private void flushQueue(bool failOldRequests = true) { lock (queue) @@ -368,7 +355,7 @@ namespace osu.Game.Online.API // Scheduled prior to state change such that the state changed event is invoked with the correct user present Schedule(() => LocalUser.Value = createGuestUser()); - State = APIState.Offline; + state.Value = APIState.Offline; } private static User createGuestUser() => new GuestUser(); diff --git a/osu.Game/Online/API/APIMod.cs b/osu.Game/Online/API/APIMod.cs new file mode 100644 index 0000000000..780e5daa16 --- /dev/null +++ b/osu.Game/Online/API/APIMod.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 System; +using System.Collections.Generic; +using System.Linq; +using Humanizer; +using Newtonsoft.Json; +using osu.Framework.Bindables; +using osu.Game.Configuration; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Online.API +{ + public class APIMod : IMod + { + [JsonProperty("acronym")] + public string Acronym { get; set; } + + [JsonProperty("settings")] + public Dictionary Settings { get; set; } = new Dictionary(); + + [JsonConstructor] + private APIMod() + { + } + + public APIMod(Mod mod) + { + Acronym = mod.Acronym; + + foreach (var (_, property) in mod.GetSettingsSourceProperties()) + Settings.Add(property.Name.Underscore(), property.GetValue(mod)); + } + + public Mod ToMod(Ruleset ruleset) + { + Mod resultMod = ruleset.GetAllMods().FirstOrDefault(m => m.Acronym == Acronym); + + if (resultMod == null) + throw new InvalidOperationException($"There is no mod in the ruleset ({ruleset.ShortName}) matching the acronym {Acronym}."); + + foreach (var (_, property) in resultMod.GetSettingsSourceProperties()) + { + if (!Settings.TryGetValue(property.Name.Underscore(), out object settingValue)) + continue; + + ((IBindable)property.GetValue(resultMod)).Parse(settingValue); + } + + return resultMod; + } + + public bool Equals(IMod other) => Acronym == other?.Acronym; + + public override string ToString() + { + if (Settings.Count > 0) + return $"{Acronym} ({string.Join(',', Settings.Select(kvp => $"{kvp.Key}:{kvp.Value}"))})"; + + return $"{Acronym}"; + } + } +} diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index e8eff5a3a9..6912d9b629 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -2,8 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using Newtonsoft.Json; using osu.Framework.IO.Network; using osu.Framework.Logging; +using osu.Game.Users; namespace osu.Game.Online.API { @@ -11,24 +13,39 @@ namespace osu.Game.Online.API /// An API request with a well-defined response type. /// /// Type of the response (used for deserialisation). - public abstract class APIRequest : APIRequest + public abstract class APIRequest : APIRequest where T : class { - protected override WebRequest CreateWebRequest() => new JsonWebRequest(Uri); + protected override WebRequest CreateWebRequest() => new OsuJsonWebRequest(Uri); - public T Result => ((JsonWebRequest)WebRequest).ResponseObject; - - protected APIRequest() - { - base.Success += onSuccess; - } - - private void onSuccess() => Success?.Invoke(Result); + public T Result { get; private set; } /// /// Invoked on successful completion of an API request. /// This will be scheduled to the API's internal scheduler (run on update thread automatically). /// public new event APISuccessHandler Success; + + protected override void PostProcess() + { + base.PostProcess(); + Result = ((OsuJsonWebRequest)WebRequest)?.ResponseObject; + } + + internal void TriggerSuccess(T result) + { + if (Result != null) + throw new InvalidOperationException("Attempted to trigger success more than once"); + + Result = result; + + TriggerSuccess(); + } + + internal override void TriggerSuccess() + { + base.TriggerSuccess(); + Success?.Invoke(Result); + } } /// @@ -38,13 +55,18 @@ namespace osu.Game.Online.API { protected abstract string Target { get; } - protected virtual WebRequest CreateWebRequest() => new WebRequest(Uri); + protected virtual WebRequest CreateWebRequest() => new OsuWebRequest(Uri); protected virtual string Uri => $@"{API.Endpoint}/api/v2/{Target}"; protected APIAccess API; protected WebRequest WebRequest; + /// + /// The currently logged in user. Note that this will only be populated during . + /// + protected User User { get; private set; } + /// /// Invoked on successful completion of an API request. /// This will be scheduled to the API's internal scheduler (run on update thread automatically). @@ -64,9 +86,13 @@ namespace osu.Game.Online.API public void Perform(IAPIProvider api) { if (!(api is APIAccess apiAccess)) - throw new NotSupportedException($"A {nameof(APIAccess)} is required to perform requests."); + { + Fail(new NotSupportedException($"A {nameof(APIAccess)} is required to perform requests.")); + return; + } API = apiAccess; + User = apiAccess.LocalUser.Value; if (checkAndScheduleFailure()) return; @@ -79,7 +105,7 @@ namespace osu.Game.Online.API if (checkAndScheduleFailure()) return; - if (!WebRequest.Aborted) //could have been aborted by a Cancel() call + if (!WebRequest.Aborted) // could have been aborted by a Cancel() call { Logger.Log($@"Performing request {this}", LoggingTarget.Network); WebRequest.Perform(); @@ -88,14 +114,33 @@ namespace osu.Game.Online.API if (checkAndScheduleFailure()) return; + PostProcess(); + API.Schedule(delegate { if (cancelled) return; - Success?.Invoke(); + TriggerSuccess(); }); } + /// + /// Perform any post-processing actions after a successful request. + /// + protected virtual void PostProcess() + { + } + + internal virtual void TriggerSuccess() + { + Success?.Invoke(); + } + + internal void TriggerFailure(Exception e) + { + Failure?.Invoke(e); + } + public void Cancel() => Fail(new OperationCanceledException(@"Request cancelled")); public void Fail(Exception e) @@ -109,8 +154,24 @@ namespace osu.Game.Online.API cancelled = true; WebRequest?.Abort(); + string responseString = WebRequest?.GetResponseString(); + + if (!string.IsNullOrEmpty(responseString)) + { + try + { + // attempt to decode a displayable error string. + var error = JsonConvert.DeserializeObject(responseString); + if (error != null) + e = new APIException(error.ErrorMessage, e); + } + catch + { + } + } + Logger.Log($@"Failing request {this} ({e})", LoggingTarget.Network); - pendingFailure = () => Failure?.Invoke(e); + pendingFailure = () => TriggerFailure(e); checkAndScheduleFailure(); } @@ -126,6 +187,20 @@ namespace osu.Game.Online.API pendingFailure = null; return true; } + + private class DisplayableError + { + [JsonProperty("error")] + public string ErrorMessage { get; set; } + } + } + + public class APIException : InvalidOperationException + { + public APIException(string messsage, Exception innerException) + : base(messsage, innerException) + { + } } public delegate void APIFailureHandler(Exception e); diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 6c04c77dc0..e275676cea 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.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 System; using System.Threading; +using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Users; @@ -19,29 +20,25 @@ namespace osu.Game.Online.API public Bindable Activity { get; } = new Bindable(); - public bool IsLoggedIn => true; + public string AccessToken => "token"; + + public bool IsLoggedIn => State.Value == APIState.Online; public string ProvidedUsername => LocalUser.Value.Username; public string Endpoint => "http://localhost"; - private APIState state = APIState.Online; + /// + /// Provide handling logic for an arbitrary API request. + /// + public Action HandleRequest; - private readonly List components = new List(); + private readonly Bindable state = new Bindable(APIState.Online); - public APIState State - { - get => state; - private set - { - if (state == value) - return; - - state = value; - - Scheduler.Add(() => components.ForEach(c => c.APIStateChanged(this, value))); - } - } + /// + /// The current connectivity state of the API. + /// + public IBindable State => state; public DummyAPIAccess() { @@ -54,17 +51,15 @@ namespace osu.Game.Online.API public virtual void Queue(APIRequest request) { + HandleRequest?.Invoke(request); } - public void Register(IOnlineComponent component) - { - Scheduler.Add(delegate { components.Add(component); }); - component.APIStateChanged(this, state); - } + public void Perform(APIRequest request) => HandleRequest?.Invoke(request); - public void Unregister(IOnlineComponent component) + public Task PerformAsync(APIRequest request) { - Scheduler.Add(delegate { components.Remove(component); }); + HandleRequest?.Invoke(request); + return Task.CompletedTask; } public void Login(string username, string password) @@ -75,13 +70,13 @@ namespace osu.Game.Online.API Id = 1001, }; - State = APIState.Online; + state.Value = APIState.Online; } public void Logout() { LocalUser.Value = new GuestUser(); - State = APIState.Offline; + state.Value = APIState.Offline; } public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) @@ -89,5 +84,7 @@ namespace osu.Game.Online.API Thread.Sleep(200); return null; } + + public void SetState(APIState newState) => state.Value = newState; } } diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 0cd41aee26..cadc806f4f 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.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.Threading.Tasks; using osu.Framework.Bindables; using osu.Game.Users; @@ -10,14 +11,21 @@ namespace osu.Game.Online.API { /// /// The local user. + /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. /// Bindable LocalUser { get; } /// /// The current user's activity. + /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. /// Bindable Activity { get; } + /// + /// Retrieve the OAuth access token. + /// + string AccessToken { get; } + /// /// Returns whether the local user is logged in. /// @@ -34,7 +42,11 @@ namespace osu.Game.Online.API /// string Endpoint { get; } - APIState State { get; } + /// + /// The current connection state of the API. + /// This is not thread-safe and should be scheduled locally if consumed from a drawable component. + /// + IBindable State { get; } /// /// Queue a new request. @@ -43,16 +55,22 @@ namespace osu.Game.Online.API void Queue(APIRequest request); /// - /// Register a component to receive state changes. + /// Perform a request immediately, bypassing any API state checks. /// - /// The component to register. - void Register(IOnlineComponent component); + /// + /// Can be used to run requests as a guest user. + /// + /// The request to perform. + void Perform(APIRequest request); /// - /// Unregisters a component to receive state changes. + /// Perform a request immediately, bypassing any API state checks. /// - /// The component to unregister. - void Unregister(IOnlineComponent component); + /// + /// Can be used to run requests as a guest user. + /// + /// The request to perform. + Task PerformAsync(APIRequest request); /// /// Attempt to login using the provided credentials. This is a non-blocking operation. diff --git a/osu.Game/Online/API/IOnlineComponent.cs b/osu.Game/Online/API/IOnlineComponent.cs deleted file mode 100644 index da6b784759..0000000000 --- a/osu.Game/Online/API/IOnlineComponent.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Game.Online.API -{ - public interface IOnlineComponent - { - void APIStateChanged(IAPIProvider api, APIState state); - } -} diff --git a/osu.Game/Online/API/OAuth.cs b/osu.Game/Online/API/OAuth.cs index baf494ebf9..bdc47aab8d 100644 --- a/osu.Game/Online/API/OAuth.cs +++ b/osu.Game/Online/API/OAuth.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.Net.Http; using osu.Framework.Bindables; -using osu.Framework.IO.Network; namespace osu.Game.Online.API { @@ -166,7 +165,7 @@ namespace osu.Game.Online.API } } - private class AccessTokenRequest : JsonWebRequest + private class AccessTokenRequest : OsuJsonWebRequest { protected string GrantType; diff --git a/osu.Game/Online/API/OsuJsonWebRequest.cs b/osu.Game/Online/API/OsuJsonWebRequest.cs new file mode 100644 index 0000000000..4a45a8b261 --- /dev/null +++ b/osu.Game/Online/API/OsuJsonWebRequest.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.Framework.IO.Network; + +namespace osu.Game.Online.API +{ + public class OsuJsonWebRequest : JsonWebRequest + { + public OsuJsonWebRequest(string uri) + : base(uri) + { + } + + public OsuJsonWebRequest() + { + } + + protected override string UserAgent => "osu!"; + } +} diff --git a/osu.Game/Online/API/OsuWebRequest.cs b/osu.Game/Online/API/OsuWebRequest.cs new file mode 100644 index 0000000000..1d27899473 --- /dev/null +++ b/osu.Game/Online/API/OsuWebRequest.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.Framework.IO.Network; + +namespace osu.Game.Online.API +{ + public class OsuWebRequest : WebRequest + { + public OsuWebRequest(string uri) + : base(uri) + { + } + + public OsuWebRequest() + { + } + + protected override string UserAgent => "osu!"; + } +} diff --git a/osu.Game/Online/API/RegistrationRequest.cs b/osu.Game/Online/API/RegistrationRequest.cs index 349cd4de0c..f650e5c93b 100644 --- a/osu.Game/Online/API/RegistrationRequest.cs +++ b/osu.Game/Online/API/RegistrationRequest.cs @@ -2,11 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using Newtonsoft.Json; -using osu.Framework.IO.Network; namespace osu.Game.Online.API { - public class RegistrationRequest : WebRequest + public class RegistrationRequest : OsuWebRequest { internal string Username; internal string Email; diff --git a/osu.Game/Online/API/Requests/CommentVoteRequest.cs b/osu.Game/Online/API/Requests/CommentVoteRequest.cs new file mode 100644 index 0000000000..06a3b1126e --- /dev/null +++ b/osu.Game/Online/API/Requests/CommentVoteRequest.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 osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; +using System.Net.Http; + +namespace osu.Game.Online.API.Requests +{ + public class CommentVoteRequest : APIRequest + { + private readonly long id; + private readonly CommentVoteAction action; + + public CommentVoteRequest(long id, CommentVoteAction action) + { + this.id = id; + this.action = action; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = action == CommentVoteAction.Vote ? HttpMethod.Post : HttpMethod.Delete; + return req; + } + + protected override string Target => $@"comments/{id}/vote"; + } + + public enum CommentVoteAction + { + Vote, + UnVote + } +} diff --git a/osu.Game/Online/API/Requests/CreateChannelRequest.cs b/osu.Game/Online/API/Requests/CreateChannelRequest.cs new file mode 100644 index 0000000000..42cb201969 --- /dev/null +++ b/osu.Game/Online/API/Requests/CreateChannelRequest.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.Linq; +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.API.Requests +{ + public class CreateChannelRequest : APIRequest + { + private readonly Channel channel; + + public CreateChannelRequest(Channel channel) + { + this.channel = channel; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + + req.AddParameter("type", $"{ChannelType.PM}"); + req.AddParameter("target_id", $"{channel.Users.First().Id}"); + + return req; + } + + protected override string Target => @"chat/channels"; + } +} diff --git a/osu.Game/Online/API/Requests/Cursor.cs b/osu.Game/Online/API/Requests/Cursor.cs new file mode 100644 index 0000000000..3de8db770c --- /dev/null +++ b/osu.Game/Online/API/Requests/Cursor.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 System.Collections.Generic; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.API.Requests +{ + /// + /// A collection of parameters which should be passed to the search endpoint to fetch the next page. + /// + public class Cursor + { + [UsedImplicitly] + [JsonExtensionData] + public IDictionary Properties { get; set; } = new Dictionary(); + } +} diff --git a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs index b37a6804fe..87925b94c6 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs @@ -10,13 +10,11 @@ namespace osu.Game.Online.API.Requests { private readonly BeatmapInfo beatmap; - private string lookupString => beatmap.OnlineBeatmapID > 0 ? beatmap.OnlineBeatmapID.ToString() : $@"lookup?checksum={beatmap.MD5Hash}&filename={System.Uri.EscapeUriString(beatmap.Path)}"; - public GetBeatmapRequest(BeatmapInfo beatmap) { this.beatmap = beatmap; } - protected override string Target => $@"beatmaps/{lookupString}"; + protected override string Target => $@"beatmaps/lookup?id={beatmap.OnlineBeatmapID}&checksum={beatmap.MD5Hash}&filename={System.Uri.EscapeUriString(beatmap.Path ?? string.Empty)}"; } } diff --git a/osu.Game/Online/API/Requests/GetCommentsRequest.cs b/osu.Game/Online/API/Requests/GetCommentsRequest.cs new file mode 100644 index 0000000000..24dae4adf1 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetCommentsRequest.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 osu.Framework.IO.Network; +using Humanizer; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Comments; + +namespace osu.Game.Online.API.Requests +{ + public class GetCommentsRequest : APIRequest + { + private readonly long commentableId; + private readonly CommentableType type; + private readonly CommentsSortCriteria sort; + private readonly int page; + private readonly long? parentId; + + public GetCommentsRequest(long commentableId, CommentableType type, CommentsSortCriteria sort = CommentsSortCriteria.New, int page = 1, long? parentId = null) + { + this.commentableId = commentableId; + this.type = type; + this.sort = sort; + this.page = page; + this.parentId = parentId; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.AddParameter("commentable_id", commentableId.ToString()); + req.AddParameter("commentable_type", type.ToString().Underscore().ToLowerInvariant()); + req.AddParameter("page", page.ToString()); + req.AddParameter("sort", sort.ToString().ToLowerInvariant()); + + if (parentId != null) + req.AddParameter("parent_id", parentId.ToString()); + + return req; + } + + protected override string Target => "comments"; + } + + public enum CommentableType + { + Build, + Beatmapset, + NewsPost + } +} diff --git a/osu.Game/Online/API/Requests/GetCountriesResponse.cs b/osu.Game/Online/API/Requests/GetCountriesResponse.cs new file mode 100644 index 0000000000..6624344b44 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetCountriesResponse.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Users; + +namespace osu.Game.Online.API.Requests +{ + public class GetCountriesResponse : ResponseWithCursor + { + [JsonProperty("ranking")] + public List Countries; + } +} diff --git a/osu.Game/Online/API/Requests/GetCountryRankingsRequest.cs b/osu.Game/Online/API/Requests/GetCountryRankingsRequest.cs new file mode 100644 index 0000000000..d8a1198627 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetCountryRankingsRequest.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.Game.Rulesets; + +namespace osu.Game.Online.API.Requests +{ + public class GetCountryRankingsRequest : GetRankingsRequest + { + public GetCountryRankingsRequest(RulesetInfo ruleset, int page = 1) + : base(ruleset, page) + { + } + + protected override string TargetPostfix() => "country"; + } +} diff --git a/osu.Game/Online/API/Requests/GetNewsRequest.cs b/osu.Game/Online/API/Requests/GetNewsRequest.cs new file mode 100644 index 0000000000..36d9dc0652 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetNewsRequest.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.IO.Network; +using osu.Game.Extensions; + +namespace osu.Game.Online.API.Requests +{ + public class GetNewsRequest : APIRequest + { + private readonly Cursor cursor; + + public GetNewsRequest(Cursor cursor = null) + { + this.cursor = cursor; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.AddCursor(cursor); + return req; + } + + protected override string Target => "news"; + } +} diff --git a/osu.Game/Online/API/Requests/GetNewsResponse.cs b/osu.Game/Online/API/Requests/GetNewsResponse.cs new file mode 100644 index 0000000000..835289a51d --- /dev/null +++ b/osu.Game/Online/API/Requests/GetNewsResponse.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetNewsResponse : ResponseWithCursor + { + [JsonProperty("news_posts")] + public IEnumerable NewsPosts; + } +} diff --git a/osu.Game/Online/API/Requests/GetRankingsRequest.cs b/osu.Game/Online/API/Requests/GetRankingsRequest.cs new file mode 100644 index 0000000000..ddc3298ca7 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetRankingsRequest.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.Framework.IO.Network; +using osu.Game.Rulesets; + +namespace osu.Game.Online.API.Requests +{ + public abstract class GetRankingsRequest : APIRequest where TModel : class + { + private readonly RulesetInfo ruleset; + private readonly int page; + + protected GetRankingsRequest(RulesetInfo ruleset, int page = 1) + { + this.ruleset = ruleset; + this.page = page; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.AddParameter("page", page.ToString()); + + return req; + } + + protected override string Target => $"rankings/{ruleset.ShortName}/{TargetPostfix()}"; + + protected abstract string TargetPostfix(); + } +} diff --git a/osu.Game/Online/API/Requests/GetRoomsRequest.cs b/osu.Game/Online/API/Requests/GetRoomsRequest.cs deleted file mode 100644 index 8f1497ef33..0000000000 --- a/osu.Game/Online/API/Requests/GetRoomsRequest.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 osu.Game.Online.Multiplayer; -using osu.Game.Screens.Multi.Lounge.Components; - -namespace osu.Game.Online.API.Requests -{ - public class GetRoomsRequest : APIRequest> - { - private readonly PrimaryFilter primaryFilter; - - public GetRoomsRequest(PrimaryFilter primaryFilter) - { - this.primaryFilter = primaryFilter; - } - - protected override string Target - { - get - { - string target = "rooms"; - - switch (primaryFilter) - { - case PrimaryFilter.Open: - break; - - case PrimaryFilter.Owned: - target += "/owned"; - break; - - case PrimaryFilter.Participated: - target += "/participated"; - break; - - case PrimaryFilter.RecentlyEnded: - target += "/ended"; - break; - } - - return target; - } - } - } -} diff --git a/osu.Game/Online/API/Requests/GetScoresRequest.cs b/osu.Game/Online/API/Requests/GetScoresRequest.cs index 50844fa256..bf3441d2a0 100644 --- a/osu.Game/Online/API/Requests/GetScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetScoresRequest.cs @@ -9,6 +9,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Mods; using System.Text; using System.Collections.Generic; +using System.Diagnostics; namespace osu.Game.Online.API.Requests { @@ -37,10 +38,12 @@ namespace osu.Game.Online.API.Requests private void onSuccess(APILegacyScores r) { + Debug.Assert(ruleset.ID != null, "ruleset.ID != null"); + foreach (APILegacyScoreInfo score in r.Scores) { score.Beatmap = beatmap; - score.Ruleset = ruleset; + score.OnlineRulesetID = ruleset.ID.Value; } var userScore = r.UserScore; @@ -48,7 +51,7 @@ namespace osu.Game.Online.API.Requests if (userScore != null) { userScore.Score.Beatmap = beatmap; - userScore.Score.Ruleset = ruleset; + userScore.Score.OnlineRulesetID = ruleset.ID.Value; } } diff --git a/osu.Game/Online/API/Requests/GetSeasonalBackgroundsRequest.cs b/osu.Game/Online/API/Requests/GetSeasonalBackgroundsRequest.cs new file mode 100644 index 0000000000..941b47244a --- /dev/null +++ b/osu.Game/Online/API/Requests/GetSeasonalBackgroundsRequest.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.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetSeasonalBackgroundsRequest : APIRequest + { + protected override string Target => @"seasonal-backgrounds"; + } +} diff --git a/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs b/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs new file mode 100644 index 0000000000..25e6b3f1af --- /dev/null +++ b/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.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.IO.Network; +using osu.Game.Overlays.Rankings; +using osu.Game.Rulesets; + +namespace osu.Game.Online.API.Requests +{ + public class GetSpotlightRankingsRequest : GetRankingsRequest + { + private readonly int spotlight; + private readonly RankingsSortCriteria sort; + + public GetSpotlightRankingsRequest(RulesetInfo ruleset, int spotlight, RankingsSortCriteria sort) + : base(ruleset, 1) + { + this.spotlight = spotlight; + this.sort = sort; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.AddParameter("spotlight", spotlight.ToString()); + req.AddParameter("filter", sort.ToString().ToLower()); + + return req; + } + + protected override string TargetPostfix() => "charts"; + } +} diff --git a/osu.Game/Online/API/Requests/GetSpotlightRankingsResponse.cs b/osu.Game/Online/API/Requests/GetSpotlightRankingsResponse.cs new file mode 100644 index 0000000000..2259314a9f --- /dev/null +++ b/osu.Game/Online/API/Requests/GetSpotlightRankingsResponse.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 System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users; + +namespace osu.Game.Online.API.Requests +{ + public class GetSpotlightRankingsResponse + { + [JsonProperty("ranking")] + public List Users; + + [JsonProperty("spotlight")] + public APISpotlight Spotlight; + + [JsonProperty("beatmapsets")] + public List BeatmapSets; + } +} diff --git a/osu.Game/Online/API/Requests/GetSpotlightsRequest.cs b/osu.Game/Online/API/Requests/GetSpotlightsRequest.cs new file mode 100644 index 0000000000..6fafb3933c --- /dev/null +++ b/osu.Game/Online/API/Requests/GetSpotlightsRequest.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 System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetSpotlightsRequest : APIRequest + { + protected override string Target => "spotlights"; + } + + public class SpotlightsCollection + { + [JsonProperty("spotlights")] + public List Spotlights; + } +} diff --git a/osu.Game/Online/API/Requests/GetTopUsersRequest.cs b/osu.Game/Online/API/Requests/GetTopUsersRequest.cs new file mode 100644 index 0000000000..dbbd2119db --- /dev/null +++ b/osu.Game/Online/API/Requests/GetTopUsersRequest.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.Online.API.Requests +{ + public class GetTopUsersRequest : APIRequest + { + protected override string Target => @"rankings/osu/performance"; + } +} diff --git a/osu.Game/Online/API/Requests/GetTopUsersResponse.cs b/osu.Game/Online/API/Requests/GetTopUsersResponse.cs new file mode 100644 index 0000000000..b37b8b3499 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetTopUsersResponse.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Users; + +namespace osu.Game.Online.API.Requests +{ + public class GetTopUsersResponse : ResponseWithCursor + { + [JsonProperty("ranking")] + public List Users; + } +} diff --git a/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs b/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs index f3384163b8..fb1385793f 100644 --- a/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs @@ -27,6 +27,7 @@ namespace osu.Game.Online.API.Requests { Favourite, RankedAndApproved, + Loved, Unranked, Graveyard } diff --git a/osu.Game/Online/API/Requests/GetUserKudosuHistoryRequest.cs b/osu.Game/Online/API/Requests/GetUserKudosuHistoryRequest.cs new file mode 100644 index 0000000000..e90e297672 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetUserKudosuHistoryRequest.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.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetUserKudosuHistoryRequest : PaginatedAPIRequest> + { + private readonly long userId; + + public GetUserKudosuHistoryRequest(long userId, int page = 0, int itemsPerPage = 5) + : base(page, itemsPerPage) + { + this.userId = userId; + } + + protected override string Target => $"users/{userId}/kudosu"; + } +} diff --git a/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs b/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs new file mode 100644 index 0000000000..bccc3bc0c3 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetUserRankingsRequest.cs @@ -0,0 +1,40 @@ +// 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.Rulesets; + +namespace osu.Game.Online.API.Requests +{ + public class GetUserRankingsRequest : GetRankingsRequest + { + public readonly UserRankingsType Type; + + private readonly string country; + + public GetUserRankingsRequest(RulesetInfo ruleset, UserRankingsType type = UserRankingsType.Performance, int page = 1, string country = null) + : base(ruleset, page) + { + Type = type; + this.country = country; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + if (country != null) + req.AddParameter("country", country); + + return req; + } + + protected override string TargetPostfix() => Type.ToString().ToLowerInvariant(); + } + + public enum UserRankingsType + { + Performance, + Score + } +} diff --git a/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs b/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs index 4908e5ecc2..123624d333 100644 --- a/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRecentActivitiesRequest.cs @@ -42,5 +42,6 @@ namespace osu.Game.Online.API.Requests Ranked, Approved, Qualified, + Loved } } diff --git a/osu.Game/Online/API/Requests/GetUserRequest.cs b/osu.Game/Online/API/Requests/GetUserRequest.cs index 37ed0574f1..31b7e95b39 100644 --- a/osu.Game/Online/API/Requests/GetUserRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRequest.cs @@ -2,18 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Users; +using osu.Game.Rulesets; namespace osu.Game.Online.API.Requests { public class GetUserRequest : APIRequest { private readonly long? userId; + private readonly RulesetInfo ruleset; - public GetUserRequest(long? userId = null) + public GetUserRequest(long? userId = null, RulesetInfo ruleset = null) { this.userId = userId; + this.ruleset = ruleset; } - protected override string Target => userId.HasValue ? $@"users/{userId}" : @"me"; + protected override string Target => userId.HasValue ? $@"users/{userId}/{ruleset?.ShortName}" : $@"me/{ruleset?.ShortName}"; } } diff --git a/osu.Game/Online/API/Requests/GetUserScoresRequest.cs b/osu.Game/Online/API/Requests/GetUserScoresRequest.cs index d41966fe1b..7b4d66e7b2 100644 --- a/osu.Game/Online/API/Requests/GetUserScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserScoresRequest.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.IO.Network; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; namespace osu.Game.Online.API.Requests { @@ -10,12 +12,24 @@ namespace osu.Game.Online.API.Requests { private readonly long userId; private readonly ScoreType type; + private readonly RulesetInfo ruleset; - public GetUserScoresRequest(long userId, ScoreType type, int page = 0, int itemsPerPage = 5) + public GetUserScoresRequest(long userId, ScoreType type, int page = 0, int itemsPerPage = 5, RulesetInfo ruleset = null) : base(page, itemsPerPage) { this.userId = userId; this.type = type; + this.ruleset = ruleset; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + if (ruleset != null) + req.AddParameter("mode", ruleset.ShortName); + + return req; } protected override string Target => $@"users/{userId}/scores/{type.ToString().ToLowerInvariant()}"; diff --git a/osu.Game/Online/API/Requests/GetUsersRequest.cs b/osu.Game/Online/API/Requests/GetUsersRequest.cs index 55df88b7e5..969d7fdba3 100644 --- a/osu.Game/Online/API/Requests/GetUsersRequest.cs +++ b/osu.Game/Online/API/Requests/GetUsersRequest.cs @@ -1,13 +1,24 @@ -// 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.Collections.Generic; -using osu.Game.Online.API.Requests.Responses; +using System; namespace osu.Game.Online.API.Requests { - public class GetUsersRequest : APIRequest> + public class GetUsersRequest : APIRequest { - protected override string Target => @"rankings/osu/performance"; + private readonly int[] userIds; + + private const int max_ids_per_request = 50; + + public GetUsersRequest(int[] userIds) + { + if (userIds.Length > max_ids_per_request) + throw new ArgumentException($"{nameof(GetUsersRequest)} calls only support up to {max_ids_per_request} IDs at once"); + + this.userIds = userIds; + } + + protected override string Target => "users/?ids[]=" + string.Join("&ids[]=", userIds); } } diff --git a/osu.Game/Online/API/Requests/GetUsersResponse.cs b/osu.Game/Online/API/Requests/GetUsersResponse.cs new file mode 100644 index 0000000000..6f49d5cd53 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetUsersResponse.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Users; + +namespace osu.Game.Online.API.Requests +{ + public class GetUsersResponse : ResponseWithCursor + { + [JsonProperty("users")] + public List Users; + } +} diff --git a/osu.Game/Online/API/Requests/JoinChannelRequest.cs b/osu.Game/Online/API/Requests/JoinChannelRequest.cs index f6ed5f22c9..33eab7e355 100644 --- a/osu.Game/Online/API/Requests/JoinChannelRequest.cs +++ b/osu.Game/Online/API/Requests/JoinChannelRequest.cs @@ -4,19 +4,16 @@ using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Chat; -using osu.Game.Users; namespace osu.Game.Online.API.Requests { public class JoinChannelRequest : APIRequest { private readonly Channel channel; - private readonly User user; - public JoinChannelRequest(Channel channel, User user) + public JoinChannelRequest(Channel channel) { this.channel = channel; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $@"chat/channels/{channel.Id}/users/{user.Id}"; + protected override string Target => $@"chat/channels/{channel.Id}/users/{User.Id}"; } } diff --git a/osu.Game/Online/API/Requests/LeaveChannelRequest.cs b/osu.Game/Online/API/Requests/LeaveChannelRequest.cs index f2ae3926bd..7dfc9a0aed 100644 --- a/osu.Game/Online/API/Requests/LeaveChannelRequest.cs +++ b/osu.Game/Online/API/Requests/LeaveChannelRequest.cs @@ -4,19 +4,16 @@ using System.Net.Http; using osu.Framework.IO.Network; using osu.Game.Online.Chat; -using osu.Game.Users; namespace osu.Game.Online.API.Requests { public class LeaveChannelRequest : APIRequest { private readonly Channel channel; - private readonly User user; - public LeaveChannelRequest(Channel channel, User user) + public LeaveChannelRequest(Channel channel) { this.channel = channel; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $@"chat/channels/{channel.Id}/users/{user.Id}"; + protected override string Target => $@"chat/channels/{channel.Id}/users/{User.Id}"; } } diff --git a/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs b/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs new file mode 100644 index 0000000000..95a5d0acbd --- /dev/null +++ b/osu.Game/Online/API/Requests/MarkChannelAsReadRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.API.Requests +{ + public class MarkChannelAsReadRequest : APIRequest + { + private readonly Channel channel; + private readonly Message message; + + public MarkChannelAsReadRequest(Channel channel, Message message) + { + this.channel = channel; + this.message = message; + } + + protected override string Target => $"chat/channels/{channel.Id}/mark-as-read/{message.Id}"; + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Put; + return req; + } + } +} diff --git a/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs b/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs index 52e12f04ee..bddc34a0dc 100644 --- a/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs +++ b/osu.Game/Online/API/Requests/PaginatedAPIRequest.cs @@ -6,7 +6,7 @@ using osu.Framework.IO.Network; namespace osu.Game.Online.API.Requests { - public abstract class PaginatedAPIRequest : APIRequest + public abstract class PaginatedAPIRequest : APIRequest where T : class { private readonly int page; private readonly int itemsPerPage; diff --git a/osu.Game/Online/API/Requests/PostBeatmapFavouriteRequest.cs b/osu.Game/Online/API/Requests/PostBeatmapFavouriteRequest.cs new file mode 100644 index 0000000000..f3724230cb --- /dev/null +++ b/osu.Game/Online/API/Requests/PostBeatmapFavouriteRequest.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 osu.Framework.IO.Network; +using System.Net.Http; + +namespace osu.Game.Online.API.Requests +{ + public class PostBeatmapFavouriteRequest : APIRequest + { + private readonly int id; + private readonly BeatmapFavouriteAction action; + + public PostBeatmapFavouriteRequest(int id, BeatmapFavouriteAction action) + { + this.id = id; + this.action = action; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + req.AddParameter(@"action", action.ToString().ToLowerInvariant()); + return req; + } + + protected override string Target => $@"beatmapsets/{id}/favourites"; + } + + public enum BeatmapFavouriteAction + { + Favourite, + UnFavourite + } +} diff --git a/osu.Game/Online/API/Requests/ResponseWithCursor.cs b/osu.Game/Online/API/Requests/ResponseWithCursor.cs new file mode 100644 index 0000000000..d52e999722 --- /dev/null +++ b/osu.Game/Online/API/Requests/ResponseWithCursor.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 Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests +{ + public abstract class ResponseWithCursor + { + [JsonProperty("cursor")] + public Cursor Cursor; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index f4d67a56aa..ae65ac09b2 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -61,7 +61,10 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"failtimes")] private BeatmapMetrics metrics { get; set; } - public BeatmapInfo ToBeatmap(RulesetStore rulesets) + [JsonProperty(@"max_combo")] + private int? maxCombo { get; set; } + + public virtual BeatmapInfo ToBeatmap(RulesetStore rulesets) { var set = BeatmapSet?.ToBeatmapSet(rulesets); @@ -76,6 +79,7 @@ namespace osu.Game.Online.API.Requests.Responses Status = Status, BeatmapSet = set, Metrics = metrics, + MaxCombo = maxCombo, BaseDifficulty = new BeatmapDifficulty { DrainRate = drainRate, diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs index e5bfde8f8f..6d0160fbc4 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs @@ -61,7 +61,7 @@ namespace osu.Game.Online.API.Requests.Responses private int[] ratings { get; set; } [JsonProperty(@"user_id")] - private long creatorId + private int creatorId { set => Author.Id = value; } @@ -69,6 +69,12 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"availability")] private BeatmapSetOnlineAvailability availability { get; set; } + [JsonProperty(@"genre")] + private BeatmapSetOnlineGenre genre { get; set; } + + [JsonProperty(@"language")] + private BeatmapSetOnlineLanguage language { get; set; } + [JsonProperty(@"beatmaps")] private IEnumerable beatmaps { get; set; } @@ -95,6 +101,8 @@ namespace osu.Game.Online.API.Requests.Responses LastUpdated = lastUpdated, Availability = availability, HasFavourited = hasFavourited, + Genre = genre, + Language = language }, Beatmaps = beatmaps?.Select(b => b.ToBeatmap(rulesets)).ToList(), }; diff --git a/osu.Game/Online/API/Requests/Responses/APIChangelogEntry.cs b/osu.Game/Online/API/Requests/Responses/APIChangelogEntry.cs index 140e228acd..f949ab5da5 100644 --- a/osu.Game/Online/API/Requests/Responses/APIChangelogEntry.cs +++ b/osu.Game/Online/API/Requests/Responses/APIChangelogEntry.cs @@ -24,7 +24,7 @@ namespace osu.Game.Online.API.Requests.Responses public string Url { get; set; } [JsonProperty("type")] - public string Type { get; set; } + public ChangelogEntryType Type { get; set; } [JsonProperty("category")] public string Category { get; set; } @@ -44,4 +44,10 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("github_user")] public APIChangelogUser GithubUser { get; set; } } + + public enum ChangelogEntryType + { + Add, + Fix + } } diff --git a/osu.Game/Online/API/Requests/Responses/APIChangelogUser.cs b/osu.Game/Online/API/Requests/Responses/APIChangelogUser.cs index 5891391e83..024e1ce048 100644 --- a/osu.Game/Online/API/Requests/Responses/APIChangelogUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIChangelogUser.cs @@ -20,7 +20,7 @@ namespace osu.Game.Online.API.Requests.Responses public string OsuUsername { get; set; } [JsonProperty("user_id")] - public long? UserId { get; set; } + public int? UserId { get; set; } [JsonProperty("user_url")] public string UserUrl { get; set; } diff --git a/osu.Game/Online/API/Requests/Responses/APIChatChannel.cs b/osu.Game/Online/API/Requests/Responses/APIChatChannel.cs new file mode 100644 index 0000000000..fc3b2a8e31 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APIChatChannel.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 System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.Chat; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APIChatChannel + { + [JsonProperty(@"channel_id")] + public int? ChannelID { get; set; } + + [JsonProperty(@"recent_messages")] + public List RecentMessages { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIKudosuHistory.cs b/osu.Game/Online/API/Requests/Responses/APIKudosuHistory.cs new file mode 100644 index 0000000000..d596ddc560 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APIKudosuHistory.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 Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APIKudosuHistory + { + [JsonProperty("created_at")] + public DateTimeOffset CreatedAt; + + [JsonProperty("amount")] + public int Amount; + + [JsonProperty("post")] + public ModdingPost Post; + + public class ModdingPost + { + [JsonProperty("url")] + public string Url; + + [JsonProperty("title")] + public string Title; + } + + [JsonProperty("giver")] + public KudosuGiver Giver; + + public class KudosuGiver + { + [JsonProperty("url")] + public string Url; + + [JsonProperty("username")] + public string Username; + } + + public KudosuSource Source; + + public KudosuAction Action; + + [JsonProperty("action")] + private string action + { + set + { + // incoming action may contain a prefix. if it doesn't, it's a legacy forum event. + + string[] split = value.Split('.'); + + if (split.Length > 1) + Enum.TryParse(split.First().Replace("_", ""), true, out Source); + else + Source = KudosuSource.Forum; + + Enum.TryParse(split.Last(), true, out Action); + } + } + } + + public enum KudosuSource + { + Unknown, + AllowKudosu, + Delete, + DenyKudosu, + Forum, + Recalculate, + Restore, + Vote + } + + public enum KudosuAction + { + Give, + Reset, + Revoke, + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs index 17da255873..3d3c07a5ad 100644 --- a/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs @@ -5,56 +5,107 @@ using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; using osu.Game.Scoring.Legacy; using osu.Game.Users; namespace osu.Game.Online.API.Requests.Responses { - public class APILegacyScoreInfo : LegacyScoreInfo + public class APILegacyScoreInfo { - [JsonProperty(@"score")] - private int totalScore + public ScoreInfo CreateScoreInfo(RulesetStore rulesets) { - set => TotalScore = value; + var ruleset = rulesets.GetRuleset(OnlineRulesetID); + + var mods = Mods != null ? ruleset.CreateInstance().GetAllMods().Where(mod => Mods.Contains(mod.Acronym)).ToArray() : Array.Empty(); + + var scoreInfo = new ScoreInfo + { + TotalScore = TotalScore, + MaxCombo = MaxCombo, + User = User, + Accuracy = Accuracy, + OnlineScoreID = OnlineScoreID, + Date = Date, + PP = PP, + Beatmap = Beatmap, + RulesetID = OnlineRulesetID, + Hash = Replay ? "online" : string.Empty, // todo: temporary? + Rank = Rank, + Ruleset = ruleset, + Mods = mods, + IsLegacyScore = true + }; + + if (Statistics != null) + { + foreach (var kvp in Statistics) + { + switch (kvp.Key) + { + case @"count_geki": + scoreInfo.SetCountGeki(kvp.Value); + break; + + case @"count_300": + scoreInfo.SetCount300(kvp.Value); + break; + + case @"count_katu": + scoreInfo.SetCountKatu(kvp.Value); + break; + + case @"count_100": + scoreInfo.SetCount100(kvp.Value); + break; + + case @"count_50": + scoreInfo.SetCount50(kvp.Value); + break; + + case @"count_miss": + scoreInfo.SetCountMiss(kvp.Value); + break; + } + } + } + + return scoreInfo; } + [JsonProperty(@"score")] + public int TotalScore { get; set; } + [JsonProperty(@"max_combo")] - private int maxCombo - { - set => MaxCombo = value; - } + public int MaxCombo { get; set; } [JsonProperty(@"user")] - private User user - { - set => User = value; - } + public User User { get; set; } [JsonProperty(@"id")] - private long onlineScoreID - { - set => OnlineScoreID = value; - } + public long OnlineScoreID { get; set; } [JsonProperty(@"replay")] public bool Replay { get; set; } [JsonProperty(@"created_at")] - private DateTimeOffset date - { - set => Date = value; - } + public DateTimeOffset Date { get; set; } [JsonProperty(@"beatmap")] - private BeatmapInfo beatmap - { - set => Beatmap = value; - } + public BeatmapInfo Beatmap { get; set; } + + [JsonProperty("accuracy")] + public double Accuracy { get; set; } + + [JsonProperty(@"pp")] + public double? PP { get; set; } [JsonProperty(@"beatmapset")] - private BeatmapMetadata metadata + public BeatmapMetadata Metadata { set { @@ -67,68 +118,16 @@ namespace osu.Game.Online.API.Requests.Responses } [JsonProperty(@"statistics")] - private Dictionary jsonStats - { - set - { - foreach (var kvp in value) - { - switch (kvp.Key) - { - case @"count_geki": - CountGeki = kvp.Value; - break; - - case @"count_300": - Count300 = kvp.Value; - break; - - case @"count_katu": - CountKatu = kvp.Value; - break; - - case @"count_100": - Count100 = kvp.Value; - break; - - case @"count_50": - Count50 = kvp.Value; - break; - - case @"count_miss": - CountMiss = kvp.Value; - break; - - default: - continue; - } - } - } - } + public Dictionary Statistics { get; set; } [JsonProperty(@"mode_int")] - public int OnlineRulesetID - { - get => RulesetID; - set => RulesetID = value; - } + public int OnlineRulesetID { get; set; } [JsonProperty(@"mods")] - private string[] modStrings { get; set; } + public string[] Mods { get; set; } - public override RulesetInfo Ruleset - { - get => base.Ruleset; - set - { - base.Ruleset = value; - - if (modStrings != null) - { - // Evaluate the mod string - Mods = Ruleset.CreateInstance().GetAllMods().Where(mod => modStrings.Contains(mod.Acronym)).ToArray(); - } - } - } + [JsonProperty("rank")] + [JsonConverter(typeof(StringEnumConverter))] + public ScoreRank Rank { get; set; } } } diff --git a/osu.Game/Online/API/Requests/Responses/APILegacyScores.cs b/osu.Game/Online/API/Requests/Responses/APILegacyScores.cs index 318fcb00de..009639c1dc 100644 --- a/osu.Game/Online/API/Requests/Responses/APILegacyScores.cs +++ b/osu.Game/Online/API/Requests/Responses/APILegacyScores.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using Newtonsoft.Json; +using osu.Game.Rulesets; +using osu.Game.Scoring; namespace osu.Game.Online.API.Requests.Responses { @@ -18,9 +20,16 @@ namespace osu.Game.Online.API.Requests.Responses public class APILegacyUserTopScoreInfo { [JsonProperty(@"position")] - public int Position; + public int? Position; [JsonProperty(@"score")] public APILegacyScoreInfo Score; + + public ScoreInfo CreateScoreInfo(RulesetStore rulesets) + { + var score = Score.CreateScoreInfo(rulesets); + score.Position = Position; + return score; + } } } diff --git a/osu.Game/Online/API/Requests/Responses/APINewsPost.cs b/osu.Game/Online/API/Requests/Responses/APINewsPost.cs new file mode 100644 index 0000000000..ced08f0bf2 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APINewsPost.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 Newtonsoft.Json; +using System; +using System.Net; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APINewsPost + { + [JsonProperty("id")] + public long Id { get; set; } + + private string author; + + [JsonProperty("author")] + public string Author + { + get => author; + set => author = WebUtility.HtmlDecode(value); + } + + [JsonProperty("edit_url")] + public string EditUrl { get; set; } + + [JsonProperty("first_image")] + public string FirstImage { get; set; } + + [JsonProperty("published_at")] + public DateTimeOffset PublishedAt { get; set; } + + [JsonProperty("updated_at")] + public DateTimeOffset UpdatedAt { get; set; } + + [JsonProperty("slug")] + public string Slug { get; set; } + + private string title; + + [JsonProperty("title")] + public string Title + { + get => title; + set => title = WebUtility.HtmlDecode(value); + } + + private string preview; + + [JsonProperty("preview")] + public string Preview + { + get => preview; + set => preview = WebUtility.HtmlDecode(value); + } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIRoomScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/APIRoomScoreInfo.cs deleted file mode 100644 index 33467b59b2..0000000000 --- a/osu.Game/Online/API/Requests/Responses/APIRoomScoreInfo.cs +++ /dev/null @@ -1,17 +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 Newtonsoft.Json; -using osu.Game.Scoring; - -namespace osu.Game.Online.API.Requests.Responses -{ - public class APIRoomScoreInfo : ScoreInfo - { - [JsonProperty("attempts")] - public int TotalAttempts { get; set; } - - [JsonProperty("completed")] - public int CompletedBeatmaps { get; set; } - } -} diff --git a/osu.Game/Online/API/Requests/Responses/APISeasonalBackgrounds.cs b/osu.Game/Online/API/Requests/Responses/APISeasonalBackgrounds.cs new file mode 100644 index 0000000000..8e395f7397 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APISeasonalBackgrounds.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; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APISeasonalBackgrounds + { + [JsonProperty("ends_at")] + public DateTimeOffset EndDate; + + [JsonProperty("backgrounds")] + public List Backgrounds { get; set; } + } + + public class APISeasonalBackground + { + [JsonProperty("url")] + public string Url { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APISpotlight.cs b/osu.Game/Online/API/Requests/Responses/APISpotlight.cs new file mode 100644 index 0000000000..4f63ebe3df --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APISpotlight.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; +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APISpotlight + { + [JsonProperty("id")] + public int Id; + + [JsonProperty("name")] + public string Name; + + [JsonProperty("type")] + public string Type; + + [JsonProperty("mode_specific")] + public bool ModeSpecific; + + [JsonProperty(@"start_date")] + public DateTimeOffset StartDate; + + [JsonProperty(@"end_date")] + public DateTimeOffset EndDate; + + [JsonProperty(@"participant_count")] + public int? Participants; + + public override string ToString() => Name; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs b/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.cs new file mode 100644 index 0000000000..bcc8721400 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APIUserScoreAggregate.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 Newtonsoft.Json; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APIUserScoreAggregate + { + [JsonProperty("attempts")] + public int TotalAttempts { get; set; } + + [JsonProperty("completed")] + public int CompletedBeatmaps { get; set; } + + [JsonProperty("accuracy")] + public double Accuracy { get; set; } + + [JsonProperty(@"pp")] + public double? PP { get; set; } + + [JsonProperty(@"room_id")] + public int RoomID { get; set; } + + [JsonProperty("total_score")] + public long TotalScore { get; set; } + + [JsonProperty(@"user_id")] + public long UserID { get; set; } + + [JsonProperty("user")] + public User User { get; set; } + + [JsonProperty("position")] + public int? Position { get; set; } + + public ScoreInfo CreateScoreInfo() => + new ScoreInfo + { + Accuracy = Accuracy, + PP = PP, + TotalScore = TotalScore, + User = User, + Position = Position + }; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/Comment.cs b/osu.Game/Online/API/Requests/Responses/Comment.cs new file mode 100644 index 0000000000..05a24cec0e --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/Comment.cs @@ -0,0 +1,71 @@ +// 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 osu.Game.Users; +using System; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class Comment + { + [JsonProperty(@"id")] + public long Id { get; set; } + + [JsonProperty(@"parent_id")] + public long? ParentId { get; set; } + + public Comment ParentComment { get; set; } + + [JsonProperty(@"user_id")] + public long? UserId { get; set; } + + public User User { get; set; } + + [JsonProperty(@"message")] + public string Message { get; set; } + + [JsonProperty(@"message_html")] + public string MessageHtml { get; set; } + + [JsonProperty(@"replies_count")] + public int RepliesCount { get; set; } + + [JsonProperty(@"votes_count")] + public int VotesCount { get; set; } + + [JsonProperty(@"commenatble_type")] + public string CommentableType { get; set; } + + [JsonProperty(@"commentable_id")] + public int CommentableId { get; set; } + + [JsonProperty(@"legacy_name")] + public string LegacyName { get; set; } + + [JsonProperty(@"created_at")] + public DateTimeOffset CreatedAt { get; set; } + + [JsonProperty(@"updated_at")] + public DateTimeOffset? UpdatedAt { get; set; } + + [JsonProperty(@"deleted_at")] + public DateTimeOffset? DeletedAt { get; set; } + + [JsonProperty(@"edited_at")] + public DateTimeOffset? EditedAt { get; set; } + + [JsonProperty(@"edited_by_id")] + public long? EditedById { get; set; } + + public User EditedUser { get; set; } + + public bool IsTopLevel => !ParentId.HasValue; + + public bool IsDeleted => DeletedAt.HasValue; + + public bool HasMessage => !string.IsNullOrEmpty(Message); + + public bool IsVoted { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/CommentBundle.cs b/osu.Game/Online/API/Requests/Responses/CommentBundle.cs new file mode 100644 index 0000000000..d76ede67cd --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/CommentBundle.cs @@ -0,0 +1,81 @@ +// 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 osu.Game.Users; +using System.Collections.Generic; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class CommentBundle + { + [JsonProperty(@"comments")] + public List Comments { get; set; } + + [JsonProperty(@"has_more")] + public bool HasMore { get; set; } + + [JsonProperty(@"has_more_id")] + public long? HasMoreId { get; set; } + + [JsonProperty(@"user_follow")] + public bool UserFollow { get; set; } + + [JsonProperty(@"included_comments")] + public List IncludedComments { get; set; } + + private List userVotes; + + [JsonProperty(@"user_votes")] + public List UserVotes + { + get => userVotes; + set + { + userVotes = value; + + Comments.ForEach(c => c.IsVoted = value.Contains(c.Id)); + IncludedComments.ForEach(c => c.IsVoted = value.Contains(c.Id)); + } + } + + private List users; + + [JsonProperty(@"users")] + public List Users + { + get => users; + set + { + users = value; + + value.ForEach(u => + { + Comments.ForEach(c => + { + if (c.UserId == u.Id) + c.User = u; + + if (c.EditedById == u.Id) + c.EditedUser = u; + }); + + IncludedComments.ForEach(c => + { + if (c.UserId == u.Id) + c.User = u; + + if (c.EditedById == u.Id) + c.EditedUser = u; + }); + }); + } + } + + [JsonProperty(@"total")] + public int Total { get; set; } + + [JsonProperty(@"top_level_count")] + public int TopLevelCount { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs index d9d05ff285..bbaa7e745f 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs @@ -1,50 +1,103 @@ // 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 System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.IO.Network; +using osu.Game.Extensions; using osu.Game.Overlays; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing; using osu.Game.Rulesets; +using osu.Game.Scoring; namespace osu.Game.Online.API.Requests { public class SearchBeatmapSetsRequest : APIRequest { + public SearchCategory SearchCategory { get; } + + public SortCriteria SortCriteria { get; } + + public SortDirection SortDirection { get; } + + public SearchGenre Genre { get; } + + public SearchLanguage Language { get; } + + [CanBeNull] + public IReadOnlyCollection Extra { get; } + + public SearchPlayed Played { get; } + + [CanBeNull] + public IReadOnlyCollection Ranks { get; } + private readonly string query; private readonly RulesetInfo ruleset; - private readonly BeatmapSearchCategory searchCategory; - private readonly DirectSortCriteria sortCriteria; - private readonly SortDirection direction; - private string directionString => direction == SortDirection.Descending ? @"desc" : @"asc"; + private readonly Cursor cursor; - public SearchBeatmapSetsRequest(string query, RulesetInfo ruleset, BeatmapSearchCategory searchCategory = BeatmapSearchCategory.Any, DirectSortCriteria sortCriteria = DirectSortCriteria.Ranked, SortDirection direction = SortDirection.Descending) + private string directionString => SortDirection == SortDirection.Descending ? @"desc" : @"asc"; + + public SearchBeatmapSetsRequest( + string query, + RulesetInfo ruleset, + Cursor cursor = null, + SearchCategory searchCategory = SearchCategory.Any, + SortCriteria sortCriteria = SortCriteria.Ranked, + SortDirection sortDirection = SortDirection.Descending, + SearchGenre genre = SearchGenre.Any, + SearchLanguage language = SearchLanguage.Any, + IReadOnlyCollection extra = null, + IReadOnlyCollection ranks = null, + SearchPlayed played = SearchPlayed.Any) { - this.query = System.Uri.EscapeDataString(query); + this.query = string.IsNullOrEmpty(query) ? string.Empty : System.Uri.EscapeDataString(query); this.ruleset = ruleset; - this.searchCategory = searchCategory; - this.sortCriteria = sortCriteria; - this.direction = direction; + this.cursor = cursor; + + SearchCategory = searchCategory; + SortCriteria = sortCriteria; + SortDirection = sortDirection; + Genre = genre; + Language = language; + Extra = extra; + Ranks = ranks; + Played = played; } - // ReSharper disable once ImpureMethodCallOnReadonlyValueField - protected override string Target => $@"beatmapsets/search?q={query}&m={ruleset.ID ?? 0}&s={(int)searchCategory}&sort={sortCriteria.ToString().ToLowerInvariant()}_{directionString}"; - } + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.AddParameter("q", query); - public enum BeatmapSearchCategory - { - Any = 7, + if (ruleset.ID.HasValue) + req.AddParameter("m", ruleset.ID.Value.ToString()); - [Description("Ranked & Approved")] - RankedApproved = 0, - Qualified = 3, - Loved = 8, - Favourites = 2, + req.AddParameter("s", SearchCategory.ToString().ToLowerInvariant()); - [Description("Pending & WIP")] - PendingWIP = 4, - Graveyard = 5, + if (Genre != SearchGenre.Any) + req.AddParameter("g", ((int)Genre).ToString()); - [Description("My Maps")] - MyMaps = 6, + if (Language != SearchLanguage.Any) + req.AddParameter("l", ((int)Language).ToString()); + + req.AddParameter("sort", $"{SortCriteria.ToString().ToLowerInvariant()}_{directionString}"); + + if (Extra != null && Extra.Any()) + req.AddParameter("e", string.Join('.', Extra.Select(e => e.ToString().ToLowerInvariant()))); + + if (Ranks != null && Ranks.Any()) + req.AddParameter("r", string.Join('.', Ranks.Select(r => r.ToString()))); + + if (Played != SearchPlayed.Any) + req.AddParameter("played", Played.ToString().ToLowerInvariant()); + + req.AddCursor(cursor); + + return req; + } + + protected override string Target => @"beatmapsets/search"; } } diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs index b0f4fef81a..3c4fb11ed1 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs @@ -7,14 +7,12 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests { - public class SearchBeatmapSetsResponse + public class SearchBeatmapSetsResponse : ResponseWithCursor { + [JsonProperty("beatmapsets")] public IEnumerable BeatmapSets; - /// - /// A collection of parameters which should be passed to the search endpoint to fetch the next page. - /// - [JsonProperty("cursor")] - public dynamic CursorJson; + [JsonProperty("total")] + public int Total; } } diff --git a/osu.Game/Online/Chat/Channel.cs b/osu.Game/Online/Chat/Channel.cs index 9ec39c5cb1..187a3e5dfc 100644 --- a/osu.Game/Online/Chat/Channel.cs +++ b/osu.Game/Online/Chat/Channel.cs @@ -14,7 +14,7 @@ namespace osu.Game.Online.Chat { public class Channel { - public readonly int MaxHistory = 300; + public const int MAX_HISTORY = 300; /// /// Contains every joined user except the current logged in user. Currently only returned for PM channels. @@ -22,7 +22,7 @@ namespace osu.Game.Online.Chat public readonly ObservableCollection Users = new ObservableCollection(); [JsonProperty(@"users")] - private long[] userIds + private int[] userIds { set { @@ -36,6 +36,11 @@ namespace osu.Game.Online.Chat /// public readonly SortedList Messages = new SortedList(Comparer.Default); + /// + /// Contains all the messages that weren't read by the user. + /// + public IEnumerable UnreadMessages => Messages.Where(m => LastReadId < m.Id); + /// /// Contains all the messages that are still pending for submission to the server. /// @@ -56,7 +61,7 @@ namespace osu.Game.Online.Chat /// public event Action MessageRemoved; - public bool ReadOnly => false; //todo not yet used. + public bool ReadOnly => false; // todo: not yet used. public override string ToString() => Name; @@ -75,13 +80,15 @@ namespace osu.Game.Online.Chat [JsonProperty(@"last_message_id")] public long? LastMessageId; + [JsonProperty(@"last_read_id")] + public long? LastReadId; + /// - /// Signalles if the current user joined this channel or not. Defaults to false. + /// Signals if the current user joined this channel or not. Defaults to false. + /// Note that this does not guarantee a join has completed. Check Id > 0 for confirmation. /// public Bindable Joined = new Bindable(); - public const int MAX_HISTORY = 300; - [JsonConstructor] public Channel() { @@ -162,8 +169,8 @@ namespace osu.Game.Online.Chat { // never purge local echos int messageCount = Messages.Count - pendingMessages.Count; - if (messageCount > MaxHistory) - Messages.RemoveRange(0, messageCount - MaxHistory); + if (messageCount > MAX_HISTORY) + Messages.RemoveRange(0, messageCount - MAX_HISTORY); } } } diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 3af11ff20f..16f46581c5 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -18,7 +18,7 @@ namespace osu.Game.Online.Chat /// /// Manages everything channel related /// - public class ChannelManager : PollingComponent + public class ChannelManager : PollingComponent, IChannelPostTarget { /// /// The channels the player joins on startup @@ -48,7 +48,8 @@ namespace osu.Game.Online.Chat /// public IBindableList AvailableChannels => availableChannels; - private IAPIProvider api; + [Resolved] + private IAPIProvider api { get; set; } public readonly BindableBool HighPollRate = new BindableBool(); @@ -85,7 +86,7 @@ namespace osu.Game.Online.Chat return; CurrentChannel.Value = JoinedChannels.FirstOrDefault(c => c.Type == ChannelType.PM && c.Users.Count == 1 && c.Users.Any(u => u.Id == user.Id)) - ?? new Channel(user); + ?? JoinChannel(new Channel(user)); } private void currentChannelChanged(ValueChangedEvent e) @@ -107,8 +108,7 @@ namespace osu.Game.Online.Chat /// An optional target channel. If null, will be used. public void PostMessage(string text, bool isAction = false, Channel target = null) { - if (target == null) - target = CurrentChannel.Value; + target ??= CurrentChannel.Value; if (target == null) return; @@ -139,7 +139,7 @@ namespace osu.Game.Online.Chat target.AddLocalEcho(message); // if this is a PM and the first message, we need to do a special request to create the PM channel - if (target.Type == ChannelType.PM && !target.Joined.Value) + if (target.Type == ChannelType.PM && target.Id == 0) { var createNewPrivateMessageRequest = new CreateNewPrivateMessageRequest(target.Users.First(), message); @@ -191,18 +191,21 @@ namespace osu.Game.Online.Chat /// An optional target channel. If null, will be used. public void PostCommand(string text, Channel target = null) { - if (target == null) - target = CurrentChannel.Value; + target ??= CurrentChannel.Value; if (target == null) return; - var parameters = text.Split(new[] { ' ' }, 2); + var parameters = text.Split(' ', 2); string command = parameters[0]; string content = parameters.Length == 2 ? parameters[1] : string.Empty; switch (command) { + case "np": + AddInternal(new NowPlayingCommand()); + break; + case "me": if (string.IsNullOrWhiteSpace(content)) { @@ -213,8 +216,26 @@ namespace osu.Game.Online.Chat PostMessage(content, true); break; + case "join": + if (string.IsNullOrWhiteSpace(content)) + { + target.AddNewMessages(new ErrorMessage("Usage: /join [channel]")); + break; + } + + var channel = availableChannels.FirstOrDefault(c => c.Name == content || c.Name == $"#{content}"); + + if (channel == null) + { + target.AddNewMessages(new ErrorMessage($"Channel '{content}' not found.")); + break; + } + + JoinChannel(channel); + break; + case "help": - target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action]")); + target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /np")); break; default: @@ -245,7 +266,7 @@ namespace osu.Game.Online.Chat // join any channels classified as "defaults" if (joinDefaults && defaultChannels.Any(c => c.Equals(channel.Name, StringComparison.OrdinalIgnoreCase))) - JoinChannel(ch); + joinChannel(ch); } }; req.Failure += error => @@ -266,7 +287,7 @@ namespace osu.Game.Online.Chat /// The channel private void fetchInitalMessages(Channel channel) { - if (channel.Id <= 0) return; + if (channel.Id <= 0 || channel.MessagesLoaded) return; var fetchInitialMsgReq = new GetMessagesRequest(channel); fetchInitialMsgReq.Success += messages => @@ -321,9 +342,10 @@ namespace osu.Game.Online.Chat /// Joins a channel if it has not already been joined. /// /// The channel to join. - /// Whether the channel has already been joined server-side. Will skip a join request. /// The joined channel. Note that this may not match the parameter channel as it is a backed object. - public Channel JoinChannel(Channel channel, bool alreadyJoined = false) + public Channel JoinChannel(Channel channel) => joinChannel(channel, true); + + private Channel joinChannel(Channel channel, bool fetchInitialMessages = false) { if (channel == null) return null; @@ -332,31 +354,48 @@ namespace osu.Game.Online.Chat // ensure we are joined to the channel if (!channel.Joined.Value) { - if (alreadyJoined) - channel.Joined.Value = true; - else + channel.Joined.Value = true; + + switch (channel.Type) { - switch (channel.Type) - { - case ChannelType.Public: - var req = new JoinChannelRequest(channel, api.LocalUser.Value); - req.Success += () => JoinChannel(channel, true); - req.Failure += ex => LeaveChannel(channel); - api.Queue(req); - return channel; - } + case ChannelType.Multiplayer: + // join is implicit. happens when you join a multiplayer game. + // this will probably change in the future. + joinChannel(channel, fetchInitialMessages); + return channel; + + case ChannelType.PM: + var createRequest = new CreateChannelRequest(channel); + createRequest.Success += resChannel => + { + if (resChannel.ChannelID.HasValue) + { + channel.Id = resChannel.ChannelID.Value; + + handleChannelMessages(resChannel.RecentMessages); + channel.MessagesLoaded = true; // this will mark the channel as having received messages even if there were none. + } + }; + + api.Queue(createRequest); + break; + + default: + var req = new JoinChannelRequest(channel); + req.Success += () => joinChannel(channel, fetchInitialMessages); + req.Failure += ex => LeaveChannel(channel); + api.Queue(req); + return channel; } } - - if (CurrentChannel.Value == null) - CurrentChannel.Value = channel; - - if (!channel.MessagesLoaded) + else { - // let's fetch a small number of messages to bring us up-to-date with the backlog. - fetchInitalMessages(channel); + if (fetchInitialMessages) + fetchInitalMessages(channel); } + CurrentChannel.Value ??= channel; + return channel; } @@ -371,7 +410,7 @@ namespace osu.Game.Online.Chat if (channel.Joined.Value) { - api.Queue(new LeaveChannelRequest(channel, api.LocalUser.Value)); + api.Queue(new LeaveChannelRequest(channel)); channel.Joined.Value = false; } } @@ -396,7 +435,8 @@ namespace osu.Game.Online.Chat foreach (var channel in updates.Presence) { // we received this from the server so should mark the channel already joined. - JoinChannel(channel, true); + channel.Joined.Value = true; + joinChannel(channel); } //todo: handle left channels @@ -426,10 +466,26 @@ namespace osu.Game.Online.Chat return tcs.Task; } - [BackgroundDependencyLoader] - private void load(IAPIProvider api) + /// + /// Marks the as read + /// + /// The channel that will be marked as read + public void MarkChannelAsRead(Channel channel) { - this.api = api; + if (channel.LastMessageId == channel.LastReadId) + return; + + var message = channel.Messages.LastOrDefault(); + + if (message == null) + return; + + var req = new MarkChannelAsReadRequest(channel, message); + + req.Success += () => channel.LastReadId = message.Id; + req.Failure += e => Logger.Error(e, $"Failed to mark channel {channel} up to '{message}' as read"); + + api.Queue(req); } } diff --git a/osu.Game/Online/Chat/ChannelType.cs b/osu.Game/Online/Chat/ChannelType.cs index 7d2b661164..151efc4645 100644 --- a/osu.Game/Online/Chat/ChannelType.cs +++ b/osu.Game/Online/Chat/ChannelType.cs @@ -12,5 +12,6 @@ namespace osu.Game.Online.Chat Temporary, PM, Group, + System, } } diff --git a/osu.Game/Online/Chat/ErrorMessage.cs b/osu.Game/Online/Chat/ErrorMessage.cs index a8ff0e9a98..87a65fb3f1 100644 --- a/osu.Game/Online/Chat/ErrorMessage.cs +++ b/osu.Game/Online/Chat/ErrorMessage.cs @@ -8,7 +8,7 @@ namespace osu.Game.Online.Chat public ErrorMessage(string message) : base(message) { - Sender.Colour = @"ff0000"; + // todo: this should likely be styled differently in the future. } } } diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index 495f1ac0b0..8407e2ca6a 100644 --- a/osu.Game/Online/Chat/ExternalLinkOpener.cs +++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs @@ -13,15 +13,17 @@ namespace osu.Game.Online.Chat { public class ExternalLinkOpener : Component { - private GameHost host; - private DialogOverlay dialogOverlay; + [Resolved] + private GameHost host { get; set; } + + [Resolved(CanBeNull = true)] + private DialogOverlay dialogOverlay { get; set; } + private Bindable externalLinkWarning; [BackgroundDependencyLoader(true)] - private void load(GameHost host, DialogOverlay dialogOverlay, OsuConfigManager config) + private void load(OsuConfigManager config) { - this.host = host; - this.dialogOverlay = dialogOverlay; externalLinkWarning = config.GetBindable(OsuSetting.ExternalLinkWarning); } diff --git a/osu.Game/Online/Chat/IChannelPostTarget.cs b/osu.Game/Online/Chat/IChannelPostTarget.cs new file mode 100644 index 0000000000..5697e918f0 --- /dev/null +++ b/osu.Game/Online/Chat/IChannelPostTarget.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; + +namespace osu.Game.Online.Chat +{ + [Cached(typeof(IChannelPostTarget))] + public interface IChannelPostTarget + { + /// + /// Posts a message to the currently opened channel. + /// + /// The message text that is going to be posted + /// Is true if the message is an action, e.g.: user is currently eating + /// An optional target channel. If null, will be used. + void PostMessage(string text, bool isAction = false, Channel target = null); + } +} diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index 4aaffdd161..d2a117876d 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; namespace osu.Game.Online.Chat @@ -10,16 +11,16 @@ namespace osu.Game.Online.Chat public static class MessageFormatter { // [[Performance Points]] -> wiki:Performance Points (https://osu.ppy.sh/wiki/Performance_Points) - private static readonly Regex wiki_regex = new Regex(@"\[\[([^\]]+)\]\]"); + private static readonly Regex wiki_regex = new Regex(@"\[\[(?[^\]]+)\]\]"); // (test)[https://osu.ppy.sh/b/1234] -> test (https://osu.ppy.sh/b/1234) - private static readonly Regex old_link_regex = new Regex(@"\(([^\)]*)\)\[([a-z]+://[^ ]+)\]"); + private static readonly Regex old_link_regex = new Regex(@"\((?(((?<=\\)[\(\)])|[^\(\)])*(((?\()(((?<=\\)[\(\)])|[^\(\)])*)+((?\))(((?<=\\)[\(\)])|[^\(\)])*)+)*(?(open)(?!)))\)\[(?[a-z]+://[^ ]+)\]"); // [https://osu.ppy.sh/b/1234 Beatmap [Hard] (poop)] -> Beatmap [hard] (poop) (https://osu.ppy.sh/b/1234) - private static readonly Regex new_link_regex = new Regex(@"\[([a-z]+://[^ ]+) ([^\[\]]*(((?\[)[^\[\]]*)+((?\])[^\[\]]*)+)*(?(open)(?!)))\]"); + private static readonly Regex new_link_regex = new Regex(@"\[(?[a-z]+://[^ ]+) (?(((?<=\\)[\[\]])|[^\[\]])*(((?\[)(((?<=\\)[\[\]])|[^\[\]])*)+((?\])(((?<=\\)[\[\]])|[^\[\]])*)+)*(?(open)(?!)))\]"); // [test](https://osu.ppy.sh/b/1234) -> test (https://osu.ppy.sh/b/1234) aka correct markdown format - private static readonly Regex markdown_link_regex = new Regex(@"\[([^\]]*)\]\(([a-z]+://[^ ]+)\)"); + private static readonly Regex markdown_link_regex = new Regex(@"\[(?(((?<=\\)[\[\]])|[^\[\]])*(((?\[)(((?<=\\)[\[\]])|[^\[\]])*)+((?\])(((?<=\\)[\[\]])|[^\[\]])*)+)*(?(open)(?!)))\]\((?[a-z]+://[^ ]+)(\s+(?""([^""]|(?<=\\)"")*""))?\)"); // advanced, RFC-compatible regular expression that matches any possible URL, *but* allows certain invalid characters that are widely used // This is in the format (<required>, [optional]): @@ -48,7 +49,7 @@ namespace osu.Game.Online.Chat // Unicode emojis private static readonly Regex emoji_regex = new Regex(@"(\uD83D[\uDC00-\uDE4F])"); - private static void handleMatches(Regex regex, string display, string link, MessageFormatterResult result, int startIndex = 0, LinkAction? linkActionOverride = 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; @@ -58,28 +59,32 @@ namespace osu.Game.Online.Chat var displayText = string.Format(display, m.Groups[0], - m.Groups.Count > 1 ? m.Groups[1].Value : "", - m.Groups.Count > 2 ? m.Groups[2].Value : "").Trim(); + m.Groups["text"].Value, + m.Groups["url"].Value).Trim(); var linkText = string.Format(link, m.Groups[0], - m.Groups.Count > 1 ? m.Groups[1].Value : "", - m.Groups.Count > 2 ? m.Groups[2].Value : "").Trim(); + m.Groups["text"].Value, + m.Groups["url"].Value).Trim(); if (displayText.Length == 0 || linkText.Length == 0) continue; + // Remove backslash escapes in front of the characters provided in escapeChars + if (escapeChars != null) + displayText = escapeChars.Aggregate(displayText, (current, c) => current.Replace($"\\{c}", c.ToString())); + // Check for encapsulated links if (result.Links.Find(l => (l.Index <= index && l.Index + l.Length >= index + m.Length) || (index <= l.Index && index + m.Length >= l.Index + l.Length)) == null) { result.Text = result.Text.Remove(index, m.Length).Insert(index, displayText); - //since we just changed the line display text, offset any already processed links. + // since we just changed the line display text, offset any already processed links. result.Links.ForEach(l => l.Index -= l.Index > index ? m.Length - displayText.Length : 0); - var details = getLinkDetails(linkText); + var details = GetLinkDetails(linkText); result.Links.Add(new Link(linkText, index, displayText.Length, linkActionOverride ?? details.Action, details.Argument)); - //adjust the offset for processing the current matches group. + // adjust the offset for processing the current matches group. captureOffset += m.Length - displayText.Length; } } @@ -90,17 +95,23 @@ namespace osu.Game.Online.Chat foreach (Match m in regex.Matches(result.Text, startIndex)) { var index = m.Index; - var link = m.Groups["link"].Value; - var indexLength = link.Length; + var linkText = m.Groups["link"].Value; + var indexLength = linkText.Length; - var details = getLinkDetails(link); - result.Links.Add(new Link(link, index, indexLength, details.Action, details.Argument)); + var details = GetLinkDetails(linkText); + var link = new Link(linkText, index, indexLength, details.Action, details.Argument); + + // sometimes an already-processed formatted link can reduce to a simple URL, too + // (example: [mean example - https://osu.ppy.sh](https://osu.ppy.sh)) + // therefore we need to check if any of the pre-existing links contains the raw one we found + if (result.Links.All(existingLink => !existingLink.Overlaps(link))) + result.Links.Add(link); } } - private static LinkDetails getLinkDetails(string url) + public static LinkDetails GetLinkDetails(string url) { - var args = url.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + var args = url.Split('/', StringSplitOptions.RemoveEmptyEntries); args[0] = args[0].TrimEnd(':'); switch (args[0]) @@ -108,7 +119,7 @@ namespace osu.Game.Online.Chat case "http": case "https": // length > 3 since all these links need another argument to work - if (args.Length > 3 && (args[1] == "osu.ppy.sh" || args[1] == "new.ppy.sh")) + if (args.Length > 3 && args[1] == "osu.ppy.sh") { switch (args[2]) { @@ -122,6 +133,7 @@ namespace osu.Game.Online.Chat return new LinkDetails(LinkAction.OpenBeatmapSet, args[3]); case "u": + case "users": return new LinkDetails(LinkAction.OpenUserProfile, args[3]); } } @@ -182,13 +194,13 @@ namespace osu.Game.Online.Chat var result = new MessageFormatterResult(toFormat); // handle the [link display] format - handleMatches(new_link_regex, "{2}", "{1}", result, startIndex); + handleMatches(new_link_regex, "{1}", "{2}", result, startIndex, escapeChars: new[] { '[', ']' }); // handle the standard markdown []() format - handleMatches(markdown_link_regex, "{1}", "{2}", result, startIndex); + handleMatches(markdown_link_regex, "{1}", "{2}", result, startIndex, escapeChars: new[] { '[', ']' }); // handle the ()[] link format - handleMatches(old_link_regex, "{1}", "{2}", result, startIndex); + handleMatches(old_link_regex, "{1}", "{2}", result, startIndex, escapeChars: new[] { '(', ')' }); // handle wiki links handleMatches(wiki_regex, "{1}", "https://osu.ppy.sh/wiki/{1}", result, startIndex); @@ -243,17 +255,17 @@ namespace osu.Game.Online.Chat OriginalText = Text = text; } } + } - public class LinkDetails + public class LinkDetails + { + public LinkAction Action; + public string Argument; + + public LinkDetails(LinkAction action, string argument) { - public LinkAction Action; - public string Argument; - - public LinkDetails(LinkAction action, string argument) - { - Action = action; - Argument = argument; - } + Action = action; + Argument = argument; } } @@ -267,6 +279,7 @@ namespace osu.Game.Online.Chat JoinMultiplayerMatch, Spectate, OpenUserProfile, + Custom } public class Link : IComparable<Link> @@ -286,6 +299,8 @@ namespace osu.Game.Online.Chat Argument = argument; } + public bool Overlaps(Link otherLink) => Index < otherLink.Index + otherLink.Length && otherLink.Index < Index + Length; + public int CompareTo(Link otherLink) => Index > otherLink.Index ? 1 : -1; } } diff --git a/osu.Game/Online/Chat/NowPlayingCommand.cs b/osu.Game/Online/Chat/NowPlayingCommand.cs new file mode 100644 index 0000000000..c0b54812b6 --- /dev/null +++ b/osu.Game/Online/Chat/NowPlayingCommand.cs @@ -0,0 +1,55 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Users; + +namespace osu.Game.Online.Chat +{ + public class NowPlayingCommand : Component + { + [Resolved] + private IChannelPostTarget channelManager { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private Bindable<WorkingBeatmap> currentBeatmap { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + string verb; + BeatmapInfo beatmap; + + switch (api.Activity.Value) + { + case UserActivity.SoloGame solo: + verb = "playing"; + beatmap = solo.Beatmap; + break; + + case UserActivity.Editing edit: + verb = "editing"; + beatmap = edit.Beatmap; + break; + + default: + verb = "listening to"; + beatmap = currentBeatmap.Value.BeatmapInfo; + break; + } + + var beatmapString = beatmap.OnlineBeatmapID.HasValue ? $"[https://osu.ppy.sh/b/{beatmap.OnlineBeatmapID} {beatmap}]" : beatmap.ToString(); + + channelManager.PostMessage($"is {verb} {beatmapString}", true); + Expire(); + } + } +} diff --git a/osu.Game/Online/Chat/StandAloneChatDisplay.cs b/osu.Game/Online/Chat/StandAloneChatDisplay.cs index 9dab2f2aba..8b0caddbc6 100644 --- a/osu.Game/Online/Chat/StandAloneChatDisplay.cs +++ b/osu.Game/Online/Chat/StandAloneChatDisplay.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Chat; using osuTK.Graphics; @@ -21,13 +22,11 @@ namespace osu.Game.Online.Chat { public readonly Bindable<Channel> Channel = new Bindable<Channel>(); - public Action Exit; - private readonly FocusedTextBox textbox; protected ChannelManager ChannelManager; - private DrawableChannel drawableChannel; + private StandAloneDrawableChannel drawableChannel; private readonly bool postingTextbox; @@ -60,14 +59,13 @@ namespace osu.Game.Online.Chat RelativeSizeAxes = Axes.X, Height = textbox_height, PlaceholderText = "type your message", - OnCommit = postMessage, ReleaseFocusOnCommit = false, HoldFocus = true, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, }); - textbox.Exit += () => Exit?.Invoke(); + textbox.OnCommit += postMessage; } Channel.BindValueChanged(channelChanged); @@ -76,10 +74,12 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader(true)] private void load(ChannelManager manager) { - if (ChannelManager == null) - ChannelManager = manager; + ChannelManager ??= manager; } + protected virtual StandAloneDrawableChannel CreateDrawableChannel(Channel channel) => + new StandAloneDrawableChannel(channel); + private void postMessage(TextBox sender, bool newtext) { var text = textbox.Text.Trim(); @@ -95,18 +95,6 @@ namespace osu.Game.Online.Chat textbox.Text = string.Empty; } - public void Contract() - { - this.FadeIn(300); - this.MoveToY(0, 500, Easing.OutQuint); - } - - public void Expand() - { - this.FadeOut(200); - this.MoveToY(100, 500, Easing.In); - } - protected virtual ChatLine CreateMessage(Message message) => new StandAloneMessage(message); private void channelChanged(ValueChangedEvent<Channel> e) @@ -115,19 +103,21 @@ namespace osu.Game.Online.Chat if (e.NewValue == null) return; - AddInternal(drawableChannel = new StandAloneDrawableChannel(e.NewValue) - { - CreateChatLineAction = CreateMessage, - Padding = new MarginPadding { Bottom = postingTextbox ? textbox_height : 0 } - }); + drawableChannel = CreateDrawableChannel(e.NewValue); + drawableChannel.CreateChatLineAction = CreateMessage; + drawableChannel.Padding = new MarginPadding { Bottom = postingTextbox ? textbox_height : 0 }; + + AddInternal(drawableChannel); } - protected class StandAloneDrawableChannel : DrawableChannel + public class StandAloneDrawableChannel : DrawableChannel { public Func<Message, ChatLine> CreateChatLineAction; protected override ChatLine CreateChatLine(Message m) => CreateChatLineAction(m); + protected override DaySeparator CreateDaySeparator(DateTimeOffset time) => new CustomDaySeparator(time); + public StandAloneDrawableChannel(Channel channel) : base(channel) { @@ -138,6 +128,24 @@ namespace osu.Game.Online.Chat { ChatLineFlow.Padding = new MarginPadding { Horizontal = 0 }; } + + private class CustomDaySeparator : DaySeparator + { + public CustomDaySeparator(DateTimeOffset time) + : base(time) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Colour = colours.Yellow; + TextSize = 14; + LineHeight = 1; + Padding = new MarginPadding { Horizontal = 10 }; + Margin = new MarginPadding { Vertical = 5 }; + } + } } protected class StandAloneMessage : ChatLine @@ -146,6 +154,7 @@ namespace osu.Game.Online.Chat protected override float HorizontalPadding => 10; protected override float MessagePadding => 120; + protected override float TimestampPadding => 50; public StandAloneMessage(Message message) : base(message) diff --git a/osu.Game/Online/DownloadTrackingComposite.cs b/osu.Game/Online/DownloadTrackingComposite.cs index 62d6efcb6f..5d9cf612bb 100644 --- a/osu.Game/Online/DownloadTrackingComposite.cs +++ b/osu.Game/Online/DownloadTrackingComposite.cs @@ -11,7 +11,7 @@ using osu.Game.Online.API; namespace osu.Game.Online { /// <summary> - /// A component which tracks a <see cref="TModel"/> through potential download/import/deletion. + /// A component which tracks a <typeparamref name="TModel"/> through potential download/import/deletion. /// </summary> public abstract class DownloadTrackingComposite<TModel, TModelManager> : CompositeDrawable where TModel : class, IEquatable<TModel> @@ -19,25 +19,29 @@ namespace osu.Game.Online { protected readonly Bindable<TModel> Model = new Bindable<TModel>(); - private TModelManager manager; + [Resolved(CanBeNull = true)] + private TModelManager manager { get; set; } /// <summary> - /// Holds the current download state of the <see cref="TModel"/>, whether is has already been downloaded, is in progress, or is not downloaded. + /// Holds the current download state of the <typeparamref name="TModel"/>, whether is has already been downloaded, is in progress, or is not downloaded. /// </summary> protected readonly Bindable<DownloadState> State = new Bindable<DownloadState>(); - protected readonly Bindable<double> Progress = new Bindable<double>(); + protected readonly BindableNumber<double> Progress = new BindableNumber<double> { MinValue = 0, MaxValue = 1 }; protected DownloadTrackingComposite(TModel model = null) { Model.Value = model; } - [BackgroundDependencyLoader(true)] - private void load(TModelManager manager) - { - this.manager = manager; + private IBindable<WeakReference<TModel>> managedUpdated; + private IBindable<WeakReference<TModel>> managerRemoved; + private IBindable<WeakReference<ArchiveDownloadRequest<TModel>>> managerDownloadBegan; + private IBindable<WeakReference<ArchiveDownloadRequest<TModel>>> managerDownloadFailed; + [BackgroundDependencyLoader(true)] + private void load() + { Model.BindValueChanged(modelInfo => { if (modelInfo.NewValue == null) @@ -48,20 +52,38 @@ namespace osu.Game.Online attachDownload(manager.GetExistingDownload(modelInfo.NewValue)); }, true); - manager.DownloadBegan += download => - { - if (download.Model.Equals(Model.Value)) - attachDownload(download); - }; + managerDownloadBegan = manager.DownloadBegan.GetBoundCopy(); + managerDownloadBegan.BindValueChanged(downloadBegan); + managerDownloadFailed = manager.DownloadFailed.GetBoundCopy(); + managerDownloadFailed.BindValueChanged(downloadFailed); + managedUpdated = manager.ItemUpdated.GetBoundCopy(); + managedUpdated.BindValueChanged(itemUpdated); + managerRemoved = manager.ItemRemoved.GetBoundCopy(); + managerRemoved.BindValueChanged(itemRemoved); + } - manager.DownloadFailed += download => + private void downloadBegan(ValueChangedEvent<WeakReference<ArchiveDownloadRequest<TModel>>> weakRequest) + { + if (weakRequest.NewValue.TryGetTarget(out var request)) { - if (download.Model.Equals(Model.Value)) - attachDownload(null); - }; + Schedule(() => + { + if (request.Model.Equals(Model.Value)) + attachDownload(request); + }); + } + } - manager.ItemAdded += itemAdded; - manager.ItemRemoved += itemRemoved; + private void downloadFailed(ValueChangedEvent<WeakReference<ArchiveDownloadRequest<TModel>>> weakRequest) + { + if (weakRequest.NewValue.TryGetTarget(out var request)) + { + Schedule(() => + { + if (request.Model.Equals(Model.Value)) + attachDownload(null); + }); + } } private ArchiveDownloadRequest<TModel> attachedRequest; @@ -106,9 +128,17 @@ namespace osu.Game.Online private void onRequestFailure(Exception e) => Schedule(() => attachDownload(null)); - private void itemAdded(TModel s) => setDownloadStateFromManager(s, DownloadState.LocallyAvailable); + private void itemUpdated(ValueChangedEvent<WeakReference<TModel>> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var item)) + setDownloadStateFromManager(item, DownloadState.LocallyAvailable); + } - private void itemRemoved(TModel s) => setDownloadStateFromManager(s, DownloadState.NotDownloaded); + private void itemRemoved(ValueChangedEvent<WeakReference<TModel>> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var item)) + setDownloadStateFromManager(item, DownloadState.NotDownloaded); + } private void setDownloadStateFromManager(TModel s, DownloadState state) => Schedule(() => { @@ -124,12 +154,6 @@ namespace osu.Game.Online { base.Dispose(isDisposing); - if (manager != null) - { - manager.DownloadBegan -= attachDownload; - manager.ItemAdded -= itemAdded; - } - State.UnbindAll(); attachDownload(null); diff --git a/osu.Game/Online/Leaderboards/DrawableRank.cs b/osu.Game/Online/Leaderboards/DrawableRank.cs index 50cb58c6ab..4d41230799 100644 --- a/osu.Game/Online/Leaderboards/DrawableRank.cs +++ b/osu.Game/Online/Leaderboards/DrawableRank.cs @@ -28,7 +28,7 @@ namespace osu.Game.Online.Leaderboards FillMode = FillMode.Fit; FillAspectRatio = 2; - var rankColour = getRankColour(); + var rankColour = OsuColour.ForRank(rank); InternalChild = new DrawSizePreservingFillContainer { TargetDrawSize = new Vector2(64, 32), @@ -58,8 +58,8 @@ namespace osu.Game.Online.Leaderboards Spacing = new Vector2(-3, 0), Padding = new MarginPadding { Top = 5 }, Colour = getRankNameColour(), - Font = OsuFont.GetFont(Typeface.Venera, 25), - Text = getRankName(), + Font = OsuFont.Numeric.With(size: 25), + Text = GetRankName(rank), ShadowColour = Color4.Black.Opacity(0.3f), ShadowOffset = new Vector2(0, 0.08f), Shadow = true, @@ -69,36 +69,7 @@ namespace osu.Game.Online.Leaderboards }; } - private string getRankName() => rank.GetDescription().TrimEnd('+'); - - /// <summary> - /// Retrieves the grade background colour. - /// </summary> - private Color4 getRankColour() - { - switch (rank) - { - case ScoreRank.XH: - case ScoreRank.X: - return OsuColour.FromHex(@"ce1c9d"); - - case ScoreRank.SH: - case ScoreRank.S: - return OsuColour.FromHex(@"00a8b5"); - - case ScoreRank.A: - return OsuColour.FromHex(@"7cce14"); - - case ScoreRank.B: - return OsuColour.FromHex(@"e3b130"); - - case ScoreRank.C: - return OsuColour.FromHex(@"f18252"); - - default: - return OsuColour.FromHex(@"e95353"); - } - } + public static string GetRankName(ScoreRank rank) => rank.GetDescription().TrimEnd('+'); /// <summary> /// Retrieves the grade text colour. @@ -109,23 +80,23 @@ namespace osu.Game.Online.Leaderboards { case ScoreRank.XH: case ScoreRank.SH: - return ColourInfo.GradientVertical(Color4.White, OsuColour.FromHex("afdff0")); + return ColourInfo.GradientVertical(Color4.White, Color4Extensions.FromHex("afdff0")); case ScoreRank.X: case ScoreRank.S: - return ColourInfo.GradientVertical(OsuColour.FromHex(@"ffe7a8"), OsuColour.FromHex(@"ffb800")); + return ColourInfo.GradientVertical(Color4Extensions.FromHex(@"ffe7a8"), Color4Extensions.FromHex(@"ffb800")); case ScoreRank.A: - return OsuColour.FromHex(@"275227"); + return Color4Extensions.FromHex(@"275227"); case ScoreRank.B: - return OsuColour.FromHex(@"553a2b"); + return Color4Extensions.FromHex(@"553a2b"); case ScoreRank.C: - return OsuColour.FromHex(@"473625"); + return Color4Extensions.FromHex(@"473625"); default: - return OsuColour.FromHex(@"512525"); + return Color4Extensions.FromHex(@"512525"); } } } diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 98f15599fc..3a5c2e181f 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -6,38 +6,47 @@ using System.Collections.Generic; using System.Linq; using System.Threading; 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.Sprites; using osu.Framework.Threading; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; +using osu.Game.Online.Placeholders; using osuTK; using osuTK.Graphics; namespace osu.Game.Online.Leaderboards { - public abstract class Leaderboard<TScope, ScoreInfo> : Container, IOnlineComponent + public abstract class Leaderboard<TScope, TScoreInfo> : Container { private const double fade_duration = 300; private readonly OsuScrollContainer scrollContainer; private readonly Container placeholderContainer; + private readonly UserTopScoreContainer<TScoreInfo> topScoreContainer; private FillFlowContainer<LeaderboardScore> scrollFlow; - private readonly LoadingAnimation loading; + private readonly LoadingSpinner loading; private ScheduledDelegate showScoresDelegate; private CancellationTokenSource showScoresCancellationSource; private bool scoresLoadedOnce; - private IEnumerable<ScoreInfo> scores; + private readonly Container content; - public IEnumerable<ScoreInfo> Scores + protected override Container<Drawable> Content => content; + + private IEnumerable<TScoreInfo> scores; + + public IEnumerable<TScoreInfo> Scores { get => scores; set @@ -49,36 +58,54 @@ namespace osu.Game.Online.Leaderboards scrollFlow?.FadeOut(fade_duration, Easing.OutQuint).Expire(); scrollFlow = null; - loading.Hide(); - showScoresDelegate?.Cancel(); showScoresCancellationSource?.Cancel(); if (scores == null || !scores.Any()) + { + loading.Hide(); return; + } // ensure placeholder is hidden when displaying scores PlaceholderState = PlaceholderState.Successful; - var sf = CreateScoreFlow(); - sf.ChildrenEnumerable = scores.Select((s, index) => CreateDrawableScore(s, index + 1)); + var scoreFlow = CreateScoreFlow(); + scoreFlow.ChildrenEnumerable = scores.Select((s, index) => CreateDrawableScore(s, index + 1)); // schedule because we may not be loaded yet (LoadComponentAsync complains). - showScoresDelegate = Schedule(() => LoadComponentAsync(sf, _ => + showScoresDelegate = Schedule(() => LoadComponentAsync(scoreFlow, _ => { - scrollContainer.Add(scrollFlow = sf); + scrollContainer.Add(scrollFlow = scoreFlow); int i = 0; foreach (var s in scrollFlow.Children) + { using (s.BeginDelayedSequence(i++ * 50, true)) s.Show(); + } scrollContainer.ScrollTo(0f, false); + loading.Hide(); }, (showScoresCancellationSource = new CancellationTokenSource()).Token)); } } + public TScoreInfo TopScore + { + get => topScoreContainer.Score.Value; + set + { + topScoreContainer.Score.Value = value; + + if (value == null) + topScoreContainer.Hide(); + else + topScoreContainer.Show(); + } + } + protected virtual FillFlowContainer<LeaderboardScore> CreateScoreFlow() => new FillFlowContainer<LeaderboardScore> { @@ -95,7 +122,7 @@ namespace osu.Game.Online.Leaderboards get => scope; set { - if (value.Equals(scope)) + if (EqualityComparer<TScope>.Default.Equals(value, scope)) return; scope = value; @@ -116,9 +143,7 @@ namespace osu.Game.Online.Leaderboards { if (value != PlaceholderState.Successful) { - getScoresRequest?.Cancel(); - getScoresRequest = null; - Scores = null; + Reset(); } if (value == placeholderState) @@ -127,12 +152,16 @@ namespace osu.Game.Online.Leaderboards switch (placeholderState = value) { case PlaceholderState.NetworkFailure: - replacePlaceholder(new RetrievalFailurePlaceholder + replacePlaceholder(new ClickablePlaceholder(@"Couldn't fetch scores!", FontAwesome.Solid.Sync) { - OnRetry = UpdateScores, + Action = UpdateScores, }); break; + case PlaceholderState.NoneSelected: + replacePlaceholder(new MessagePlaceholder(@"Please select a beatmap!")); + break; + case PlaceholderState.Unavailable: replacePlaceholder(new MessagePlaceholder(@"Leaderboards are not available for this beatmap!")); break; @@ -142,7 +171,7 @@ namespace osu.Game.Online.Leaderboards break; case PlaceholderState.NotLoggedIn: - replacePlaceholder(new MessagePlaceholder(@"Please sign in to view online leaderboards!")); + replacePlaceholder(new LoginPlaceholder(@"Please sign in to view online leaderboards!")); break; case PlaceholderState.NotSupporter: @@ -158,14 +187,43 @@ namespace osu.Game.Online.Leaderboards protected Leaderboard() { - Children = new Drawable[] + InternalChildren = new Drawable[] { - scrollContainer = new OsuScrollContainer + new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, + Masking = true, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + scrollContainer = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + } + }, + new Drawable[] + { + content = new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Child = topScoreContainer = new UserTopScoreContainer<TScoreInfo>(CreateDrawableTopScore) + }, + }, + }, + }, }, - loading = new LoadingAnimation(), + loading = new LoadingSpinner(), placeholderContainer = new Container { RelativeSizeAxes = Axes.Both @@ -173,21 +231,25 @@ namespace osu.Game.Online.Leaderboards }; } - private IAPIProvider api; + protected virtual void Reset() + { + getScoresRequest?.Cancel(); + getScoresRequest = null; + Scores = null; + } + + [Resolved(CanBeNull = true)] + private IAPIProvider api { get; set; } private ScheduledDelegate pendingUpdateScores; - [BackgroundDependencyLoader(true)] - private void load(IAPIProvider api) - { - this.api = api; - api?.Register(this); - } + private readonly IBindable<APIState> apiState = new Bindable<APIState>(); - protected override void Dispose(bool isDisposing) + [BackgroundDependencyLoader] + private void load() { - base.Dispose(isDisposing); - api?.Unregister(this); + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); } public void RefreshScores() => UpdateScores(); @@ -196,9 +258,9 @@ namespace osu.Game.Online.Leaderboards protected abstract bool IsOnlineScope { get; } - public void APIStateChanged(IAPIProvider api, APIState state) + private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() => { - switch (state) + switch (state.NewValue) { case APIState.Online: case APIState.Offline: @@ -207,7 +269,7 @@ namespace osu.Game.Online.Leaderboards break; } - } + }); protected void UpdateScores() { @@ -250,7 +312,7 @@ namespace osu.Game.Online.Leaderboards /// </summary> /// <param name="scoresCallback">A callback which should be called when fetching is completed. Scheduling is not required.</param> /// <returns>An <see cref="APIRequest"/> responsible for the fetch operation. This will be queued and performed automatically.</returns> - protected abstract APIRequest FetchScores(Action<IEnumerable<ScoreInfo>> scoresCallback); + protected abstract APIRequest FetchScores(Action<IEnumerable<TScoreInfo>> scoresCallback); private Placeholder currentPlaceholder; @@ -306,17 +368,23 @@ namespace osu.Game.Online.Leaderboards else { if (bottomY - fadeBottom > 0 && FadeBottom) + { c.Colour = ColourInfo.GradientVertical( Color4.White.Opacity(Math.Min(1 - (topY - fadeBottom) / LeaderboardScore.HEIGHT, 1)), Color4.White.Opacity(Math.Min(1 - (bottomY - fadeBottom) / LeaderboardScore.HEIGHT, 1))); + } else if (FadeTop) + { c.Colour = ColourInfo.GradientVertical( Color4.White.Opacity(Math.Min(1 - (fadeTop - topY) / LeaderboardScore.HEIGHT, 1)), Color4.White.Opacity(Math.Min(1 - (fadeTop - bottomY) / LeaderboardScore.HEIGHT, 1))); + } } } } - protected abstract LeaderboardScore CreateDrawableScore(ScoreInfo model, int index); + protected abstract LeaderboardScore CreateDrawableScore(TScoreInfo model, int index); + + protected abstract LeaderboardScore CreateDrawableTopScore(TScoreInfo model); } } diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 008f8208eb..dcd0cb435a 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -11,32 +11,38 @@ 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.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Select; using osu.Game.Scoring; using osu.Game.Users.Drawables; using osuTK; using osuTK.Graphics; +using Humanizer; +using osu.Game.Online.API; namespace osu.Game.Online.Leaderboards { - public class LeaderboardScore : OsuClickableContainer + public class LeaderboardScore : OsuClickableContainer, IHasContextMenu { - public readonly int RankPosition; - public const float HEIGHT = 60; private const float corner_radius = 5; private const float edge_margin = 5; private const float background_alpha = 0.25f; - private const float rank_width = 30; + private const float rank_width = 35; protected Container RankContainer { get; private set; } private readonly ScoreInfo score; + private readonly int? rank; + private readonly bool allowHighlight; private Box background; private Container content; @@ -49,17 +55,24 @@ namespace osu.Game.Online.Leaderboards private List<ScoreComponentLabel> statisticsLabels; - public LeaderboardScore(ScoreInfo score, int rank) + [Resolved(CanBeNull = true)] + private DialogOverlay dialogOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private SongSelect songSelect { get; set; } + + public LeaderboardScore(ScoreInfo score, int? rank, bool allowHighlight = true) { this.score = score; - RankPosition = rank; + this.rank = rank; + this.allowHighlight = allowHighlight; RelativeSizeAxes = Axes.X; Height = HEIGHT; } [BackgroundDependencyLoader] - private void load() + private void load(IAPIProvider api, OsuColour colour, ScoreManager scoreManager) { var user = score.User; @@ -69,20 +82,10 @@ namespace osu.Game.Online.Leaderboards Children = new Drawable[] { - new Container + new RankLabel(rank) { RelativeSizeAxes = Axes.Y, Width = rank_width, - Children = new[] - { - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 22, italics: true), - Text = RankPosition.ToString(), - }, - }, }, content = new Container { @@ -100,7 +103,7 @@ namespace osu.Game.Online.Leaderboards background = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, + Colour = user.Id == api.LocalUser.Value.Id && allowHighlight ? colour.Green : Color4.Black, Alpha = background_alpha, }, }, @@ -190,8 +193,8 @@ namespace osu.Game.Online.Leaderboards scoreLabel = new GlowingSpriteText { TextColour = Color4.White, - GlowColour = OsuColour.FromHex(@"83ccfa"), - Text = score.TotalScore.ToString(@"N0"), + GlowColour = Color4Extensions.FromHex(@"83ccfa"), + Current = scoreManager.GetBindableTotalScoreString(score), Font = OsuFont.Numeric.With(size: 23), }, RankContainer = new Container @@ -215,6 +218,7 @@ namespace osu.Game.Online.Leaderboards Origin = Anchor.BottomRight, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, + Spacing = new Vector2(1), ChildrenEnumerable = score.Mods.Select(mod => new ModIcon(mod) { Scale = new Vector2(0.375f) }) }, }, @@ -266,7 +270,7 @@ namespace osu.Game.Online.Leaderboards protected virtual IEnumerable<LeaderboardScoreStatistic> GetStatistics(ScoreInfo model) => new[] { new LeaderboardScoreStatistic(FontAwesome.Solid.Link, "Max Combo", model.MaxCombo.ToString()), - new LeaderboardScoreStatistic(FontAwesome.Solid.Crosshairs, "Accuracy", string.Format(model.Accuracy % 1 == 0 ? @"{0:P0}" : @"{0:P2}", model.Accuracy)) + new LeaderboardScoreStatistic(FontAwesome.Solid.Crosshairs, "Accuracy", model.DisplayAccuracy) }; protected override bool OnHover(HoverEvent e) @@ -284,17 +288,15 @@ namespace osu.Game.Online.Leaderboards private class ScoreComponentLabel : Container, IHasTooltip { private const float icon_size = 20; - - private readonly string name; private readonly FillFlowContainer content; public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); - public string TooltipText => name; + public string TooltipText { get; } public ScoreComponentLabel(LeaderboardScoreStatistic statistic) { - name = statistic.Name; + TooltipText = statistic.Name; AutoSizeAxes = Axes.Both; Child = content = new FillFlowContainer @@ -316,7 +318,7 @@ namespace osu.Game.Online.Leaderboards Origin = Anchor.Centre, Size = new Vector2(icon_size), Rotation = 45, - Colour = OsuColour.FromHex(@"3087ac"), + Colour = Color4Extensions.FromHex(@"3087ac"), Icon = FontAwesome.Solid.Square, Shadow = true, }, @@ -325,7 +327,7 @@ namespace osu.Game.Online.Leaderboards Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(icon_size - 6), - Colour = OsuColour.FromHex(@"a4edff"), + Colour = Color4Extensions.FromHex(@"a4edff"), Icon = statistic.Icon, }, }, @@ -335,7 +337,7 @@ namespace osu.Game.Online.Leaderboards Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, TextColour = Color4.White, - GlowColour = OsuColour.FromHex(@"83ccfa"), + GlowColour = Color4Extensions.FromHex(@"83ccfa"), Text = statistic.Value, Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold), }, @@ -344,6 +346,25 @@ namespace osu.Game.Online.Leaderboards } } + private class RankLabel : Container, IHasTooltip + { + public RankLabel(int? rank) + { + if (rank >= 1000) + TooltipText = $"#{rank:N0}"; + + Child = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 20, italics: true), + Text = rank == null ? "-" : rank.Value.ToMetric(decimals: rank < 100000 ? 1 : 0), + }; + } + + public string TooltipText { get; } + } + public class LeaderboardScoreStatistic { public IconUsage Icon; @@ -357,5 +378,21 @@ namespace osu.Game.Online.Leaderboards Value = value; } } + + public MenuItem[] ContextMenuItems + { + get + { + List<MenuItem> items = new List<MenuItem>(); + + 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.ID != 0) + items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score)))); + + return items.ToArray(); + } + } } } diff --git a/osu.Game/Online/Leaderboards/PlaceholderState.cs b/osu.Game/Online/Leaderboards/PlaceholderState.cs index 930e1df484..297241fa73 100644 --- a/osu.Game/Online/Leaderboards/PlaceholderState.cs +++ b/osu.Game/Online/Leaderboards/PlaceholderState.cs @@ -9,6 +9,7 @@ namespace osu.Game.Online.Leaderboards Retrieving, NetworkFailure, Unavailable, + NoneSelected, NoScores, NotLoggedIn, NotSupporter, diff --git a/osu.Game/Online/Leaderboards/RetrievalFailurePlaceholder.cs b/osu.Game/Online/Leaderboards/RetrievalFailurePlaceholder.cs deleted file mode 100644 index 801f3f8ff0..0000000000 --- a/osu.Game/Online/Leaderboards/RetrievalFailurePlaceholder.cs +++ /dev/null @@ -1,64 +0,0 @@ -// 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. - -using System; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Game.Graphics.Containers; -using osuTK; - -namespace osu.Game.Online.Leaderboards -{ - public class RetrievalFailurePlaceholder : Placeholder - { - public Action OnRetry; - - public RetrievalFailurePlaceholder() - { - AddArbitraryDrawable(new RetryButton - { - Action = () => OnRetry?.Invoke(), - Padding = new MarginPadding { Right = 10 } - }); - - AddText(@"Couldn't retrieve scores!"); - } - - public class RetryButton : OsuHoverContainer - { - private readonly SpriteIcon icon; - - public new Action Action; - - public RetryButton() - { - AutoSizeAxes = Axes.Both; - - Child = new OsuClickableContainer - { - AutoSizeAxes = Axes.Both, - Action = () => Action?.Invoke(), - Child = icon = new SpriteIcon - { - Icon = FontAwesome.Solid.Sync, - Size = new Vector2(TEXT_SIZE), - Shadow = true, - }, - }; - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - icon.ScaleTo(0.8f, 4000, Easing.OutQuint); - return base.OnMouseDown(e); - } - - protected override bool OnMouseUp(MouseUpEvent e) - { - icon.ScaleTo(1, 1000, Easing.OutElastic); - return base.OnMouseUp(e); - } - } - } -} diff --git a/osu.Game/Online/Leaderboards/UpdateableRank.cs b/osu.Game/Online/Leaderboards/UpdateableRank.cs index d9e8957281..8f74fd84fe 100644 --- a/osu.Game/Online/Leaderboards/UpdateableRank.cs +++ b/osu.Game/Online/Leaderboards/UpdateableRank.cs @@ -7,23 +7,31 @@ using osu.Game.Scoring; namespace osu.Game.Online.Leaderboards { - public class UpdateableRank : ModelBackedDrawable<ScoreRank> + public class UpdateableRank : ModelBackedDrawable<ScoreRank?> { - public ScoreRank Rank + public ScoreRank? Rank { get => Model; set => Model = value; } - public UpdateableRank(ScoreRank rank) + public UpdateableRank(ScoreRank? rank) { Rank = rank; } - protected override Drawable CreateDrawable(ScoreRank rank) => new DrawableRank(rank) + protected override Drawable CreateDrawable(ScoreRank? rank) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; + if (rank.HasValue) + { + return new DrawableRank(rank.Value) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + return null; + } } } diff --git a/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs b/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs new file mode 100644 index 0000000000..ab4210251e --- /dev/null +++ b/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs @@ -0,0 +1,94 @@ +// 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. + +using System; +using System.Threading; +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; +using osuTK; + +namespace osu.Game.Online.Leaderboards +{ + public class UserTopScoreContainer<TScoreInfo> : VisibilityContainer + { + private const int duration = 500; + + public Bindable<TScoreInfo> Score = new Bindable<TScoreInfo>(); + + private readonly Container scoreContainer; + private readonly Func<TScoreInfo, LeaderboardScore> createScoreDelegate; + + protected override bool StartHidden => true; + + [Resolved] + private RulesetStore rulesets { get; set; } + + public UserTopScoreContainer(Func<TScoreInfo, LeaderboardScore> createScoreDelegate) + { + this.createScoreDelegate = createScoreDelegate; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Margin = new MarginPadding { Vertical = 5 }; + + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = @"your personal best".ToUpper(), + Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold), + }, + scoreContainer = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + } + } + }; + + Score.BindValueChanged(onScoreChanged); + } + + private CancellationTokenSource loadScoreCancellation; + + private void onScoreChanged(ValueChangedEvent<TScoreInfo> score) + { + var newScore = score.NewValue; + + scoreContainer.Clear(); + loadScoreCancellation?.Cancel(); + + if (newScore == null) + return; + + LoadComponentAsync(createScoreDelegate(newScore), drawableScore => + { + scoreContainer.Child = drawableScore; + drawableScore.FadeInFromZero(duration, Easing.OutQuint); + }, (loadScoreCancellation = new CancellationTokenSource()).Token); + } + + protected override void PopIn() => this.FadeIn(duration, Easing.OutQuint); + + protected override void PopOut() => this.FadeOut(duration, Easing.OutQuint); + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APICreatedRoom.cs b/osu.Game/Online/Multiplayer/APICreatedRoom.cs similarity index 78% rename from osu.Game/Online/API/Requests/Responses/APICreatedRoom.cs rename to osu.Game/Online/Multiplayer/APICreatedRoom.cs index a554101bc7..2a3bb39647 100644 --- a/osu.Game/Online/API/Requests/Responses/APICreatedRoom.cs +++ b/osu.Game/Online/Multiplayer/APICreatedRoom.cs @@ -2,9 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using Newtonsoft.Json; -using osu.Game.Online.Multiplayer; -namespace osu.Game.Online.API.Requests.Responses +namespace osu.Game.Online.Multiplayer { public class APICreatedRoom : Room { diff --git a/osu.Game/Online/Multiplayer/APILeaderboard.cs b/osu.Game/Online/Multiplayer/APILeaderboard.cs new file mode 100644 index 0000000000..65863d6e0e --- /dev/null +++ b/osu.Game/Online/Multiplayer/APILeaderboard.cs @@ -0,0 +1,18 @@ +// 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. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.Multiplayer +{ + public class APILeaderboard + { + [JsonProperty("leaderboard")] + public List<APIUserScoreAggregate> Leaderboard; + + [JsonProperty("user_score")] + public APIUserScoreAggregate UserScore; + } +} diff --git a/osu.Game/Online/Multiplayer/APIPlaylistBeatmap.cs b/osu.Game/Online/Multiplayer/APIPlaylistBeatmap.cs new file mode 100644 index 0000000000..98972ef36d --- /dev/null +++ b/osu.Game/Online/Multiplayer/APIPlaylistBeatmap.cs @@ -0,0 +1,23 @@ +// 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. + +using Newtonsoft.Json; +using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; + +namespace osu.Game.Online.Multiplayer +{ + public class APIPlaylistBeatmap : APIBeatmap + { + [JsonProperty("checksum")] + public string Checksum { get; set; } + + public override BeatmapInfo ToBeatmap(RulesetStore rulesets) + { + var b = base.ToBeatmap(rulesets); + b.MD5Hash = Checksum; + return b; + } + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIScoreToken.cs b/osu.Game/Online/Multiplayer/APIScoreToken.cs similarity index 85% rename from osu.Game/Online/API/Requests/Responses/APIScoreToken.cs rename to osu.Game/Online/Multiplayer/APIScoreToken.cs index 1d2465bedf..1f0063d94e 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoreToken.cs +++ b/osu.Game/Online/Multiplayer/APIScoreToken.cs @@ -3,7 +3,7 @@ using Newtonsoft.Json; -namespace osu.Game.Online.API.Requests.Responses +namespace osu.Game.Online.Multiplayer { public class APIScoreToken { diff --git a/osu.Game/Online/API/Requests/CreateRoomRequest.cs b/osu.Game/Online/Multiplayer/CreateRoomRequest.cs similarity index 86% rename from osu.Game/Online/API/Requests/CreateRoomRequest.cs rename to osu.Game/Online/Multiplayer/CreateRoomRequest.cs index c848c55cc6..dcb4ed51ea 100644 --- a/osu.Game/Online/API/Requests/CreateRoomRequest.cs +++ b/osu.Game/Online/Multiplayer/CreateRoomRequest.cs @@ -4,10 +4,9 @@ using System.Net.Http; using Newtonsoft.Json; using osu.Framework.IO.Network; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Multiplayer; +using osu.Game.Online.API; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { public class CreateRoomRequest : APIRequest<APICreatedRoom> { diff --git a/osu.Game/Online/API/Requests/CreateRoomScoreRequest.cs b/osu.Game/Online/Multiplayer/CreateRoomScoreRequest.cs similarity index 77% rename from osu.Game/Online/API/Requests/CreateRoomScoreRequest.cs rename to osu.Game/Online/Multiplayer/CreateRoomScoreRequest.cs index e6246b4f1f..2d99b12519 100644 --- a/osu.Game/Online/API/Requests/CreateRoomScoreRequest.cs +++ b/osu.Game/Online/Multiplayer/CreateRoomScoreRequest.cs @@ -3,25 +3,28 @@ using System.Net.Http; using osu.Framework.IO.Network; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.API; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { public class CreateRoomScoreRequest : APIRequest<APIScoreToken> { private readonly int roomId; private readonly int playlistItemId; + private readonly string versionHash; - public CreateRoomScoreRequest(int roomId, int playlistItemId) + public CreateRoomScoreRequest(int roomId, int playlistItemId, string versionHash) { this.roomId = roomId; this.playlistItemId = playlistItemId; + this.versionHash = versionHash; } protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); req.Method = HttpMethod.Post; + req.AddParameter("version_hash", versionHash); return req; } diff --git a/osu.Game/Online/API/Requests/GetRoomScoresRequest.cs b/osu.Game/Online/Multiplayer/GetRoomLeaderboardRequest.cs similarity index 58% rename from osu.Game/Online/API/Requests/GetRoomScoresRequest.cs rename to osu.Game/Online/Multiplayer/GetRoomLeaderboardRequest.cs index 993e49dab2..37c21457bc 100644 --- a/osu.Game/Online/API/Requests/GetRoomScoresRequest.cs +++ b/osu.Game/Online/Multiplayer/GetRoomLeaderboardRequest.cs @@ -1,16 +1,15 @@ // 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. -using System.Collections.Generic; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.API; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { - public class GetRoomScoresRequest : APIRequest<List<APIRoomScoreInfo>> + public class GetRoomLeaderboardRequest : APIRequest<APILeaderboard> { private readonly int roomId; - public GetRoomScoresRequest(int roomId) + public GetRoomLeaderboardRequest(int roomId) { this.roomId = roomId; } diff --git a/osu.Game/Online/Multiplayer/GetRoomRequest.cs b/osu.Game/Online/Multiplayer/GetRoomRequest.cs new file mode 100644 index 0000000000..2907b49f1d --- /dev/null +++ b/osu.Game/Online/Multiplayer/GetRoomRequest.cs @@ -0,0 +1,19 @@ +// 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. + +using osu.Game.Online.API; + +namespace osu.Game.Online.Multiplayer +{ + public class GetRoomRequest : APIRequest<Room> + { + private readonly int roomId; + + public GetRoomRequest(int roomId) + { + this.roomId = roomId; + } + + protected override string Target => $"rooms/{roomId}"; + } +} diff --git a/osu.Game/Online/Multiplayer/GetRoomsRequest.cs b/osu.Game/Online/Multiplayer/GetRoomsRequest.cs new file mode 100644 index 0000000000..64e0386f77 --- /dev/null +++ b/osu.Game/Online/Multiplayer/GetRoomsRequest.cs @@ -0,0 +1,38 @@ +// 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. + +using System.Collections.Generic; +using Humanizer; +using osu.Framework.IO.Network; +using osu.Game.Online.API; +using osu.Game.Screens.Multi.Lounge.Components; + +namespace osu.Game.Online.Multiplayer +{ + public class GetRoomsRequest : APIRequest<List<Room>> + { + private readonly RoomStatusFilter statusFilter; + private readonly RoomCategoryFilter categoryFilter; + + public GetRoomsRequest(RoomStatusFilter statusFilter, RoomCategoryFilter categoryFilter) + { + this.statusFilter = statusFilter; + this.categoryFilter = categoryFilter; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + if (statusFilter != RoomStatusFilter.Open) + req.AddParameter("mode", statusFilter.ToString().Underscore().ToLowerInvariant()); + + if (categoryFilter != RoomCategoryFilter.Any) + req.AddParameter("category", categoryFilter.ToString().Underscore().ToLowerInvariant()); + + return req; + } + + protected override string Target => "rooms"; + } +} diff --git a/osu.Game/Online/Multiplayer/IndexPlaylistScoresRequest.cs b/osu.Game/Online/Multiplayer/IndexPlaylistScoresRequest.cs new file mode 100644 index 0000000000..684d0aecd8 --- /dev/null +++ b/osu.Game/Online/Multiplayer/IndexPlaylistScoresRequest.cs @@ -0,0 +1,59 @@ +// 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. + +using System.Diagnostics; +using JetBrains.Annotations; +using osu.Framework.IO.Network; +using osu.Game.Extensions; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Online.Multiplayer +{ + /// <summary> + /// Returns a list of scores for the specified playlist item. + /// </summary> + public class IndexPlaylistScoresRequest : APIRequest<IndexedMultiplayerScores> + { + public readonly int RoomId; + public readonly int PlaylistItemId; + + [CanBeNull] + public readonly Cursor Cursor; + + [CanBeNull] + public readonly IndexScoresParams IndexParams; + + public IndexPlaylistScoresRequest(int roomId, int playlistItemId) + { + RoomId = roomId; + PlaylistItemId = playlistItemId; + } + + public IndexPlaylistScoresRequest(int roomId, int playlistItemId, [NotNull] Cursor cursor, [NotNull] IndexScoresParams indexParams) + : this(roomId, playlistItemId) + { + Cursor = cursor; + IndexParams = indexParams; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + if (Cursor != null) + { + Debug.Assert(IndexParams != null); + + req.AddCursor(Cursor); + + foreach (var (key, value) in IndexParams.Properties) + req.AddParameter(key, value.ToString()); + } + + return req; + } + + protected override string Target => $@"rooms/{RoomId}/playlist/{PlaylistItemId}/scores"; + } +} diff --git a/osu.Game/Online/Multiplayer/IndexScoresParams.cs b/osu.Game/Online/Multiplayer/IndexScoresParams.cs new file mode 100644 index 0000000000..a511e9a780 --- /dev/null +++ b/osu.Game/Online/Multiplayer/IndexScoresParams.cs @@ -0,0 +1,20 @@ +// 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. + +using System.Collections.Generic; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace osu.Game.Online.Multiplayer +{ + /// <summary> + /// A collection of parameters which should be passed to the index endpoint to fetch the next page. + /// </summary> + public class IndexScoresParams + { + [UsedImplicitly] + [JsonExtensionData] + public IDictionary<string, JToken> Properties { get; set; } = new Dictionary<string, JToken>(); + } +} diff --git a/osu.Game/Online/Multiplayer/IndexedMultiplayerScores.cs b/osu.Game/Online/Multiplayer/IndexedMultiplayerScores.cs new file mode 100644 index 0000000000..e237b7e3fb --- /dev/null +++ b/osu.Game/Online/Multiplayer/IndexedMultiplayerScores.cs @@ -0,0 +1,27 @@ +// 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. + +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer +{ + /// <summary> + /// A <see cref="MultiplayerScores"/> object returned via a <see cref="IndexPlaylistScoresRequest"/>. + /// </summary> + public class IndexedMultiplayerScores : MultiplayerScores + { + /// <summary> + /// The total scores in the playlist item. + /// </summary> + [JsonProperty("total")] + public int? TotalScores { get; set; } + + /// <summary> + /// The user's score, if any. + /// </summary> + [JsonProperty("user_score")] + [CanBeNull] + public MultiplayerScore UserScore { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/JoinRoomRequest.cs b/osu.Game/Online/Multiplayer/JoinRoomRequest.cs similarity index 71% rename from osu.Game/Online/API/Requests/JoinRoomRequest.cs rename to osu.Game/Online/Multiplayer/JoinRoomRequest.cs index 36b275236c..74375af856 100644 --- a/osu.Game/Online/API/Requests/JoinRoomRequest.cs +++ b/osu.Game/Online/Multiplayer/JoinRoomRequest.cs @@ -3,20 +3,17 @@ using System.Net.Http; using osu.Framework.IO.Network; -using osu.Game.Online.Multiplayer; -using osu.Game.Users; +using osu.Game.Online.API; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { public class JoinRoomRequest : APIRequest { private readonly Room room; - private readonly User user; - public JoinRoomRequest(Room room, User user) + public JoinRoomRequest(Room room) { this.room = room; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{user.Id}"; + protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}"; } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerScore.cs b/osu.Game/Online/Multiplayer/MultiplayerScore.cs new file mode 100644 index 0000000000..8191003aad --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerScore.cs @@ -0,0 +1,90 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using osu.Game.Online.API; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Online.Multiplayer +{ + public class MultiplayerScore + { + [JsonProperty("id")] + public int ID { get; set; } + + [JsonProperty("user")] + public User User { get; set; } + + [JsonProperty("rank")] + [JsonConverter(typeof(StringEnumConverter))] + public ScoreRank Rank { get; set; } + + [JsonProperty("total_score")] + public long TotalScore { get; set; } + + [JsonProperty("accuracy")] + public double Accuracy { get; set; } + + [JsonProperty("max_combo")] + public int MaxCombo { get; set; } + + [JsonProperty("mods")] + public APIMod[] Mods { get; set; } + + [JsonProperty("statistics")] + public Dictionary<HitResult, int> Statistics = new Dictionary<HitResult, int>(); + + [JsonProperty("passed")] + public bool Passed { get; set; } + + [JsonProperty("ended_at")] + public DateTimeOffset EndedAt { get; set; } + + /// <summary> + /// The position of this score, starting at 1. + /// </summary> + [JsonProperty("position")] + public int? Position { get; set; } + + /// <summary> + /// Any scores in the room around this score. + /// </summary> + [JsonProperty("scores_around")] + [CanBeNull] + public MultiplayerScoresAround ScoresAround { get; set; } + + public ScoreInfo CreateScoreInfo(PlaylistItem playlistItem) + { + var rulesetInstance = playlistItem.Ruleset.Value.CreateInstance(); + + var scoreInfo = new ScoreInfo + { + OnlineScoreID = ID, + TotalScore = TotalScore, + MaxCombo = MaxCombo, + Beatmap = playlistItem.Beatmap.Value, + BeatmapInfoID = playlistItem.BeatmapID, + Ruleset = playlistItem.Ruleset.Value, + RulesetID = playlistItem.RulesetID, + Statistics = Statistics, + User = User, + Accuracy = Accuracy, + Date = EndedAt, + Hash = string.Empty, // todo: temporary? + Rank = Rank, + Mods = Mods?.Select(m => m.ToMod(rulesetInstance)).ToArray() ?? Array.Empty<Mod>(), + Position = Position, + }; + + return scoreInfo; + } + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerScores.cs b/osu.Game/Online/Multiplayer/MultiplayerScores.cs new file mode 100644 index 0000000000..7b9dcff828 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerScores.cs @@ -0,0 +1,27 @@ +// 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. + +using System.Collections.Generic; +using Newtonsoft.Json; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Online.Multiplayer +{ + /// <summary> + /// An object which contains scores and related data for fetching next pages. + /// </summary> + public class MultiplayerScores : ResponseWithCursor + { + /// <summary> + /// The scores. + /// </summary> + [JsonProperty("scores")] + public List<MultiplayerScore> Scores { get; set; } = new List<MultiplayerScore>(); + + /// <summary> + /// The parameters to be used to fetch the next page. + /// </summary> + [JsonProperty("params")] + public IndexScoresParams Params { get; set; } = new IndexScoresParams(); + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerScoresAround.cs b/osu.Game/Online/Multiplayer/MultiplayerScoresAround.cs new file mode 100644 index 0000000000..2ac62d0300 --- /dev/null +++ b/osu.Game/Online/Multiplayer/MultiplayerScoresAround.cs @@ -0,0 +1,28 @@ +// 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. + +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer +{ + /// <summary> + /// An object which stores scores higher and lower than the user's score. + /// </summary> + public class MultiplayerScoresAround + { + /// <summary> + /// Scores sorted "higher" than the user's score, depending on the sorting order. + /// </summary> + [JsonProperty("higher")] + [CanBeNull] + public MultiplayerScores Higher { get; set; } + + /// <summary> + /// Scores sorted "lower" than the user's score, depending on the sorting order. + /// </summary> + [JsonProperty("lower")] + [CanBeNull] + public MultiplayerScores Lower { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/PartRoomRequest.cs b/osu.Game/Online/Multiplayer/PartRoomRequest.cs similarity index 71% rename from osu.Game/Online/API/Requests/PartRoomRequest.cs rename to osu.Game/Online/Multiplayer/PartRoomRequest.cs index e1550cb2e0..54bb005d96 100644 --- a/osu.Game/Online/API/Requests/PartRoomRequest.cs +++ b/osu.Game/Online/Multiplayer/PartRoomRequest.cs @@ -3,20 +3,17 @@ using System.Net.Http; using osu.Framework.IO.Network; -using osu.Game.Online.Multiplayer; -using osu.Game.Users; +using osu.Game.Online.API; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { public class PartRoomRequest : APIRequest { private readonly Room room; - private readonly User user; - public PartRoomRequest(Room room, User user) + public PartRoomRequest(Room room) { this.room = room; - this.user = user; } protected override WebRequest CreateWebRequest() @@ -26,6 +23,6 @@ namespace osu.Game.Online.API.Requests return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{user.Id}"; + protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}"; } } diff --git a/osu.Game/Online/Multiplayer/PlaylistItem.cs b/osu.Game/Online/Multiplayer/PlaylistItem.cs index e47d497d94..416091a1aa 100644 --- a/osu.Game/Online/Multiplayer/PlaylistItem.cs +++ b/osu.Game/Online/Multiplayer/PlaylistItem.cs @@ -1,17 +1,18 @@ // 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. -using System.Collections.Generic; +using System; using System.Linq; using Newtonsoft.Json; +using osu.Framework.Bindables; using osu.Game.Beatmaps; -using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; namespace osu.Game.Online.Multiplayer { - public class PlaylistItem + public class PlaylistItem : IEquatable<PlaylistItem> { [JsonProperty("id")] public int ID { get; set; } @@ -23,71 +24,71 @@ namespace osu.Game.Online.Multiplayer public int RulesetID { get; set; } [JsonIgnore] - public BeatmapInfo Beatmap - { - get => beatmap; - set - { - beatmap = value; - BeatmapID = value?.OnlineBeatmapID ?? 0; - } - } + public readonly Bindable<BeatmapInfo> Beatmap = new Bindable<BeatmapInfo>(); [JsonIgnore] - public RulesetInfo Ruleset { get; set; } + public readonly Bindable<RulesetInfo> Ruleset = new Bindable<RulesetInfo>(); [JsonIgnore] - public readonly List<Mod> AllowedMods = new List<Mod>(); + public readonly BindableList<Mod> AllowedMods = new BindableList<Mod>(); [JsonIgnore] - public readonly List<Mod> RequiredMods = new List<Mod>(); + public readonly BindableList<Mod> RequiredMods = new BindableList<Mod>(); [JsonProperty("beatmap")] - private APIBeatmap apiBeatmap { get; set; } + private APIPlaylistBeatmap apiBeatmap { get; set; } + + private APIMod[] allowedModsBacking; [JsonProperty("allowed_mods")] private APIMod[] allowedMods { - get => AllowedMods.Select(m => new APIMod { Acronym = m.Acronym }).ToArray(); - set => _allowedMods = value; + get => AllowedMods.Select(m => new APIMod(m)).ToArray(); + set => allowedModsBacking = value; } + private APIMod[] requiredModsBacking; + [JsonProperty("required_mods")] private APIMod[] requiredMods { - get => RequiredMods.Select(m => new APIMod { Acronym = m.Acronym }).ToArray(); - set => _requiredMods = value; + get => RequiredMods.Select(m => new APIMod(m)).ToArray(); + set => requiredModsBacking = value; } - private BeatmapInfo beatmap; - private APIMod[] _allowedMods; - private APIMod[] _requiredMods; + public PlaylistItem() + { + Beatmap.BindValueChanged(beatmap => BeatmapID = beatmap.NewValue?.OnlineBeatmapID ?? 0); + Ruleset.BindValueChanged(ruleset => RulesetID = ruleset.NewValue?.ID ?? 0); + } public void MapObjects(BeatmapManager beatmaps, RulesetStore rulesets) { - // If we don't have an api beatmap, the request occurred as a result of room creation, so we can query the local beatmap instead - // Todo: Is this a bug? Room creation only returns the beatmap ID - Beatmap = apiBeatmap == null ? beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == BeatmapID) : apiBeatmap.ToBeatmap(rulesets); - Ruleset = rulesets.GetRuleset(RulesetID); + Beatmap.Value = apiBeatmap.ToBeatmap(rulesets); + Ruleset.Value = rulesets.GetRuleset(RulesetID); - if (_allowedMods != null) + Ruleset rulesetInstance = Ruleset.Value.CreateInstance(); + + if (allowedModsBacking != null) { AllowedMods.Clear(); - AllowedMods.AddRange(Ruleset.CreateInstance().GetAllMods().Where(mod => _allowedMods.Any(m => m.Acronym == mod.Acronym))); + AllowedMods.AddRange(allowedModsBacking.Select(m => m.ToMod(rulesetInstance))); - _allowedMods = null; + allowedModsBacking = null; } - if (_requiredMods != null) + if (requiredModsBacking != null) { RequiredMods.Clear(); - RequiredMods.AddRange(Ruleset.CreateInstance().GetAllMods().Where(mod => _requiredMods.Any(m => m.Acronym == mod.Acronym))); + RequiredMods.AddRange(requiredModsBacking.Select(m => m.ToMod(rulesetInstance))); - _requiredMods = null; + requiredModsBacking = null; } } public bool ShouldSerializeID() => false; public bool ShouldSerializeapiBeatmap() => false; + + public bool Equals(PlaylistItem other) => ID == other?.ID && BeatmapID == other.BeatmapID && RulesetID == other.RulesetID; } } diff --git a/osu.Game/Online/Multiplayer/Room.cs b/osu.Game/Online/Multiplayer/Room.cs index 53089897f7..9a21543b2e 100644 --- a/osu.Game/Online/Multiplayer/Room.cs +++ b/osu.Game/Online/Multiplayer/Room.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using osu.Framework.Allocation; @@ -17,70 +16,58 @@ namespace osu.Game.Online.Multiplayer { [Cached] [JsonProperty("id")] - public Bindable<int?> RoomID { get; private set; } = new Bindable<int?>(); + public readonly Bindable<int?> RoomID = new Bindable<int?>(); [Cached] [JsonProperty("name")] - public Bindable<string> Name { get; private set; } = new Bindable<string>(); + public readonly Bindable<string> Name = new Bindable<string>(); [Cached] [JsonProperty("host")] - public Bindable<User> Host { get; private set; } = new Bindable<User>(); + public readonly Bindable<User> Host = new Bindable<User>(); [Cached] [JsonProperty("playlist")] - public BindableList<PlaylistItem> Playlist { get; private set; } = new BindableList<PlaylistItem>(); - - [Cached] - [JsonIgnore] - public Bindable<PlaylistItem> CurrentItem { get; private set; } = new Bindable<PlaylistItem>(); + public readonly BindableList<PlaylistItem> Playlist = new BindableList<PlaylistItem>(); [Cached] [JsonProperty("channel_id")] - public Bindable<int> ChannelId { get; private set; } = new Bindable<int>(); + public readonly Bindable<int> ChannelId = new Bindable<int>(); + + [Cached] + [JsonProperty("category")] + public readonly Bindable<RoomCategory> Category = new Bindable<RoomCategory>(); [Cached] [JsonIgnore] - public Bindable<TimeSpan> Duration { get; private set; } = new Bindable<TimeSpan>(TimeSpan.FromMinutes(30)); + public readonly Bindable<TimeSpan> Duration = new Bindable<TimeSpan>(TimeSpan.FromMinutes(30)); [Cached] [JsonIgnore] - public Bindable<int?> MaxAttempts { get; private set; } = new Bindable<int?>(); + public readonly Bindable<int?> MaxAttempts = new Bindable<int?>(); [Cached] [JsonIgnore] - public Bindable<RoomStatus> Status { get; private set; } = new Bindable<RoomStatus>(new RoomStatusOpen()); + public readonly Bindable<RoomStatus> Status = new Bindable<RoomStatus>(new RoomStatusOpen()); [Cached] [JsonIgnore] - public Bindable<RoomAvailability> Availability { get; private set; } = new Bindable<RoomAvailability>(); + public readonly Bindable<RoomAvailability> Availability = new Bindable<RoomAvailability>(); [Cached] [JsonIgnore] - public Bindable<GameType> Type { get; private set; } = new Bindable<GameType>(new GameTypeTimeshift()); + public readonly Bindable<GameType> Type = new Bindable<GameType>(new GameTypeTimeshift()); [Cached] [JsonIgnore] - public Bindable<int?> MaxParticipants { get; private set; } = new Bindable<int?>(); + public readonly Bindable<int?> MaxParticipants = new Bindable<int?>(); [Cached] - [JsonIgnore] - public Bindable<IEnumerable<User>> Participants { get; private set; } = new Bindable<IEnumerable<User>>(Enumerable.Empty<User>()); + [JsonProperty("recent_participants")] + public readonly BindableList<User> RecentParticipants = new BindableList<User>(); [Cached] - public Bindable<int> ParticipantCount { get; private set; } = new Bindable<int>(); - - public Room() - { - Playlist.ItemsAdded += updateCurrent; - Playlist.ItemsRemoved += updateCurrent; - updateCurrent(Playlist); - } - - private void updateCurrent(IEnumerable<PlaylistItem> playlist) - { - CurrentItem.Value = playlist.FirstOrDefault(); - } + public readonly Bindable<int> ParticipantCount = new Bindable<int>(); // todo: TEMPORARY [JsonProperty("participant_count")] @@ -100,7 +87,7 @@ namespace osu.Game.Online.Multiplayer // Only supports retrieval for now [Cached] [JsonProperty("ends_at")] - public Bindable<DateTimeOffset> EndDate { get; private set; } = new Bindable<DateTimeOffset>(); + public readonly Bindable<DateTimeOffset> EndDate = new Bindable<DateTimeOffset>(); // Todo: Find a better way to do this (https://github.com/ppy/osu-framework/issues/1930) [JsonProperty("max_attempts", DefaultValueHandling = DefaultValueHandling.Ignore)] @@ -114,7 +101,21 @@ namespace osu.Game.Online.Multiplayer /// The position of this <see cref="Room"/> in the list. This is not read from or written to the API. /// </summary> [JsonIgnore] - public Bindable<int> Position { get; private set; } = new Bindable<int>(-1); + public readonly Bindable<int> Position = new Bindable<int>(-1); + + /// <summary> + /// Create a copy of this room without online information. + /// Should be used to create a local copy of a room for submitting in the future. + /// </summary> + public Room CreateCopy() + { + var copy = new Room(); + + copy.CopyFrom(this); + copy.RoomID.Value = null; + + return copy; + } public void CopyFrom(Room other) { @@ -130,19 +131,24 @@ namespace osu.Game.Online.Multiplayer Type.Value = other.Type.Value; MaxParticipants.Value = other.MaxParticipants.Value; ParticipantCount.Value = other.ParticipantCount.Value; - Participants.Value = other.Participants.Value.ToArray(); EndDate.Value = other.EndDate.Value; if (DateTimeOffset.Now >= EndDate.Value) Status.Value = new RoomStatusEnded(); - // Todo: Temporary, should only remove/add new items (requires framework changes) - if (Playlist.Count == 0) + if (!Playlist.SequenceEqual(other.Playlist)) + { + Playlist.Clear(); Playlist.AddRange(other.Playlist); - else if (other.Playlist.Count > 0) - Playlist.First().ID = other.Playlist.First().ID; + } - Position = other.Position; + if (!RecentParticipants.SequenceEqual(other.RecentParticipants)) + { + RecentParticipants.Clear(); + RecentParticipants.AddRange(other.RecentParticipants); + } + + Position.Value = other.Position.Value; } public bool ShouldSerializeRoomID() => false; diff --git a/osu.Game/Online/Multiplayer/RoomCategory.cs b/osu.Game/Online/Multiplayer/RoomCategory.cs new file mode 100644 index 0000000000..636a73a3e9 --- /dev/null +++ b/osu.Game/Online/Multiplayer/RoomCategory.cs @@ -0,0 +1,11 @@ +// 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. + +namespace osu.Game.Online.Multiplayer +{ + public enum RoomCategory + { + Normal, + Spotlight + } +} diff --git a/osu.Game/Online/Multiplayer/ShowPlaylistUserScoreRequest.cs b/osu.Game/Online/Multiplayer/ShowPlaylistUserScoreRequest.cs new file mode 100644 index 0000000000..936b8bbe89 --- /dev/null +++ b/osu.Game/Online/Multiplayer/ShowPlaylistUserScoreRequest.cs @@ -0,0 +1,23 @@ +// 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. + +using osu.Game.Online.API; + +namespace osu.Game.Online.Multiplayer +{ + public class ShowPlaylistUserScoreRequest : APIRequest<MultiplayerScore> + { + private readonly int roomId; + private readonly int playlistItemId; + private readonly long userId; + + public ShowPlaylistUserScoreRequest(int roomId, int playlistItemId, long userId) + { + this.roomId = roomId; + this.playlistItemId = playlistItemId; + this.userId = userId; + } + + protected override string Target => $"rooms/{roomId}/playlist/{playlistItemId}/scores/users/{userId}"; + } +} diff --git a/osu.Game/Online/API/Requests/SubmitRoomScoreRequest.cs b/osu.Game/Online/Multiplayer/SubmitRoomScoreRequest.cs similarity index 90% rename from osu.Game/Online/API/Requests/SubmitRoomScoreRequest.cs rename to osu.Game/Online/Multiplayer/SubmitRoomScoreRequest.cs index 50b62cd6ed..d31aef2ea5 100644 --- a/osu.Game/Online/API/Requests/SubmitRoomScoreRequest.cs +++ b/osu.Game/Online/Multiplayer/SubmitRoomScoreRequest.cs @@ -4,11 +4,12 @@ using System.Net.Http; using Newtonsoft.Json; using osu.Framework.IO.Network; +using osu.Game.Online.API; using osu.Game.Scoring; -namespace osu.Game.Online.API.Requests +namespace osu.Game.Online.Multiplayer { - public class SubmitRoomScoreRequest : APIRequest + public class SubmitRoomScoreRequest : APIRequest<MultiplayerScore> { private readonly int scoreId; private readonly int roomId; diff --git a/osu.Game/Online/OnlineViewContainer.cs b/osu.Game/Online/OnlineViewContainer.cs new file mode 100644 index 0000000000..c9fb70f0cc --- /dev/null +++ b/osu.Game/Online/OnlineViewContainer.cs @@ -0,0 +1,93 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.Placeholders; + +namespace osu.Game.Online +{ + /// <summary> + /// A <see cref="Container"/> for displaying online content which require a local user to be logged in. + /// Shows its children only when the local user is logged in and supports displaying a placeholder if not. + /// </summary> + public abstract class OnlineViewContainer : Container + { + protected LoadingSpinner LoadingSpinner { get; private set; } + + protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + private readonly string placeholderMessage; + + private Placeholder placeholder; + + private const double transform_duration = 300; + + [Resolved] + protected IAPIProvider API { get; private set; } + + protected OnlineViewContainer(string placeholderMessage) + { + this.placeholderMessage = placeholderMessage; + } + + private readonly IBindable<APIState> apiState = new Bindable<APIState>(); + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) + { + InternalChildren = new Drawable[] + { + Content, + placeholder = new LoginPlaceholder(placeholderMessage), + LoadingSpinner = new LoadingSpinner + { + Alpha = 0, + } + }; + + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); + } + + private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() => + { + switch (state.NewValue) + { + case APIState.Offline: + PopContentOut(Content); + placeholder.ScaleTo(0.8f).Then().ScaleTo(1, 3 * transform_duration, Easing.OutQuint); + placeholder.FadeInFromZero(2 * transform_duration, Easing.OutQuint); + LoadingSpinner.Hide(); + break; + + case APIState.Online: + PopContentIn(Content); + placeholder.FadeOut(transform_duration / 2, Easing.OutQuint); + LoadingSpinner.Hide(); + break; + + case APIState.Failing: + case APIState.Connecting: + PopContentOut(Content); + LoadingSpinner.Show(); + placeholder.FadeOut(transform_duration / 2, Easing.OutQuint); + break; + } + }); + + /// <summary> + /// Applies a transform to the online content to make it hidden. + /// </summary> + protected virtual void PopContentOut(Drawable content) => content.FadeOut(transform_duration / 2, Easing.OutQuint); + + /// <summary> + /// Applies a transform to the online content to make it visible. + /// </summary> + protected virtual void PopContentIn(Drawable content) => content.FadeIn(transform_duration, Easing.OutQuint); + } +} diff --git a/osu.Game/Online/Placeholders/ClickablePlaceholder.cs b/osu.Game/Online/Placeholders/ClickablePlaceholder.cs new file mode 100644 index 0000000000..936ad79c64 --- /dev/null +++ b/osu.Game/Online/Placeholders/ClickablePlaceholder.cs @@ -0,0 +1,38 @@ +// 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. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Online.Placeholders +{ + public class ClickablePlaceholder : Placeholder + { + public Action Action; + + public ClickablePlaceholder(string actionMessage, IconUsage icon) + { + OsuTextFlowContainer textFlow; + + AddArbitraryDrawable(new OsuAnimatedButton + { + AutoSizeAxes = Framework.Graphics.Axes.Both, + Child = textFlow = new OsuTextFlowContainer(cp => cp.Font = cp.Font.With(size: TEXT_SIZE)) + { + AutoSizeAxes = Framework.Graphics.Axes.Both, + Margin = new Framework.Graphics.MarginPadding(5) + }, + Action = () => Action?.Invoke() + }); + + textFlow.AddIcon(icon, i => + { + i.Padding = new Framework.Graphics.MarginPadding { Right = 10 }; + }); + + textFlow.AddText(actionMessage); + } + } +} diff --git a/osu.Game/Online/Placeholders/LoginPlaceholder.cs b/osu.Game/Online/Placeholders/LoginPlaceholder.cs new file mode 100644 index 0000000000..f8a326a52e --- /dev/null +++ b/osu.Game/Online/Placeholders/LoginPlaceholder.cs @@ -0,0 +1,21 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays; + +namespace osu.Game.Online.Placeholders +{ + public sealed class LoginPlaceholder : ClickablePlaceholder + { + [Resolved(CanBeNull = true)] + private LoginOverlay login { get; set; } + + public LoginPlaceholder(string actionMessage) + : base(actionMessage, FontAwesome.Solid.UserLock) + { + Action = () => login?.Show(); + } + } +} diff --git a/osu.Game/Online/Leaderboards/MessagePlaceholder.cs b/osu.Game/Online/Placeholders/MessagePlaceholder.cs similarity index 95% rename from osu.Game/Online/Leaderboards/MessagePlaceholder.cs rename to osu.Game/Online/Placeholders/MessagePlaceholder.cs index ef425dacd8..7342765ca4 100644 --- a/osu.Game/Online/Leaderboards/MessagePlaceholder.cs +++ b/osu.Game/Online/Placeholders/MessagePlaceholder.cs @@ -4,7 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -namespace osu.Game.Online.Leaderboards +namespace osu.Game.Online.Placeholders { public class MessagePlaceholder : Placeholder { diff --git a/osu.Game/Online/Leaderboards/Placeholder.cs b/osu.Game/Online/Placeholders/Placeholder.cs similarity index 95% rename from osu.Game/Online/Leaderboards/Placeholder.cs rename to osu.Game/Online/Placeholders/Placeholder.cs index d38110a9d0..f58282bbd9 100644 --- a/osu.Game/Online/Leaderboards/Placeholder.cs +++ b/osu.Game/Online/Placeholders/Placeholder.cs @@ -5,7 +5,7 @@ using System; using osu.Framework.Graphics; using osu.Game.Graphics.Containers; -namespace osu.Game.Online.Leaderboards +namespace osu.Game.Online.Placeholders { public abstract class Placeholder : OsuTextFlowContainer, IEquatable<Placeholder> { diff --git a/osu.Game/Online/PollingComponent.cs b/osu.Game/Online/PollingComponent.cs index acbb2c39f4..228f147835 100644 --- a/osu.Game/Online/PollingComponent.cs +++ b/osu.Game/Online/PollingComponent.cs @@ -3,7 +3,7 @@ using System; using System.Threading.Tasks; -using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Threading; namespace osu.Game.Online @@ -11,7 +11,7 @@ namespace osu.Game.Online /// <summary> /// A component which requires a constant polling process. /// </summary> - public abstract class PollingComponent : Component + public abstract class PollingComponent : CompositeDrawable // switch away from Component because InternalChildren are used in usages. { private double? lastTimePolled; diff --git a/osu.Game/Online/Spectator/FrameDataBundle.cs b/osu.Game/Online/Spectator/FrameDataBundle.cs new file mode 100644 index 0000000000..5281e61f9c --- /dev/null +++ b/osu.Game/Online/Spectator/FrameDataBundle.cs @@ -0,0 +1,20 @@ +// 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. + +using System; +using System.Collections.Generic; +using osu.Game.Replays.Legacy; + +namespace osu.Game.Online.Spectator +{ + [Serializable] + public class FrameDataBundle + { + public IEnumerable<LegacyReplayFrame> Frames { get; set; } + + public FrameDataBundle(IEnumerable<LegacyReplayFrame> frames) + { + Frames = frames; + } + } +} diff --git a/osu.Game/Online/Spectator/ISpectatorClient.cs b/osu.Game/Online/Spectator/ISpectatorClient.cs new file mode 100644 index 0000000000..3acc9b2282 --- /dev/null +++ b/osu.Game/Online/Spectator/ISpectatorClient.cs @@ -0,0 +1,34 @@ +// 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. + +using System.Threading.Tasks; + +namespace osu.Game.Online.Spectator +{ + /// <summary> + /// An interface defining a spectator client instance. + /// </summary> + public interface ISpectatorClient + { + /// <summary> + /// Signals that a user has begun a new play session. + /// </summary> + /// <param name="userId">The user.</param> + /// <param name="state">The state of gameplay.</param> + Task UserBeganPlaying(int userId, SpectatorState state); + + /// <summary> + /// Signals that a user has finished a play session. + /// </summary> + /// <param name="userId">The user.</param> + /// <param name="state">The state of gameplay.</param> + Task UserFinishedPlaying(int userId, SpectatorState state); + + /// <summary> + /// Called when new frames are available for a subscribed user's play session. + /// </summary> + /// <param name="userId">The user.</param> + /// <param name="data">The frame data.</param> + Task UserSentFrames(int userId, FrameDataBundle data); + } +} diff --git a/osu.Game/Online/Spectator/ISpectatorServer.cs b/osu.Game/Online/Spectator/ISpectatorServer.cs new file mode 100644 index 0000000000..af0196862a --- /dev/null +++ b/osu.Game/Online/Spectator/ISpectatorServer.cs @@ -0,0 +1,44 @@ +// 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. + +using System.Threading.Tasks; + +namespace osu.Game.Online.Spectator +{ + /// <summary> + /// An interface defining the spectator server instance. + /// </summary> + public interface ISpectatorServer + { + /// <summary> + /// Signal the start of a new play session. + /// </summary> + /// <param name="state">The state of gameplay.</param> + Task BeginPlaySession(SpectatorState state); + + /// <summary> + /// Send a bundle of frame data for the current play session. + /// </summary> + /// <param name="data">The frame data.</param> + Task SendFrameData(FrameDataBundle data); + + /// <summary> + /// Signal the end of a play session. + /// </summary> + /// <param name="state">The state of gameplay.</param> + Task EndPlaySession(SpectatorState state); + + /// <summary> + /// Request spectating data for the specified user. May be called on multiple users and offline users. + /// For offline users, a subscription will be created and data will begin streaming on next play. + /// </summary> + /// <param name="userId">The user to subscribe to.</param> + Task StartWatchingUser(int userId); + + /// <summary> + /// Stop requesting spectating data for the specified user. Unsubscribes from receiving further data. + /// </summary> + /// <param name="userId">The user to unsubscribe from.</param> + Task EndWatchingUser(int userId); + } +} diff --git a/osu.Game/Online/Spectator/SpectatorState.cs b/osu.Game/Online/Spectator/SpectatorState.cs new file mode 100644 index 0000000000..101ce3d5d5 --- /dev/null +++ b/osu.Game/Online/Spectator/SpectatorState.cs @@ -0,0 +1,26 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using osu.Game.Online.API; + +namespace osu.Game.Online.Spectator +{ + [Serializable] + public class SpectatorState : IEquatable<SpectatorState> + { + public int? BeatmapID { get; set; } + + public int? RulesetID { get; set; } + + [NotNull] + public IEnumerable<APIMod> Mods { get; set; } = Enumerable.Empty<APIMod>(); + + public bool Equals(SpectatorState other) => 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 new file mode 100644 index 0000000000..9ba81720d8 --- /dev/null +++ b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs @@ -0,0 +1,299 @@ +// 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. + +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 Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Logging; +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.Screens.Play; + +namespace osu.Game.Online.Spectator +{ + public class SpectatorStreamingClient : Component, ISpectatorClient + { + /// <summary> + /// The maximum milliseconds between frame bundle sends. + /// </summary> + public const double TIME_BETWEEN_SENDS = 200; + + private HubConnection connection; + + private readonly List<int> watchingUsers = new List<int>(); + + public IBindableList<int> PlayingUsers => playingUsers; + + private readonly BindableList<int> playingUsers = new BindableList<int>(); + + private readonly IBindable<APIState> apiState = new Bindable<APIState>(); + + private bool isConnected; + + [Resolved] + private IAPIProvider api { get; set; } + + [CanBeNull] + private IBeatmap currentBeatmap; + + [Resolved] + private IBindable<RulesetInfo> currentRuleset { get; set; } + + [Resolved] + private IBindable<IReadOnlyList<Mod>> currentMods { get; set; } + + private readonly SpectatorState currentState = new SpectatorState(); + + private bool isPlaying; + + /// <summary> + /// Called whenever new frames arrive from the server. + /// </summary> + public event Action<int, FrameDataBundle> OnNewFrames; + + /// <summary> + /// Called whenever a user starts a play session. + /// </summary> + public event Action<int, SpectatorState> OnUserBeganPlaying; + + /// <summary> + /// Called whenever a user finishes a play session. + /// </summary> + public event Action<int, SpectatorState> OnUserFinishedPlaying; + + [BackgroundDependencyLoader] + private void load() + { + apiState.BindTo(api.State); + apiState.BindValueChanged(apiStateChanged, true); + } + + private void apiStateChanged(ValueChangedEvent<APIState> state) + { + switch (state.NewValue) + { + case APIState.Failing: + case APIState.Offline: + connection?.StopAsync(); + connection = null; + break; + + case APIState.Online: + Task.Run(Connect); + break; + } + } + + private const string endpoint = "https://spectator.ppy.sh/spectator"; + + protected virtual async Task Connect() + { + if (connection != null) + return; + + connection = new HubConnectionBuilder() + .WithUrl(endpoint, options => + { + options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); + }) + .AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; }) + .Build(); + + // until strong typed client support is added, each method must be manually bound (see https://github.com/dotnet/aspnetcore/issues/15198) + connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); + connection.On<int, FrameDataBundle>(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); + connection.On<int, SpectatorState>(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); + + connection.Closed += async ex => + { + isConnected = false; + playingUsers.Clear(); + + if (ex != null) + { + Logger.Log($"Spectator client lost connection: {ex}", LoggingTarget.Network); + await tryUntilConnected(); + } + }; + + await tryUntilConnected(); + + async Task tryUntilConnected() + { + Logger.Log("Spectator client connecting...", LoggingTarget.Network); + + while (api.State.Value == APIState.Online) + { + try + { + // reconnect on any failure + await connection.StartAsync(); + Logger.Log("Spectator client connected!", LoggingTarget.Network); + + // success + isConnected = true; + + // resubscribe to watched users + var users = watchingUsers.ToArray(); + watchingUsers.Clear(); + foreach (var userId in users) + WatchUser(userId); + + // re-send state in case it wasn't received + if (isPlaying) + beginPlaying(); + + break; + } + catch (Exception e) + { + Logger.Log($"Spectator client connection error: {e}", LoggingTarget.Network); + await Task.Delay(5000); + } + } + } + } + + 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) + { + 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; + beginPlaying(); + } + + private void beginPlaying() + { + Debug.Assert(isPlaying); + + if (!isConnected) return; + + connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), currentState); + } + + public void SendFrames(FrameDataBundle data) + { + if (!isConnected) return; + + lastSend = connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data); + } + + public void EndPlaying() + { + isPlaying = false; + currentBeatmap = null; + + if (!isConnected) return; + + connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), currentState); + } + + public virtual void WatchUser(int userId) + { + if (watchingUsers.Contains(userId)) + return; + + watchingUsers.Add(userId); + + if (!isConnected) return; + + connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); + } + + public void StopWatchingUser(int userId) + { + watchingUsers.Remove(userId); + + if (!isConnected) return; + + connection.SendAsync(nameof(ISpectatorServer.EndWatchingUser), userId); + } + + private readonly Queue<LegacyReplayFrame> pendingFrames = new Queue<LegacyReplayFrame>(); + + 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(); + + SendFrames(new FrameDataBundle(frames)); + + lastSendTime = Time.Current; + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index ceaf0c3d5e..5119f262d5 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -18,6 +18,8 @@ using osu.Game.Screens.Menu; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Humanizer; +using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Development; @@ -25,24 +27,31 @@ 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.Platform; using osu.Framework.Threading; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input; using osu.Game.Overlays.Notifications; -using osu.Game.Screens.Play; using osu.Game.Input.Bindings; using osu.Game.Online.Chat; +using osu.Game.Overlays.Music; using osu.Game.Skinning; using osuTK.Graphics; using osu.Game.Overlays.Volume; +using osu.Game.Rulesets.Mods; using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; +using osu.Game.Updater; using osu.Game.Utils; using LogLevel = osu.Framework.Logging.LogLevel; +using osu.Game.Users; namespace osu.Game { @@ -58,11 +67,14 @@ namespace osu.Game private ChannelManager channelManager; - private NotificationOverlay notifications; + [NotNull] + private readonly NotificationOverlay notifications = new NotificationOverlay(); - private DirectOverlay direct; + private BeatmapListingOverlay beatmapListing; - private SocialOverlay social; + private DashboardOverlay dashboard; + + private NewsOverlay news; private UserProfileOverlay userProfile; @@ -71,23 +83,41 @@ namespace osu.Game [Cached] private readonly ScreenshotManager screenshotManager = new ScreenshotManager(); - protected RavenLogger RavenLogger; + protected SentryLogger SentryLogger; public virtual Storage GetStorageForStableInstall() => null; - public float ToolbarOffset => Toolbar.Position.Y + Toolbar.DrawHeight; + public float ToolbarOffset => (Toolbar?.Position.Y ?? 0) + (Toolbar?.DrawHeight ?? 0); private IdleTracker idleTracker; - public readonly Bindable<OverlayActivation> OverlayActivationMode = new Bindable<OverlayActivation>(); + /// <summary> + /// Whether overlays should be able to be opened game-wide. Value is sourced from the current active screen. + /// </summary> + public readonly IBindable<OverlayActivation> OverlayActivationMode = new Bindable<OverlayActivation>(); + + /// <summary> + /// Whether the local user is currently interacting with the game in a way that should not be interrupted. + /// </summary> + /// <remarks> + /// This is exclusively managed by <see cref="Player"/>. If other components are mutating this state, a more + /// resilient method should be used to ensure correct state. + /// </remarks> + public Bindable<bool> LocalUserPlaying = new BindableBool(); + + protected OsuScreenStack ScreenStack; + + protected BackButton BackButton; + + protected SettingsOverlay Settings; - private OsuScreenStack screenStack; private VolumeOverlay volume; private OsuLogo osuLogo; - private BackButton backButton; private MainMenu menuScreen; - private Intro introScreen; + + [CanBeNull] + private IntroScreen introScreen; private Bindable<int> configRuleset; @@ -95,12 +125,8 @@ namespace osu.Game private readonly string[] args; - private SettingsPanel settings; - private readonly List<OverlayContainer> overlays = new List<OverlayContainer>(); - private readonly List<OverlayContainer> toolbarElements = new List<OverlayContainer>(); - private readonly List<OverlayContainer> visibleBlockingOverlays = new List<OverlayContainer>(); public OsuGame(string[] args = null) @@ -109,7 +135,7 @@ namespace osu.Game forwardLoggedErrorsToNotifications(); - RavenLogger = new RavenLogger(this); + SentryLogger = new SentryLogger(this); } private void updateBlockingOverlayFade() => @@ -131,17 +157,13 @@ namespace osu.Game /// <summary> /// Close all game-wide overlays. /// </summary> - /// <param name="hideToolbarElements">Whether the toolbar (and accompanying controls) should also be hidden.</param> - public void CloseAllOverlays(bool hideToolbarElements = true) + /// <param name="hideToolbar">Whether the toolbar should also be hidden.</param> + public void CloseAllOverlays(bool hideToolbar = true) { foreach (var overlay in overlays) overlay.Hide(); - if (hideToolbarElements) - { - foreach (var overlay in toolbarElements) - overlay.Hide(); - } + if (hideToolbar) Toolbar.Hide(); } private DependencyContainer dependencies; @@ -150,10 +172,8 @@ namespace osu.Game dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); [BackgroundDependencyLoader] - private void load(FrameworkConfigManager frameworkConfig) + private void load() { - this.frameworkConfig = frameworkConfig; - if (!Host.IsPrimaryInstance && !DebugUtils.IsDebugBuild) { Logger.Log(@"osu! does not support multiple running instances.", LoggingTarget.Runtime, LogLevel.Error); @@ -162,14 +182,14 @@ namespace osu.Game if (args?.Length > 0) { - var paths = args.Where(a => !a.StartsWith(@"-")).ToArray(); + var paths = args.Where(a => !a.StartsWith('-')).ToArray(); if (paths.Length > 0) Task.Run(() => Import(paths)); } dependencies.CacheAs(this); - dependencies.Cache(RavenLogger); + dependencies.Cache(SentryLogger); dependencies.Cache(osuLogo = new OsuLogo { Alpha = 0 }); @@ -181,51 +201,149 @@ namespace osu.Game // bind config int to database SkinInfo configSkin = LocalConfig.GetBindable<int>(OsuSetting.Skin); SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID; - configSkin.ValueChanged += skinId => SkinManager.CurrentSkinInfo.Value = SkinManager.Query(s => s.ID == skinId.NewValue) ?? SkinInfo.Default; + configSkin.ValueChanged += skinId => + { + var skinInfo = SkinManager.Query(s => s.ID == skinId.NewValue); + + if (skinInfo == null) + { + switch (skinId.NewValue) + { + case -1: + skinInfo = DefaultLegacySkin.Info; + break; + + default: + skinInfo = SkinInfo.Default; + break; + } + } + + SkinManager.CurrentSkinInfo.Value = skinInfo; + }; configSkin.TriggerChange(); IsActive.BindValueChanged(active => updateActiveState(active.NewValue), true); Audio.AddAdjustment(AdjustableProperty.Volume, inactiveVolumeFade); + SelectedMods.BindValueChanged(modsChanged); Beatmap.BindValueChanged(beatmapChanged, true); } private ExternalLinkOpener externalLinkOpener; - public void OpenUrlExternally(string url) + /// <summary> + /// Handle an arbitrary URL. Displays via in-game overlays where possible. + /// This can be called from a non-thread-safe non-game-loaded state. + /// </summary> + /// <param name="url">The URL to load.</param> + public void HandleLink(string url) => HandleLink(MessageFormatter.GetLinkDetails(url)); + + /// <summary> + /// Handle a specific <see cref="LinkDetails"/>. + /// This can be called from a non-thread-safe non-game-loaded state. + /// </summary> + /// <param name="link">The link to load.</param> + public void HandleLink(LinkDetails link) => Schedule(() => { - if (url.StartsWith("/")) + switch (link.Action) + { + 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)) + ShowBeatmap(beatmapId); + break; + + case LinkAction.OpenBeatmapSet: + if (int.TryParse(link.Argument, out int setId)) + ShowBeatmapSet(setId); + break; + + case LinkAction.OpenChannel: + ShowChannel(link.Argument); + break; + + case LinkAction.OpenEditorTimestamp: + case LinkAction.JoinMultiplayerMatch: + case LinkAction.Spectate: + waitForReady(() => notifications, _ => notifications.Post(new SimpleNotification + { + Text = @"This link type is not yet supported!", + Icon = FontAwesome.Solid.LifeRing, + })); + break; + + case LinkAction.External: + OpenUrlExternally(link.Argument); + break; + + case LinkAction.OpenUserProfile: + if (int.TryParse(link.Argument, out int userId)) + ShowUser(userId); + break; + + default: + throw new NotImplementedException($"This {nameof(LinkAction)} ({link.Action.ToString()}) is missing an associated action."); + } + }); + + public void OpenUrlExternally(string url) => waitForReady(() => externalLinkOpener, _ => + { + if (url.StartsWith('/')) url = $"{API.Endpoint}{url}"; externalLinkOpener.OpenUrlExternally(url); - } + }); + + /// <summary> + /// Open a specific channel in chat. + /// </summary> + /// <param name="channel">The channel to display.</param> + public void ShowChannel(string channel) => waitForReady(() => channelManager, _ => + { + try + { + channelManager.OpenChannel(channel); + } + catch (ChannelNotFoundException) + { + Logger.Log($"The requested channel \"{channel}\" does not exist"); + } + }); /// <summary> /// Show a beatmap set as an overlay. /// </summary> /// <param name="setId">The set to display.</param> - public void ShowBeatmapSet(int setId) => beatmapSetOverlay.FetchAndShowBeatmapSet(setId); + public void ShowBeatmapSet(int setId) => waitForReady(() => beatmapSetOverlay, _ => beatmapSetOverlay.FetchAndShowBeatmapSet(setId)); /// <summary> /// Show a user's profile as an overlay. /// </summary> /// <param name="userId">The user to display.</param> - public void ShowUser(long userId) => userProfile.ShowUser(userId); + public void ShowUser(int userId) => waitForReady(() => userProfile, _ => userProfile.ShowUser(userId)); /// <summary> /// Show a beatmap's set as an overlay, displaying the given beatmap. /// </summary> /// <param name="beatmapId">The beatmap to show.</param> - public void ShowBeatmap(int beatmapId) => beatmapSetOverlay.FetchAndShowBeatmap(beatmapId); + public void ShowBeatmap(int beatmapId) => waitForReady(() => beatmapSetOverlay, _ => beatmapSetOverlay.FetchAndShowBeatmap(beatmapId)); /// <summary> /// Present a beatmap at song select immediately. /// The user should have already requested this interactively. /// </summary> /// <param name="beatmap">The beatmap to select.</param> - public void PresentBeatmap(BeatmapSetInfo beatmap) + /// <param name="difficultyCriteria"> + /// Optional predicate used to try and find a difficulty to select. + /// If omitted, this will try to present the first beatmap from the current ruleset. + /// In case of failure the first difficulty of the set will be presented, ignoring the predicate. + /// </param> + public void PresentBeatmap(BeatmapSetInfo beatmap, Predicate<BeatmapInfo> difficultyCriteria = null) { + difficultyCriteria ??= b => b.Ruleset.Equals(Ruleset.Value); + var databasedSet = beatmap.OnlineBeatmapSetID != null ? BeatmapManager.QueryBeatmapSet(s => s.OnlineBeatmapSetID == beatmap.OnlineBeatmapSetID) : BeatmapManager.QueryBeatmapSet(s => s.Hash == beatmap.Hash); @@ -236,35 +354,44 @@ namespace osu.Game return; } - performFromMainMenu(() => + PerformFromScreen(screen => { // we might already be at song select, so a check is required before performing the load to solo. - if (menuScreen.IsCurrentScreen()) + if (screen is MainMenu) menuScreen.LoadToSolo(); // we might even already be at the song - if (Beatmap.Value.BeatmapSetInfo.Hash == databasedSet.Hash) + if (Beatmap.Value.BeatmapSetInfo.Hash == databasedSet.Hash && difficultyCriteria(Beatmap.Value.BeatmapInfo)) { return; } - // Use first beatmap available for current ruleset, else switch ruleset. - var first = databasedSet.Beatmaps.Find(b => b.Ruleset.Equals(Ruleset.Value)) ?? databasedSet.Beatmaps.First(); + // Find first beatmap that matches our predicate. + var first = databasedSet.Beatmaps.Find(difficultyCriteria) ?? databasedSet.Beatmaps.First(); Ruleset.Value = first.Ruleset; Beatmap.Value = BeatmapManager.GetWorkingBeatmap(first); - }, $"load {beatmap}", bypassScreenAllowChecks: true, targetScreen: typeof(PlaySongSelect)); + }, validScreens: new[] { typeof(PlaySongSelect) }); } /// <summary> /// Present a score's replay immediately. /// The user should have already requested this interactively. /// </summary> - public void PresentScore(ScoreInfo score) + public void PresentScore(ScoreInfo score, ScorePresentType presentType = ScorePresentType.Results) { // The given ScoreInfo may have missing properties if it was retrieved from online data. Re-retrieve it from the database // to ensure all the required data for presenting a replay are present. - var databasedScoreInfo = ScoreManager.Query(s => s.OnlineScoreID == score.OnlineScoreID); + var databasedScoreInfo = score.OnlineScoreID != null + ? ScoreManager.Query(s => s.OnlineScoreID == score.OnlineScoreID) + : ScoreManager.Query(s => s.Hash == score.Hash); + + if (databasedScoreInfo == null) + { + Logger.Log("The requested score could not be found locally.", LoggingTarget.Information); + return; + } + var databasedScore = ScoreManager.GetScore(databasedScoreInfo); if (databasedScore.Replay == null) @@ -281,31 +408,57 @@ namespace osu.Game return; } - performFromMainMenu(() => + PerformFromScreen(screen => { + Ruleset.Value = databasedScore.ScoreInfo.Ruleset; Beatmap.Value = BeatmapManager.GetWorkingBeatmap(databasedBeatmap); - menuScreen.Push(new ReplayPlayerLoader(databasedScore)); - }, $"watch {databasedScoreInfo}", bypassScreenAllowChecks: true); + switch (presentType) + { + case ScorePresentType.Gameplay: + screen.Push(new ReplayPlayerLoader(databasedScore)); + break; + + case ScorePresentType.Results: + screen.Push(new SoloResultsScreen(databasedScore.ScoreInfo)); + break; + } + }, validScreens: new[] { typeof(PlaySongSelect) }); } - #region Beatmap jukebox progression + protected virtual Loader CreateLoader() => new Loader(); + + protected virtual UpdateManager CreateUpdateManager() => new UpdateManager(); + + protected override Container CreateScalingContainer() => new ScalingContainer(ScalingMode.Everything); + + #region Beatmap progression private void beatmapChanged(ValueChangedEvent<WorkingBeatmap> beatmap) { - var nextBeatmap = beatmap.NewValue; - if (nextBeatmap?.Track != null) - nextBeatmap.Track.Completed += currentTrackCompleted; + beatmap.OldValue?.CancelAsyncLoad(); - beatmap.OldValue?.Dispose(); + updateModDefaults(); - nextBeatmap?.LoadBeatmapAsync(); + beatmap.NewValue?.BeginAsyncLoad(); } - private void currentTrackCompleted() + private void modsChanged(ValueChangedEvent<IReadOnlyList<Mod>> mods) { - if (!Beatmap.Value.Track.Looping && !Beatmap.Disabled) - musicController.NextTrack(); + updateModDefaults(); + } + + private void updateModDefaults() + { + BeatmapDifficulty baseDifficulty = Beatmap.Value.BeatmapInfo.BaseDifficulty; + + if (baseDifficulty != null && SelectedMods.Value.Any(m => m is IApplicableToDifficulty)) + { + var adjustedDifficulty = baseDifficulty.Clone(); + + foreach (var mod in SelectedMods.Value.OfType<IApplicableToDifficulty>()) + mod.ReadFromDifficulty(adjustedDifficulty); + } } #endregion @@ -313,59 +466,65 @@ namespace osu.Game private ScheduledDelegate performFromMainMenuTask; /// <summary> - /// Perform an action only after returning to the main menu. + /// Perform an action only after returning to a specific screen as indicated by <paramref name="validScreens"/>. /// Eagerly tries to exit the current screen until it succeeds. /// </summary> /// <param name="action">The action to perform once we are in the correct state.</param> - /// <param name="taskName">The task name to display in a notification (if we can't immediately reach the main menu state).</param> - /// <param name="targetScreen">An optional target screen type. If this screen is already current we can immediately perform the action without returning to the menu.</param> - /// <param name="bypassScreenAllowChecks">Whether checking <see cref="IOsuScreen.AllowExternalScreenChange"/> should be bypassed.</param> - private void performFromMainMenu(Action action, string taskName, Type targetScreen = null, bool bypassScreenAllowChecks = false) + /// <param name="validScreens">An optional collection of valid screen types. If any of these screens are already current we can perform the action immediately, else the first valid parent will be made current before performing the action. <see cref="MainMenu"/> is used if not specified.</param> + public void PerformFromScreen(Action<IScreen> action, IEnumerable<Type> validScreens = null) { performFromMainMenuTask?.Cancel(); - // if the current screen does not allow screen changing, give the user an option to try again later. - if (!bypassScreenAllowChecks && (screenStack.CurrentScreen as IOsuScreen)?.AllowExternalScreenChange == false) - { - notifications.Post(new SimpleNotification - { - Text = $"Click here to {taskName}", - Activated = () => - { - performFromMainMenu(action, taskName, targetScreen, true); - return true; - } - }); - - return; - } + validScreens ??= Enumerable.Empty<Type>(); + validScreens = validScreens.Append(typeof(MainMenu)); CloseAllOverlays(false); // we may already be at the target screen type. - if (targetScreen != null && screenStack.CurrentScreen?.GetType() == targetScreen) + if (validScreens.Contains(ScreenStack.CurrentScreen?.GetType()) && !Beatmap.Disabled) { - action(); + action(ScreenStack.CurrentScreen); return; } - // all conditions have been met to continue with the action. - if (menuScreen?.IsCurrentScreen() == true && !Beatmap.Disabled) + // find closest valid target + IScreen screen = ScreenStack.CurrentScreen; + + while (screen != null) { - action(); - return; + if (validScreens.Contains(screen.GetType())) + { + screen.MakeCurrent(); + break; + } + + screen = screen.GetParentScreen(); } - // menuScreen may not be initialised yet (null check required). - menuScreen?.MakeCurrent(); + performFromMainMenuTask = Schedule(() => PerformFromScreen(action, validScreens)); + } - performFromMainMenuTask = Schedule(() => performFromMainMenu(action, taskName)); + /// <summary> + /// Wait for the game (and target component) to become loaded and then run an action. + /// </summary> + /// <param name="retrieveInstance">A function to retrieve a (potentially not-yet-constructed) target instance.</param> + /// <param name="action">The action to perform on the instance when load is confirmed.</param> + /// <typeparam name="T">The type of the target instance.</typeparam> + private void waitForReady<T>(Func<T> retrieveInstance, Action<T> action) + where T : Drawable + { + var instance = retrieveInstance(); + + if (ScreenStack == null || ScreenStack.CurrentScreen is StartupScreen || instance?.IsLoaded != true) + Schedule(() => waitForReady(retrieveInstance, action)); + else + action(instance); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - RavenLogger.Dispose(); + SentryLogger.Dispose(); } protected override void LoadComplete() @@ -378,18 +537,19 @@ namespace osu.Game MenuCursorContainer.CanShowCursor = menuScreen?.CursorVisible ?? false; // todo: all archive managers should be able to be looped here. - SkinManager.PostNotification = n => notifications?.Post(n); + SkinManager.PostNotification = n => notifications.Post(n); SkinManager.GetStableStorage = GetStorageForStableInstall; - BeatmapManager.PostNotification = n => notifications?.Post(n); + BeatmapManager.PostNotification = n => notifications.Post(n); BeatmapManager.GetStableStorage = GetStorageForStableInstall; BeatmapManager.PresentImport = items => PresentBeatmap(items.First()); - ScoreManager.PostNotification = n => notifications?.Post(n); + ScoreManager.PostNotification = n => notifications.Post(n); ScoreManager.GetStableStorage = GetStorageForStableInstall; ScoreManager.PresentImport = items => PresentScore(items.First()); Container logoContainer; + BackButton.Receptor receptor; dependencies.CacheAs(idleTracker = new GameIdleTracker(6000)); @@ -406,15 +566,18 @@ namespace osu.Game RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - screenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, - backButton = new BackButton + receptor = new BackButton.Receptor(), + ScreenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, + BackButton = new BackButton(receptor) { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Action = () => { - if ((screenStack.CurrentScreen as IOsuScreen)?.AllowBackButton == true) - screenStack.Exit(); + var currentScreen = ScreenStack.CurrentScreen as IOsuScreen; + + if (currentScreen?.AllowBackButton == true && !currentScreen.OnBackButton()) + ScreenStack.Exit(); } }, logoContainer = new Container { RelativeSizeAxes = Axes.Both }, @@ -424,21 +587,19 @@ namespace osu.Game rightFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, leftFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, topMostOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, - idleTracker + idleTracker, + new ConfineMouseTracker() }); - screenStack.ScreenPushed += screenPushed; - screenStack.ScreenExited += screenExited; + ScreenStack.ScreenPushed += screenPushed; + ScreenStack.ScreenExited += screenExited; loadComponentSingleFile(osuLogo, logo => { logoContainer.Add(logo); // Loader has to be created after the logo has finished loading as Loader performs logo transformations on entering. - screenStack.Push(new Loader - { - RelativeSizeAxes = Axes.Both - }); + ScreenStack.Push(CreateLoader().With(l => l.RelativeSizeAxes = Axes.Both)); }); loadComponentSingleFile(Toolbar = new Toolbar @@ -448,30 +609,39 @@ namespace osu.Game CloseAllOverlays(false); menuScreen?.MakeCurrent(); }, - }, d => - { - topMostOverlayContent.Add(d); - toolbarElements.Add(d); - }); + }, topMostOverlayContent.Add); + + loadComponentSingleFile(volume = new VolumeOverlay(), leftFloatingOverlayContent.Add, true); - loadComponentSingleFile(volume = new VolumeOverlay(), leftFloatingOverlayContent.Add); loadComponentSingleFile(new OnScreenDisplay(), Add, true); - loadComponentSingleFile(notifications = new NotificationOverlay + loadComponentSingleFile(notifications.With(d => { - GetToolbarHeight = () => ToolbarOffset, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }, rightFloatingOverlayContent.Add, true); + d.GetToolbarHeight = () => ToolbarOffset; + d.Anchor = Anchor.TopRight; + d.Origin = Anchor.TopRight; + }), rightFloatingOverlayContent.Add, true); + + loadComponentSingleFile(new CollectionManager(Storage) + { + PostNotification = n => notifications.Post(n), + GetStableStorage = GetStorageForStableInstall + }, Add, true); loadComponentSingleFile(screenshotManager, Add); - //overlay elements - loadComponentSingleFile(direct = new DirectOverlay(), overlayContent.Add, true); - loadComponentSingleFile(social = new SocialOverlay(), overlayContent.Add, true); + // dependency on notification overlay, dependent by settings overlay + loadComponentSingleFile(CreateUpdateManager(), Add, true); + + // overlay elements + loadComponentSingleFile(new ManageCollectionsDialog(), overlayContent.Add, true); + loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true); + loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true); + loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true); + var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true); loadComponentSingleFile(channelManager = new ChannelManager(), AddInternal, true); loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true); - loadComponentSingleFile(settings = new SettingsOverlay { GetToolbarHeight = () => ToolbarOffset }, leftFloatingOverlayContent.Add, true); + loadComponentSingleFile(Settings = new SettingsOverlay { GetToolbarHeight = () => ToolbarOffset }, leftFloatingOverlayContent.Add, true); var changelogOverlay = loadComponentSingleFile(new ChangelogOverlay(), overlayContent.Add, true); loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); @@ -483,16 +653,12 @@ namespace osu.Game Origin = Anchor.TopRight, }, rightFloatingOverlayContent.Add, true); - loadComponentSingleFile(musicController = new MusicController + loadComponentSingleFile(new NowPlayingOverlay { GetToolbarHeight = () => ToolbarOffset, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - }, d => - { - rightFloatingOverlayContent.Add(d); - toolbarElements.Add(d); - }, true); + }, rightFloatingOverlayContent.Add, true); loadComponentSingleFile(new AccountCreationOverlay(), topMostOverlayContent.Add, true); loadComponentSingleFile(new DialogOverlay(), topMostOverlayContent.Add, true); @@ -501,9 +667,10 @@ namespace osu.Game chatOverlay.State.ValueChanged += state => channelManager.HighPollRate.Value = state.NewValue == Visibility.Visible; Add(externalLinkOpener = new ExternalLinkOpener()); + Add(new MusicKeyBindingHandler()); - var singleDisplaySideOverlays = new OverlayContainer[] { settings, notifications }; - overlays.AddRange(singleDisplaySideOverlays); + // side overlays which cancel each other. + var singleDisplaySideOverlays = new OverlayContainer[] { Settings, notifications }; foreach (var overlay in singleDisplaySideOverlays) { @@ -517,21 +684,18 @@ namespace osu.Game // eventually informational overlays should be displayed in a stack, but for now let's only allow one to stay open at a time. var informationalOverlays = new OverlayContainer[] { beatmapSetOverlay, userProfile }; - overlays.AddRange(informationalOverlays); foreach (var overlay in informationalOverlays) { overlay.State.ValueChanged += state => { - if (state.NewValue == Visibility.Hidden) return; - - informationalOverlays.Where(o => o != overlay).ForEach(o => o.Hide()); + if (state.NewValue != Visibility.Hidden) + showOverlayAboveOthers(overlay, informationalOverlays); }; } // ensure only one of these overlays are open at once. - var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, social, direct, changelogOverlay }; - overlays.AddRange(singleDisplayOverlays); + var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, news, dashboard, beatmapListing, changelogOverlay, rankingsOverlay }; foreach (var overlay in singleDisplayOverlays) { @@ -540,9 +704,8 @@ namespace osu.Game // informational overlays should be dismissed on a show or hide of a full overlay. informationalOverlays.ForEach(o => o.Hide()); - if (state.NewValue == Visibility.Hidden) return; - - singleDisplayOverlays.Where(o => o != overlay).ForEach(o => o.Hide()); + if (state.NewValue != Visibility.Hidden) + showOverlayAboveOthers(overlay, singleDisplayOverlays); }; } @@ -555,34 +718,25 @@ namespace osu.Game { float offset = 0; - if (settings.State.Value == Visibility.Visible) - offset += ToolbarButton.WIDTH / 2; + if (Settings.State.Value == Visibility.Visible) + offset += Toolbar.HEIGHT / 2; if (notifications.State.Value == Visibility.Visible) - offset -= ToolbarButton.WIDTH / 2; + offset -= Toolbar.HEIGHT / 2; screenContainer.MoveToX(offset, SettingsPanel.TRANSITION_LENGTH, Easing.OutQuint); } - settings.State.ValueChanged += _ => updateScreenOffset(); + Settings.State.ValueChanged += _ => updateScreenOffset(); notifications.State.ValueChanged += _ => updateScreenOffset(); } - public class GameIdleTracker : IdleTracker + private void showOverlayAboveOthers(OverlayContainer overlay, OverlayContainer[] otherOverlays) { - private InputManager inputManager; + otherOverlays.Where(o => o != overlay).ForEach(o => o.Hide()); - public GameIdleTracker(int time) - : base(time) - { - } - - protected override void LoadComplete() - { - base.LoadComplete(); - inputManager = GetContainingInputManager(); - } - - protected override bool AllowIdle => inputManager.FocusedDrawable == null; + // show above others if not visible at all, else leave at current depth. + if (!overlay.IsPresent) + overlayContent.ChangeChildDepth(overlay, (float)-Clock.CurrentTime); } private void forwardLoggedErrorsToNotifications() @@ -602,7 +756,7 @@ namespace osu.Game Schedule(() => notifications.Post(new SimpleNotification { Icon = entry.Level == LogLevel.Important ? FontAwesome.Solid.ExclamationCircle : FontAwesome.Solid.Bomb, - Text = entry.Message + (entry.Exception != null && IsDeployedBuild ? "\n\nThis error has been automatically reported to the devs." : string.Empty), + Text = entry.Message.Truncate(256) + (entry.Exception != null && IsDeployedBuild ? "\n\nThis error has been automatically reported to the devs." : string.Empty), })); } else if (recentLogCount == short_term_display_limit) @@ -613,7 +767,7 @@ namespace osu.Game Text = "Subsequent messages have been logged. Click to view log files.", Activated = () => { - Host.Storage.GetStorageForDirectory("logs").OpenInNativeExplorer(); + Storage.GetStorageForDirectory("logs").OpenInNativeExplorer(); return true; } })); @@ -626,11 +780,21 @@ namespace osu.Game private Task asyncLoadStream; - private T loadComponentSingleFile<T>(T d, Action<T> add, bool cache = false) + /// <summary> + /// Queues loading the provided component in sequential fashion. + /// This operation is limited to a single thread to avoid saturating all cores. + /// </summary> + /// <param name="component">The component to load.</param> + /// <param name="loadCompleteAction">An action to invoke on load completion (generally to add the component to the hierarchy).</param> + /// <param name="cache">Whether to cache the component as type <typeparamref name="T"/> into the game dependencies before any scheduling.</param> + private T loadComponentSingleFile<T>(T component, Action<T> loadCompleteAction, bool cache = false) where T : Drawable { if (cache) - dependencies.Cache(d); + dependencies.CacheAs(component); + + if (component is OverlayContainer overlay) + overlays.Add(overlay); // schedule is here to ensure that all component loads are done after LoadComplete is run (and thus all dependencies are cached). // with some better organisation of LoadComplete to do construction and dependency caching in one step, followed by calls to loadComponentSingleFile, @@ -639,7 +803,7 @@ namespace osu.Game { var previousLoadStream = asyncLoadStream; - //chain with existing load stream + // chain with existing load stream asyncLoadStream = Task.Run(async () => { if (previousLoadStream != null) @@ -647,12 +811,12 @@ namespace osu.Game try { - Logger.Log($"Loading {d}...", level: LogLevel.Debug); + Logger.Log($"Loading {component}...", level: LogLevel.Debug); // Since this is running in a separate thread, it is possible for OsuGame to be disposed after LoadComponentAsync has been called // throwing an exception. To avoid this, the call is scheduled on the update thread, which does not run if IsDisposed = true Task task = null; - var del = new ScheduledDelegate(() => task = LoadComponentAsync(d, add)); + var del = new ScheduledDelegate(() => task = LoadComponentAsync(component, loadCompleteAction)); Scheduler.Add(del); // The delegate won't complete if OsuGame has been disposed in the meantime @@ -667,7 +831,7 @@ namespace osu.Game await task; - Logger.Log($"Loaded {d}!", level: LogLevel.Debug); + Logger.Log($"Loaded {component}!", level: LogLevel.Debug); } catch (OperationCanceledException) { @@ -675,7 +839,14 @@ namespace osu.Game }); }); - return d; + 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) @@ -684,14 +855,6 @@ namespace osu.Game switch (action) { - case GlobalAction.ToggleChat: - chatOverlay.ToggleVisibility(); - return true; - - case GlobalAction.ToggleSocial: - social.ToggleVisibility(); - return true; - case GlobalAction.ResetInputSettings: var sensitivity = frameworkConfig.GetBindable<double>(FrameworkSetting.CursorSensitivity); @@ -707,14 +870,6 @@ namespace osu.Game Toolbar.ToggleVisibility(); return true; - case GlobalAction.ToggleSettings: - settings.ToggleVisibility(); - return true; - - case GlobalAction.ToggleDirect: - direct.ToggleVisibility(); - return true; - case GlobalAction.ToggleGameplayMouseButtons: LocalConfig.Set(OsuSetting.MouseDisableButtons, !LocalConfig.Get<bool>(OsuSetting.MouseDisableButtons)); return true; @@ -737,7 +892,9 @@ namespace osu.Game #endregion - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } private Container overlayContent; @@ -747,21 +904,17 @@ namespace osu.Game private Container topMostOverlayContent; - private FrameworkConfigManager frameworkConfig; + [Resolved] + private FrameworkConfigManager frameworkConfig { get; set; } private ScalingContainer screenContainer; - private MusicController musicController; - protected override bool OnExiting() { - if (screenStack.CurrentScreen is Loader) + if (ScreenStack.CurrentScreen is Loader) return false; - if (introScreen == null) - return true; - - if (!introScreen.DidLoadMenu || !(screenStack.CurrentScreen is Intro)) + if (introScreen?.DidLoadMenu == true && !(ScreenStack.CurrentScreen is IntroScreen)) { Scheduler.Add(introScreen.MakeCurrent); return true; @@ -789,14 +942,14 @@ namespace osu.Game screenContainer.Padding = new MarginPadding { Top = ToolbarOffset }; overlayContent.Padding = new MarginPadding { Top = ToolbarOffset }; - MenuCursorContainer.CanShowCursor = (screenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; + MenuCursorContainer.CanShowCursor = (ScreenStack.CurrentScreen as IOsuScreen)?.CursorVisible ?? false; } protected virtual void ScreenChanged(IScreen current, IScreen newScreen) { switch (newScreen) { - case Intro intro: + case IntroScreen intro: introScreen = intro; break; @@ -805,9 +958,21 @@ namespace osu.Game break; } + // reset on screen change for sanity. + LocalUserPlaying.Value = false; + + if (current is IOsuScreen currentOsuScreen) + { + OverlayActivationMode.UnbindFrom(currentOsuScreen.OverlayActivationMode); + API.Activity.UnbindFrom(currentOsuScreen.Activity); + } + if (newScreen is IOsuScreen newOsuScreen) { - OverlayActivationMode.Value = newOsuScreen.InitialOverlayActivationMode; + OverlayActivationMode.BindTo(newOsuScreen.OverlayActivationMode); + ((IBindable<UserActivity>)API.Activity).BindTo(newOsuScreen.Activity); + + MusicController.AllowRateAdjustments = newOsuScreen.AllowRateAdjustments; if (newOsuScreen.HideOverlaysOnEnter) CloseAllOverlays(); @@ -815,9 +980,9 @@ namespace osu.Game Toolbar.Show(); if (newOsuScreen.AllowBackButton) - backButton.Show(); + BackButton.Show(); else - backButton.Hide(); + BackButton.Hide(); } } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 076c9ada78..193f6fe61b 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Development; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.IO.Stores; @@ -26,15 +27,18 @@ using osu.Framework.Input; using osu.Framework.Logging; using osu.Game.Audio; using osu.Game.Database; -using osu.Game.Graphics.Containers; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; +using osu.Game.Online.Spectator; +using osu.Game.Overlays; +using osu.Game.Resources; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Skinning; using osuTK.Input; +using RuntimeInfo = osu.Framework.RuntimeInfo; namespace osu.Game { @@ -47,12 +51,18 @@ namespace osu.Game { public const string CLIENT_STREAM_NAME = "lazer"; + public const int SAMPLE_CONCURRENCY = 6; + protected OsuConfigManager LocalConfig; protected BeatmapManager BeatmapManager; protected ScoreManager ScoreManager; + protected BeatmapDifficultyCache DifficultyCache; + + protected UserLookupCache UserCache; + protected SkinManager SkinManager; protected RulesetStore RulesetStore; @@ -65,15 +75,19 @@ namespace osu.Game protected RulesetConfigCache RulesetConfigCache; - protected APIAccess API; + protected IAPIProvider API; + + private SpectatorStreamingClient spectatorStreaming; protected MenuCursorContainer MenuCursorContainer; + protected MusicController MusicController; + private Container content; protected override Container<Drawable> Content => content; - private Bindable<WorkingBeatmap> beatmap; // cached via load() method + protected Storage Storage { get; set; } [Cached] [Cached(typeof(IBindable<RulesetInfo>))] @@ -81,18 +95,28 @@ namespace osu.Game // todo: move this to SongSelect once Screen has the ability to unsuspend. [Cached] - [Cached(Type = typeof(IBindable<IReadOnlyList<Mod>>))] - protected readonly Bindable<IReadOnlyList<Mod>> Mods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); + [Cached(typeof(IBindable<IReadOnlyList<Mod>>))] + protected readonly Bindable<IReadOnlyList<Mod>> SelectedMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); - protected Bindable<WorkingBeatmap> Beatmap => beatmap; + /// <summary> + /// Mods available for the current <see cref="Ruleset"/>. + /// </summary> + public readonly Bindable<Dictionary<ModType, IReadOnlyList<Mod>>> AvailableMods = new Bindable<Dictionary<ModType, IReadOnlyList<Mod>>>(); + + protected Bindable<WorkingBeatmap> Beatmap { get; private set; } // cached via load() method private Bindable<bool> fpsDisplayVisible; public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(); + /// <summary> + /// MD5 representation of the game executable. + /// </summary> + public string VersionHash { get; private set; } + public bool IsDeployedBuild => AssemblyVersion.Major > 0; - public string Version + public virtual string Version { get { @@ -121,9 +145,23 @@ namespace osu.Game [BackgroundDependencyLoader] private void load() { - Resources.AddStore(new DllResourceStore(@"osu.Game.Resources.dll")); + try + { + using (var str = File.OpenRead(typeof(OsuGameBase).Assembly.Location)) + VersionHash = str.ComputeMD5Hash(); + } + catch + { + // special case for android builds, which can't read DLLs from a packed apk. + // should eventually be handled in a better way. + VersionHash = $"{Version}-{RuntimeInfo.OS}".ComputeMD5Hash(); + } - dependencies.Cache(contextFactory = new DatabaseContextFactory(Host.Storage)); + Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly)); + + dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage)); + + dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore<byte[]>(Resources, @"Textures"))); largeStore.AddStore(Host.CreateTextureLoaderStore(new OnlineStore())); @@ -132,47 +170,42 @@ namespace osu.Game dependencies.CacheAs(this); dependencies.Cache(LocalConfig); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/osuFont")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-Medium")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-MediumItalic")); + AddFont(Resources, @"Fonts/osuFont"); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Noto-Basic")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Noto-Hangul")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Noto-CJK-Basic")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Noto-CJK-Compatibility")); + AddFont(Resources, @"Fonts/Torus-Regular"); + AddFont(Resources, @"Fonts/Torus-Light"); + AddFont(Resources, @"Fonts/Torus-SemiBold"); + AddFont(Resources, @"Fonts/Torus-Bold"); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-Regular")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-RegularItalic")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-SemiBold")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-SemiBoldItalic")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-Bold")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-BoldItalic")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-Light")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-LightItalic")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-Black")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Exo2.0-BlackItalic")); + AddFont(Resources, @"Fonts/Noto-Basic"); + AddFont(Resources, @"Fonts/Noto-Hangul"); + AddFont(Resources, @"Fonts/Noto-CJK-Basic"); + AddFont(Resources, @"Fonts/Noto-CJK-Compatibility"); + AddFont(Resources, @"Fonts/Noto-Thai"); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Venera")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Venera-Light")); - Fonts.AddStore(new GlyphStore(Resources, @"Fonts/Venera-Medium")); + AddFont(Resources, @"Fonts/Venera-Light"); + AddFont(Resources, @"Fonts/Venera-Bold"); + AddFont(Resources, @"Fonts/Venera-Black"); + + Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; runMigrations(); - dependencies.Cache(SkinManager = new SkinManager(Host.Storage, contextFactory, Host, Audio)); + dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Audio, new NamespacedResourceStore<byte[]>(Resources, "Skins/Legacy"))); dependencies.CacheAs<ISkinSource>(SkinManager); - API = new APIAccess(LocalConfig); + dependencies.CacheAs(API ??= new APIAccess(LocalConfig)); - dependencies.CacheAs<IAPIProvider>(API); + dependencies.CacheAs(spectatorStreaming = new SpectatorStreamingClient()); var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); - dependencies.Cache(RulesetStore = new RulesetStore(contextFactory)); - dependencies.Cache(FileStore = new FileStore(contextFactory, Host.Storage)); + dependencies.Cache(RulesetStore = new RulesetStore(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, Host.Storage, API, contextFactory, Host)); - dependencies.Cache(BeatmapManager = new BeatmapManager(Host.Storage, contextFactory, RulesetStore, API, Audio, Host, defaultBeatmap)); + 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)); // 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<T> interface to @@ -183,50 +216,98 @@ namespace osu.Game return ScoreManager.QueryScores(s => beatmapIds.Contains(s.Beatmap.ID)).ToList(); } - BeatmapManager.ItemRemoved += i => ScoreManager.Delete(getBeatmapScores(i), true); - BeatmapManager.ItemAdded += i => ScoreManager.Undelete(getBeatmapScores(i), true); + BeatmapManager.ItemRemoved.BindValueChanged(i => + { + if (i.NewValue.TryGetTarget(out var item)) + ScoreManager.Delete(getBeatmapScores(item), true); + }); + + BeatmapManager.ItemUpdated.BindValueChanged(i => + { + if (i.NewValue.TryGetTarget(out var item)) + ScoreManager.Undelete(getBeatmapScores(item), true); + }); + + dependencies.Cache(DifficultyCache = new BeatmapDifficultyCache()); + AddInternal(DifficultyCache); + + 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(new OsuColour()); - fileImporters.Add(BeatmapManager); - fileImporters.Add(ScoreManager); - fileImporters.Add(SkinManager); + RegisterImportHandler(BeatmapManager); + RegisterImportHandler(ScoreManager); + RegisterImportHandler(SkinManager); // tracks play so loud our samples can't keep up. // this adds a global reduction of track volume for the time being. Audio.Tracks.AddAdjustment(AdjustableProperty.Volume, new BindableDouble(0.8)); - beatmap = new OsuBindableBeatmap(defaultBeatmap); + Beatmap = new NonNullableBindable<WorkingBeatmap>(defaultBeatmap); - dependencies.CacheAs<IBindable<WorkingBeatmap>>(beatmap); - dependencies.CacheAs(beatmap); + dependencies.CacheAs<IBindable<WorkingBeatmap>>(Beatmap); + dependencies.CacheAs(Beatmap); FileStore.Cleanup(); - AddInternal(API); + // add api components to hierarchy. + if (API is APIAccess apiAccess) + AddInternal(apiAccess); + AddInternal(spectatorStreaming); - GlobalActionContainer globalBinding; + AddInternal(RulesetConfigCache); MenuCursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }; - MenuCursorContainer.Child = globalBinding = new GlobalActionContainer(this) + + GlobalActionContainer globalBindings; + + MenuCursorContainer.Child = globalBindings = new GlobalActionContainer(this) { RelativeSizeAxes = Axes.Both, Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both } }; - base.Content.Add(new ScalingContainer(ScalingMode.Everything) { Child = MenuCursorContainer }); + base.Content.Add(CreateScalingContainer().WithChild(MenuCursorContainer)); - KeyBindingStore.Register(globalBinding); - dependencies.Cache(globalBinding); + KeyBindingStore.Register(globalBindings); + dependencies.Cache(globalBindings); PreviewTrackManager previewTrackManager; dependencies.Cache(previewTrackManager = new PreviewTrackManager()); Add(previewTrackManager); + + AddInternal(MusicController = new MusicController()); + dependencies.CacheAs(MusicController); + + Ruleset.BindValueChanged(onRulesetChanged); } + private void onRulesetChanged(ValueChangedEvent<RulesetInfo> r) + { + var dict = new Dictionary<ModType, IReadOnlyList<Mod>>(); + + 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<Mod>(); + AvailableMods.Value = dict; + } + + protected virtual Container CreateScalingContainer() => new DrawSizePreservingFillContainer(); + protected override void LoadComplete() { base.LoadComplete(); @@ -265,35 +346,57 @@ namespace osu.Game public override void SetHost(GameHost host) { - if (LocalConfig == null) - LocalConfig = new OsuConfigManager(host.Storage); base.SetHost(host); + + // may be non-null for certain tests + Storage ??= host.Storage; + + LocalConfig ??= new OsuConfigManager(Storage); } + protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage); + private readonly List<ICanAcceptFiles> fileImporters = new List<ICanAcceptFiles>(); + /// <summary> + /// Register a global handler for file imports. Most recently registered will have precedence. + /// </summary> + /// <param name="handler">The handler to register.</param> + public void RegisterImportHandler(ICanAcceptFiles handler) => fileImporters.Insert(0, handler); + + /// <summary> + /// Unregister a global handler for file imports. + /// </summary> + /// <param name="handler">The previously registered handler.</param> + public void UnregisterImportHandler(ICanAcceptFiles handler) => fileImporters.Remove(handler); + public async Task Import(params string[] paths) { var extension = Path.GetExtension(paths.First())?.ToLowerInvariant(); foreach (var importer in fileImporters) + { if (importer.HandledExtensions.Contains(extension)) await importer.Import(paths); + } } - public string[] HandledExtensions => fileImporters.SelectMany(i => i.HandledExtensions).ToArray(); + public IEnumerable<string> HandledExtensions => fileImporters.SelectMany(i => i.HandledExtensions); - private class OsuBindableBeatmap : BindableBeatmap + protected override void Dispose(bool isDisposing) { - public OsuBindableBeatmap(WorkingBeatmap defaultValue) - : base(defaultValue) - { - } + base.Dispose(isDisposing); + + RulesetStore?.Dispose(); + BeatmapManager?.Dispose(); + LocalConfig?.Dispose(); + + contextFactory.FlushConnections(); } private class OsuUserInputManager : UserInputManager { - protected override MouseButtonEventManager CreateButtonManagerFor(MouseButton button) + protected override MouseButtonEventManager CreateButtonEventManagerFor(MouseButton button) { switch (button) { @@ -301,7 +404,7 @@ namespace osu.Game return new RightMouseManager(button); } - return base.CreateButtonManagerFor(button); + return base.CreateButtonEventManagerFor(button); } private class RightMouseManager : MouseButtonEventManager @@ -316,5 +419,11 @@ namespace osu.Game public override bool ChangeFocusOnClick => false; } } + + public void Migrate(string path) + { + contextFactory.FlushConnections(); + (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); + } } } diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index e136fc1403..a0b1b27ebf 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -9,7 +9,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Graphics; @@ -33,23 +33,26 @@ namespace osu.Game.Overlays.AccountCreation private OsuTextBox emailTextBox; private OsuPasswordTextBox passwordTextBox; - private IAPIProvider api; + [Resolved] + private IAPIProvider api { get; set; } + private ShakeContainer registerShake; private IEnumerable<Drawable> characterCheckText; private OsuTextBox[] textboxes; - private ProcessingOverlay processingOverlay; - private GameHost host; + private LoadingLayer loadingLayer; + + [Resolved] + private GameHost host { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours, IAPIProvider api, GameHost host) + private void load(OsuColour colours) { - this.api = api; - this.host = host; + FillFlowContainer mainContent; InternalChildren = new Drawable[] { - new FillFlowContainer + mainContent = new FillFlowContainer { RelativeSizeAxes = Axes.Both, Direction = FillDirection.Vertical, @@ -121,7 +124,7 @@ namespace osu.Game.Overlays.AccountCreation }, }, }, - processingOverlay = new ProcessingOverlay { Alpha = 0 } + loadingLayer = new LoadingLayer(mainContent) }; textboxes = new[] { usernameTextBox, emailTextBox, passwordTextBox }; @@ -129,7 +132,7 @@ namespace osu.Game.Overlays.AccountCreation usernameDescription.AddText("This will be your public presence. No profanity, no impersonation. Avoid exposing your own personal details, too!"); emailAddressDescription.AddText("Will be used for notifications, account verification and in the case you forget your password. No spam, ever."); - emailAddressDescription.AddText(" Make sure to get it right!", cp => cp.Font = cp.Font.With(Typeface.Exo, weight: FontWeight.Bold)); + emailAddressDescription.AddText(" Make sure to get it right!", cp => cp.Font = cp.Font.With(Typeface.Torus, weight: FontWeight.Bold)); passwordDescription.AddText("At least "); characterCheckText = passwordDescription.AddText("8 characters long"); @@ -138,18 +141,13 @@ namespace osu.Game.Overlays.AccountCreation passwordTextBox.Current.ValueChanged += password => { characterCheckText.ForEach(s => s.Colour = password.NewValue.Length == 0 ? Color4.White : Interpolation.ValueAt(password.NewValue.Length, Color4.OrangeRed, Color4.YellowGreen, 0, 8, Easing.In)); }; } - protected override void Update() - { - base.Update(); - - if (host?.OnScreenKeyboardOverlapsGameWindow != true && !textboxes.Any(t => t.HasFocus)) - focusNextTextbox(); - } - public override void OnEntering(IScreen last) { base.OnEntering(last); - processingOverlay.Hide(); + loadingLayer.Hide(); + + if (host?.OnScreenKeyboardOverlapsGameWindow != true) + focusNextTextbox(); } private void performRegistration() @@ -164,7 +162,7 @@ namespace osu.Game.Overlays.AccountCreation emailAddressDescription.ClearErrors(); passwordDescription.ClearErrors(); - processingOverlay.Show(); + loadingLayer.Show(); Task.Run(() => { @@ -197,11 +195,11 @@ namespace osu.Game.Overlays.AccountCreation } registerShake.Shake(); - processingOverlay.Hide(); + loadingLayer.Hide(); return; } - api.Login(emailTextBox.Text, passwordTextBox.Text); + api.Login(usernameTextBox.Text, passwordTextBox.Text); }); }); } diff --git a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs index be417f4aac..5375476c9e 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs @@ -22,7 +22,9 @@ namespace osu.Game.Overlays.AccountCreation { private OsuTextFlowContainer multiAccountExplanationText; private LinkFlowContainer furtherAssistance; - private IAPIProvider api; + + [Resolved(CanBeNull = true)] + private IAPIProvider api { get; set; } private const string help_centre_url = "/help/wiki/Help_Centre#login"; @@ -39,10 +41,8 @@ namespace osu.Game.Overlays.AccountCreation } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, IAPIProvider api, OsuGame game, TextureStore textures) + private void load(OsuColour colours, OsuGame game, TextureStore textures) { - this.api = api; - if (string.IsNullOrEmpty(api.ProvidedUsername)) return; @@ -121,7 +121,7 @@ namespace osu.Game.Overlays.AccountCreation multiAccountExplanationText.AddText("? osu! has a policy of "); multiAccountExplanationText.AddText("one account per person!", cp => cp.Colour = colours.Yellow); multiAccountExplanationText.AddText(" Please be aware that creating more than one account per person may result in "); - multiAccountExplanationText.AddText("permanent deactivation of accounts", cp => cp.Colour = colours.Yellow); + multiAccountExplanationText.AddText("permanent deactivation of accounts", cp => cp.Colour = colours.Yellow); multiAccountExplanationText.AddText("."); furtherAssistance.AddText("Need further assistance? Contact us via our "); diff --git a/osu.Game/Overlays/AccountCreationOverlay.cs b/osu.Game/Overlays/AccountCreationOverlay.cs index 89d8cbde11..58ede5502a 100644 --- a/osu.Game/Overlays/AccountCreationOverlay.cs +++ b/osu.Game/Overlays/AccountCreationOverlay.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.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -17,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public class AccountCreationOverlay : OsuFocusedOverlayContainer, IOnlineComponent + public class AccountCreationOverlay : OsuFocusedOverlayContainer { private const float transition_time = 400; @@ -30,10 +31,13 @@ namespace osu.Game.Overlays Origin = Anchor.Centre; } + private readonly IBindable<APIState> apiState = new Bindable<APIState>(); + [BackgroundDependencyLoader] private void load(OsuColour colours, IAPIProvider api) { - api.Register(this); + apiState.BindTo(api.State); + apiState.BindValueChanged(apiStateChanged, true); Children = new Drawable[] { @@ -97,9 +101,9 @@ namespace osu.Game.Overlays this.FadeOut(100); } - public void APIStateChanged(IAPIProvider api, APIState state) + private void apiStateChanged(ValueChangedEvent<APIState> state) => Schedule(() => { - switch (state) + switch (state.NewValue) { case APIState.Offline: case APIState.Failing: @@ -112,6 +116,6 @@ namespace osu.Game.Overlays Hide(); break; } - } + }); } } diff --git a/osu.Game/Overlays/Direct/BeatmapDownloadTrackingComposite.cs b/osu.Game/Overlays/BeatmapDownloadTrackingComposite.cs similarity index 94% rename from osu.Game/Overlays/Direct/BeatmapDownloadTrackingComposite.cs rename to osu.Game/Overlays/BeatmapDownloadTrackingComposite.cs index fd04a1541e..f6b5b181c3 100644 --- a/osu.Game/Overlays/Direct/BeatmapDownloadTrackingComposite.cs +++ b/osu.Game/Overlays/BeatmapDownloadTrackingComposite.cs @@ -5,7 +5,7 @@ using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Online; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays { public abstract class BeatmapDownloadTrackingComposite : DownloadTrackingComposite<BeatmapSetInfo, BeatmapManager> { diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs new file mode 100644 index 0000000000..d991dcfcfb --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -0,0 +1,237 @@ +// 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. + +using System; +using System.Collections.Generic; +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.Graphics.Shapes; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapListingFilterControl : CompositeDrawable + { + /// <summary> + /// Fired when a search finishes. Contains only new items in the case of pagination. + /// </summary> + public Action<List<BeatmapSetInfo>> SearchFinished; + + /// <summary> + /// Fired when search criteria change. + /// </summary> + public Action SearchStarted; + + /// <summary> + /// Any time the search text box receives key events (even while masked). + /// </summary> + public Action TypingStarted; + + /// <summary> + /// True when pagination has reached the end of available results. + /// </summary> + private bool noMoreResults; + + /// <summary> + /// The current page fetched of results (zero index). + /// </summary> + public int CurrentPage { get; private set; } + + private readonly BeatmapListingSearchControl searchControl; + private readonly BeatmapListingSortTabControl sortControl; + private readonly Box sortControlBackground; + + private ScheduledDelegate queryChangedDebounce; + + private SearchBeatmapSetsRequest getSetsRequest; + private SearchBeatmapSetsResponse lastResponse; + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } + + public BeatmapListingFilterControl() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.25f), + Type = EdgeEffectType.Shadow, + Radius = 3, + Offset = new Vector2(0f, 1f), + }, + Child = searchControl = new BeatmapListingSearchControl + { + TypingStarted = () => TypingStarted?.Invoke() + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 40, + Children = new Drawable[] + { + sortControlBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + sortControl = new BeatmapListingSortTabControl + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 20 } + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + sortControlBackground.Colour = colourProvider.Background5; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var sortCriteria = sortControl.Current; + var sortDirection = sortControl.SortDirection; + + searchControl.Query.BindValueChanged(query => + { + sortCriteria.Value = string.IsNullOrEmpty(query.NewValue) ? SortCriteria.Ranked : SortCriteria.Relevance; + sortDirection.Value = SortDirection.Descending; + queueUpdateSearch(true); + }); + + searchControl.Ruleset.BindValueChanged(_ => queueUpdateSearch()); + searchControl.Category.BindValueChanged(_ => queueUpdateSearch()); + searchControl.Genre.BindValueChanged(_ => queueUpdateSearch()); + searchControl.Language.BindValueChanged(_ => queueUpdateSearch()); + searchControl.Extra.CollectionChanged += (_, __) => queueUpdateSearch(); + searchControl.Ranks.CollectionChanged += (_, __) => queueUpdateSearch(); + searchControl.Played.BindValueChanged(_ => queueUpdateSearch()); + + sortCriteria.BindValueChanged(_ => queueUpdateSearch()); + sortDirection.BindValueChanged(_ => queueUpdateSearch()); + } + + public void TakeFocus() => searchControl.TakeFocus(); + + /// <summary> + /// Fetch the next page of results. May result in a no-op if a fetch is already in progress, or if there are no results left. + /// </summary> + public void FetchNextPage() + { + // there may be no results left. + if (noMoreResults) + return; + + // there may already be an active request. + if (getSetsRequest != null) + return; + + if (lastResponse != null) + CurrentPage++; + + performRequest(); + } + + private void queueUpdateSearch(bool queryTextChanged = false) + { + SearchStarted?.Invoke(); + + resetSearch(); + + queryChangedDebounce = Scheduler.AddDelayed(() => + { + resetSearch(); + FetchNextPage(); + }, queryTextChanged ? 500 : 100); + } + + private void performRequest() + { + getSetsRequest = new SearchBeatmapSetsRequest( + searchControl.Query.Value, + searchControl.Ruleset.Value, + lastResponse?.Cursor, + searchControl.Category.Value, + sortControl.Current.Value, + sortControl.SortDirection.Value, + searchControl.Genre.Value, + searchControl.Language.Value, + searchControl.Extra, + searchControl.Ranks, + searchControl.Played.Value); + + getSetsRequest.Success += response => + { + var sets = response.BeatmapSets.Select(responseJson => responseJson.ToBeatmapSet(rulesets)).ToList(); + + if (sets.Count == 0) + noMoreResults = true; + + if (CurrentPage == 0) + searchControl.BeatmapSet = sets.FirstOrDefault(); + + lastResponse = response; + getSetsRequest = null; + + SearchFinished?.Invoke(sets); + }; + + api.Queue(getSetsRequest); + } + + private void resetSearch() + { + noMoreResults = false; + CurrentPage = 0; + + lastResponse = null; + + getSetsRequest?.Cancel(); + getSetsRequest = null; + + queryChangedDebounce?.Cancel(); + } + + protected override void Dispose(bool isDisposing) + { + resetSearch(); + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs new file mode 100644 index 0000000000..6a9a71210a --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs @@ -0,0 +1,20 @@ +// 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. + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapListingHeader : OverlayHeader + { + protected override OverlayTitle CreateTitle() => new BeatmapListingTitle(); + + private class BeatmapListingTitle : OverlayTitle + { + public BeatmapListingTitle() + { + Title = "beatmap listing"; + Description = "browse for new beatmaps"; + IconTexture = "Icons/Hexacons/beatmap"; + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs new file mode 100644 index 0000000000..e232bf045f --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -0,0 +1,171 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK; +using osu.Framework.Bindables; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osuTK.Graphics; +using osu.Game.Rulesets; +using osu.Game.Scoring; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapListingSearchControl : CompositeDrawable + { + /// <summary> + /// Any time the text box receives key events (even while masked). + /// </summary> + public Action TypingStarted; + + public Bindable<string> Query => textBox.Current; + + public Bindable<RulesetInfo> Ruleset => modeFilter.Current; + + public Bindable<SearchCategory> Category => categoryFilter.Current; + + public Bindable<SearchGenre> Genre => genreFilter.Current; + + public Bindable<SearchLanguage> Language => languageFilter.Current; + + public BindableList<SearchExtra> Extra => extraFilter.Current; + + public BindableList<ScoreRank> Ranks => ranksFilter.Current; + + public Bindable<SearchPlayed> Played => playedFilter.Current; + + public BeatmapSetInfo BeatmapSet + { + set + { + if (value == null || string.IsNullOrEmpty(value.OnlineInfo.Covers.Cover)) + { + beatmapCover.FadeOut(600, Easing.OutQuint); + return; + } + + beatmapCover.BeatmapSet = value; + beatmapCover.FadeTo(0.1f, 200, Easing.OutQuint); + } + } + + private readonly BeatmapSearchTextBox textBox; + private readonly BeatmapSearchRulesetFilterRow modeFilter; + private readonly BeatmapSearchFilterRow<SearchCategory> categoryFilter; + private readonly BeatmapSearchFilterRow<SearchGenre> genreFilter; + private readonly BeatmapSearchFilterRow<SearchLanguage> languageFilter; + private readonly BeatmapSearchMultipleSelectionFilterRow<SearchExtra> extraFilter; + private readonly BeatmapSearchScoreFilterRow ranksFilter; + private readonly BeatmapSearchFilterRow<SearchPlayed> playedFilter; + + private readonly Box background; + private readonly UpdateableBeatmapSetCover beatmapCover; + + public BeatmapListingSearchControl() + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + AddRangeInternal(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Child = beatmapCover = new UpdateableBeatmapSetCover + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + } + }, + new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding + { + Vertical = 20, + Horizontal = 40, + }, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + textBox = new BeatmapSearchTextBox + { + RelativeSizeAxes = Axes.X, + TypingStarted = () => TypingStarted?.Invoke(), + }, + new ReverseChildIDFillFlowContainer<Drawable> + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Horizontal = 10 }, + Children = new Drawable[] + { + modeFilter = new BeatmapSearchRulesetFilterRow(), + categoryFilter = new BeatmapSearchFilterRow<SearchCategory>(@"Categories"), + genreFilter = new BeatmapSearchFilterRow<SearchGenre>(@"Genre"), + languageFilter = new BeatmapSearchFilterRow<SearchLanguage>(@"Language"), + extraFilter = new BeatmapSearchMultipleSelectionFilterRow<SearchExtra>(@"Extra"), + ranksFilter = new BeatmapSearchScoreFilterRow(), + playedFilter = new BeatmapSearchFilterRow<SearchPlayed>(@"Played") + } + } + } + } + } + }); + + categoryFilter.Current.Value = SearchCategory.Leaderboard; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Dark6; + } + + public void TakeFocus() => textBox.TakeFocus(); + + private class BeatmapSearchTextBox : SearchTextBox + { + /// <summary> + /// Any time the text box receives key events (even while masked). + /// </summary> + public Action TypingStarted; + + protected override Color4 SelectionColour => Color4.Gray; + + public BeatmapSearchTextBox() + { + PlaceholderText = @"type in keywords..."; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (!base.OnKeyDown(e)) + return false; + + TypingStarted?.Invoke(); + return true; + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs new file mode 100644 index 0000000000..4c77a736ac --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSortTabControl.cs @@ -0,0 +1,107 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Graphics; +using osuTK.Graphics; +using osuTK; +using osu.Framework.Input.Events; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapListingSortTabControl : OverlaySortTabControl<SortCriteria> + { + public readonly Bindable<SortDirection> SortDirection = new Bindable<SortDirection>(Overlays.SortDirection.Descending); + + public BeatmapListingSortTabControl() + { + Current.Value = SortCriteria.Ranked; + } + + protected override SortTabControl CreateControl() => new BeatmapSortTabControl + { + SortDirection = { BindTarget = SortDirection } + }; + + private class BeatmapSortTabControl : SortTabControl + { + public readonly Bindable<SortDirection> SortDirection = new Bindable<SortDirection>(); + + protected override TabItem<SortCriteria> CreateTabItem(SortCriteria value) => new BeatmapSortTabItem(value) + { + SortDirection = { BindTarget = SortDirection } + }; + } + + private class BeatmapSortTabItem : SortTabItem + { + public readonly Bindable<SortDirection> SortDirection = new Bindable<SortDirection>(); + + public BeatmapSortTabItem(SortCriteria value) + : base(value) + { + } + + protected override TabButton CreateTabButton(SortCriteria value) => new BeatmapTabButton(value) + { + Active = { BindTarget = Active }, + SortDirection = { BindTarget = SortDirection } + }; + } + + private class BeatmapTabButton : TabButton + { + public readonly Bindable<SortDirection> SortDirection = new Bindable<SortDirection>(); + + protected override Color4 ContentColour + { + set + { + base.ContentColour = value; + icon.Colour = value; + } + } + + private readonly SpriteIcon icon; + + public BeatmapTabButton(SortCriteria value) + : base(value) + { + Add(icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AlwaysPresent = true, + Alpha = 0, + Size = new Vector2(6) + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SortDirection.BindValueChanged(direction => + { + icon.Icon = direction.NewValue == Overlays.SortDirection.Ascending ? FontAwesome.Solid.CaretUp : FontAwesome.Solid.CaretDown; + }, true); + } + + protected override void UpdateState() + { + base.UpdateState(); + icon.FadeTo(Active.Value || IsHovered ? 1 : 0, 200, Easing.OutQuint); + } + + protected override bool OnClick(ClickEvent e) + { + if (Active.Value) + SortDirection.Value = SortDirection.Value == Overlays.SortDirection.Ascending ? Overlays.SortDirection.Descending : Overlays.SortDirection.Ascending; + + return base.OnClick(e); + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs new file mode 100644 index 0000000000..b429a5277b --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs @@ -0,0 +1,117 @@ +// 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. + +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; +using Humanizer; +using osu.Game.Utils; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapSearchFilterRow<T> : CompositeDrawable, IHasCurrentValue<T> + { + private readonly BindableWithCurrent<T> current = new BindableWithCurrent<T>(); + + public Bindable<T> Current + { + get => current.Current; + set => current.Current = value; + } + + public BeatmapSearchFilterRow(string headerName) + { + Drawable filter; + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + AddInternal(new GridContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, size: 100), + new Dimension() + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new[] + { + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 13), + Text = headerName.Titleize() + }, + filter = CreateFilter() + } + } + }); + + if (filter is IHasCurrentValue<T> filterWithValue) + Current = filterWithValue.Current; + } + + [NotNull] + protected virtual Drawable CreateFilter() => new BeatmapSearchFilter(); + + protected class BeatmapSearchFilter : TabControl<T> + { + public BeatmapSearchFilter() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + RelativeSizeAxes = Axes.X; + Height = 15; + + TabContainer.Spacing = new Vector2(10, 0); + + if (typeof(T).IsEnum) + { + foreach (var val in OrderAttributeUtils.GetValuesInOrder<T>()) + AddItem(val); + } + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + if (Dropdown is FilterDropdown fd) + fd.AccentColour = colourProvider.Light2; + } + + protected override Dropdown<T> CreateDropdown() => new FilterDropdown(); + + protected override TabItem<T> CreateTabItem(T value) => new FilterTabItem<T>(value); + + private class FilterDropdown : OsuTabDropdown<T> + { + protected override DropdownHeader CreateHeader() => new FilterHeader + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight + }; + + private class FilterHeader : OsuTabDropdownHeader + { + public FilterHeader() + { + Background.Height = 1; + } + } + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs new file mode 100644 index 0000000000..5dfa8e6109 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs @@ -0,0 +1,93 @@ +// 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. + +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 osuTK; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapSearchMultipleSelectionFilterRow<T> : BeatmapSearchFilterRow<List<T>> + { + public new readonly BindableList<T> Current = new BindableList<T>(); + + private MultipleSelectionFilter filter; + + public BeatmapSearchMultipleSelectionFilterRow(string headerName) + : base(headerName) + { + Current.BindTo(filter.Current); + } + + protected sealed override Drawable CreateFilter() => filter = CreateMultipleSelectionFilter(); + + /// <summary> + /// Creates a filter control that can be used to simultaneously select multiple values of type <typeparamref name="T"/>. + /// </summary> + protected virtual MultipleSelectionFilter CreateMultipleSelectionFilter() => new MultipleSelectionFilter(); + + protected class MultipleSelectionFilter : FillFlowContainer<MultipleSelectionFilterTabItem> + { + public readonly BindableList<T> Current = new BindableList<T>(); + + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + RelativeSizeAxes = Axes.X; + Height = 15; + Spacing = new Vector2(10, 0); + + AddRange(GetValues().Select(CreateTabItem)); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + foreach (var item in Children) + item.Active.BindValueChanged(active => toggleItem(item.Value, active.NewValue)); + } + + /// <summary> + /// Returns all values to be displayed in this filter row. + /// </summary> + protected virtual IEnumerable<T> GetValues() => Enum.GetValues(typeof(T)).Cast<T>(); + + /// <summary> + /// Creates a <see cref="MultipleSelectionFilterTabItem"/> representing the supplied <paramref name="value"/>. + /// </summary> + protected virtual MultipleSelectionFilterTabItem CreateTabItem(T value) => new MultipleSelectionFilterTabItem(value); + + private void toggleItem(T value, bool active) + { + if (active) + Current.Add(value); + else + Current.Remove(value); + } + } + + protected class MultipleSelectionFilterTabItem : FilterTabItem<T> + { + public MultipleSelectionFilterTabItem(T value) + : base(value) + { + } + + protected override bool OnClick(ClickEvent e) + { + base.OnClick(e); + Active.Toggle(); + return true; + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs new file mode 100644 index 0000000000..a8dc088e52 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs @@ -0,0 +1,34 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapSearchRulesetFilterRow : BeatmapSearchFilterRow<RulesetInfo> + { + public BeatmapSearchRulesetFilterRow() + : base(@"Mode") + { + } + + protected override Drawable CreateFilter() => new RulesetFilter(); + + private class RulesetFilter : BeatmapSearchFilter + { + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + AddItem(new RulesetInfo + { + Name = @"Any" + }); + + foreach (var r in rulesets.AvailableRulesets) + AddItem(r); + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs new file mode 100644 index 0000000000..804962adfb --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs @@ -0,0 +1,50 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Extensions; +using osu.Game.Scoring; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapSearchScoreFilterRow : BeatmapSearchMultipleSelectionFilterRow<ScoreRank> + { + public BeatmapSearchScoreFilterRow() + : base(@"Rank Achieved") + { + } + + protected override MultipleSelectionFilter CreateMultipleSelectionFilter() => new RankFilter(); + + private class RankFilter : MultipleSelectionFilter + { + protected override MultipleSelectionFilterTabItem CreateTabItem(ScoreRank value) => new RankItem(value); + + protected override IEnumerable<ScoreRank> GetValues() => base.GetValues().Reverse(); + } + + private class RankItem : MultipleSelectionFilterTabItem + { + public RankItem(ScoreRank value) + : base(value) + { + } + + protected override string LabelFor(ScoreRank value) + { + switch (value) + { + case ScoreRank.XH: + return @"Silver SS"; + + case ScoreRank.SH: + return @"Silver S"; + + default: + return value.GetDescription(); + } + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs new file mode 100644 index 0000000000..f02b515755 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs @@ -0,0 +1,79 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK.Graphics; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class FilterTabItem<T> : TabItem<T> + { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private OsuSpriteText text; + + public FilterTabItem(T value) + : base(value) + { + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + AddRangeInternal(new Drawable[] + { + text = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 13, weight: FontWeight.Regular), + Text = LabelFor(Value) + }, + new HoverClickSounds() + }); + + Enabled.Value = true; + updateState(); + } + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + protected override void OnActivated() => updateState(); + + protected override void OnDeactivated() => updateState(); + + /// <summary> + /// Returns the label text to be used for the supplied <paramref name="value"/>. + /// </summary> + protected virtual string LabelFor(T value) => (value as Enum)?.GetDescription() ?? value.ToString(); + + private void updateState() + { + text.FadeColour(IsHovered ? colourProvider.Light1 : getStateColour(), 200, Easing.OutQuint); + text.Font = text.Font.With(weight: Active.Value ? FontWeight.SemiBold : FontWeight.Regular); + } + + private Color4 getStateColour() => Active.Value ? colourProvider.Content1 : colourProvider.Light2; + } +} diff --git a/osu.Game/Overlays/Direct/DirectPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs similarity index 76% rename from osu.Game/Overlays/Direct/DirectPanel.cs rename to osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs index 8199d80528..88c15776cd 100644 --- a/osu.Game/Overlays/Direct/DirectPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs @@ -1,6 +1,7 @@ // 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. +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -9,32 +10,35 @@ 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.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { - public abstract class DirectPanel : Container + public abstract class BeatmapPanel : OsuClickableContainer, IHasContextMenu { public readonly BeatmapSetInfo SetInfo; private const double hover_transition_time = 400; + private const int maximum_difficulty_icons = 10; private Container content; - private BeatmapSetOverlay beatmapSetOverlay; - public PreviewTrack Preview => PlayButton.Preview; - public Bindable<bool> PreviewPlaying => PlayButton.Playing; + public Bindable<bool> PreviewPlaying => PlayButton?.Playing; protected abstract PlayButton PlayButton { get; } protected abstract Box PreviewBar { get; } @@ -43,7 +47,9 @@ namespace osu.Game.Overlays.Direct protected override Container<Drawable> Content => content; - protected DirectPanel(BeatmapSetInfo setInfo) + protected Action ViewBeatmap; + + protected BeatmapPanel(BeatmapSetInfo setInfo) { Debug.Assert(setInfo.OnlineBeatmapSetID != null); @@ -69,8 +75,6 @@ namespace osu.Game.Overlays.Direct [BackgroundDependencyLoader(permitNulls: true)] private void load(BeatmapManager beatmaps, OsuColour colours, BeatmapSetOverlay beatmapSetOverlay) { - this.beatmapSetOverlay = beatmapSetOverlay; - AddInternal(content = new Container { RelativeSizeAxes = Axes.Both, @@ -87,6 +91,12 @@ namespace osu.Game.Overlays.Direct }, } }); + + Action = ViewBeatmap = () => + { + Debug.Assert(SetInfo.OnlineBeatmapSetID != null); + beatmapSetOverlay?.FetchAndShowBeatmapSet(SetInfo.OnlineBeatmapSetID.Value); + }; } protected override void Update() @@ -119,13 +129,6 @@ namespace osu.Game.Overlays.Direct base.OnHoverLost(e); } - protected override bool OnClick(ClickEvent e) - { - Debug.Assert(SetInfo.OnlineBeatmapSetID != null); - beatmapSetOverlay?.FetchAndShowBeatmapSet(SetInfo.OnlineBeatmapSetID.Value); - return true; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -138,12 +141,20 @@ namespace osu.Game.Overlays.Direct }; } - protected List<DifficultyIcon> GetDifficultyIcons() + protected List<DifficultyIcon> GetDifficultyIcons(OsuColour colours) { var icons = new List<DifficultyIcon>(); - foreach (var b in SetInfo.Beatmaps.OrderBy(beatmap => beatmap.StarDifficulty)) - icons.Add(new DifficultyIcon(b)); + if (SetInfo.Beatmaps.Count > maximum_difficulty_icons) + { + foreach (var ruleset in SetInfo.Beatmaps.Select(b => b.Ruleset).Distinct()) + icons.Add(new GroupedDifficultyIcon(SetInfo.Beatmaps.FindAll(b => b.Ruleset.Equals(ruleset)), ruleset, this is ListBeatmapPanel ? Color4.White : colours.Gray5)); + } + else + { + foreach (var b in SetInfo.Beatmaps.OrderBy(beatmap => beatmap.StarDifficulty)) + icons.Add(new DifficultyIcon(b)); + } return icons; } @@ -183,15 +194,21 @@ namespace osu.Game.Overlays.Direct text = new OsuSpriteText { Font = OsuFont.GetFont(weight: FontWeight.SemiBold, italics: true) }, new SpriteIcon { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, Icon = icon, Shadow = true, Size = new Vector2(14), - Margin = new MarginPadding { Top = 1 }, }, }; Value = value; } } + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("View Beatmap", MenuItemType.Highlighted, ViewBeatmap), + }; } } diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs new file mode 100644 index 0000000000..001ca801d9 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs @@ -0,0 +1,99 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; + +namespace osu.Game.Overlays.BeatmapListing.Panels +{ + public class BeatmapPanelDownloadButton : BeatmapDownloadTrackingComposite + { + protected bool DownloadEnabled => button.Enabled.Value; + + /// <summary> + /// Currently selected beatmap. Used to present the correct difficulty after completing a download. + /// </summary> + public readonly IBindable<BeatmapInfo> SelectedBeatmap = new Bindable<BeatmapInfo>(); + + private readonly ShakeContainer shakeContainer; + private readonly DownloadButton button; + private Bindable<bool> noVideoSetting; + + public BeatmapPanelDownloadButton(BeatmapSetInfo beatmapSet) + : base(beatmapSet) + { + InternalChild = shakeContainer = new ShakeContainer + { + RelativeSizeAxes = Axes.Both, + Child = button = new DownloadButton + { + RelativeSizeAxes = Axes.Both, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + button.State.BindTo(State); + FinishTransforms(true); + } + + [BackgroundDependencyLoader(true)] + private void load(OsuGame game, BeatmapManager beatmaps, OsuConfigManager osuConfig) + { + noVideoSetting = osuConfig.GetBindable<bool>(OsuSetting.PreferNoVideo); + + button.Action = () => + { + switch (State.Value) + { + case DownloadState.Downloading: + case DownloadState.Downloaded: + shakeContainer.Shake(); + break; + + case DownloadState.LocallyAvailable: + Predicate<BeatmapInfo> findPredicate = null; + if (SelectedBeatmap.Value != null) + findPredicate = b => b.OnlineBeatmapID == SelectedBeatmap.Value.OnlineBeatmapID; + + game?.PresentBeatmap(BeatmapSet.Value, findPredicate); + break; + + default: + beatmaps.Download(BeatmapSet.Value, noVideoSetting.Value); + break; + } + }; + + State.BindValueChanged(state => + { + switch (state.NewValue) + { + case DownloadState.LocallyAvailable: + button.Enabled.Value = true; + button.TooltipText = "Go to beatmap"; + break; + + default: + if (BeatmapSet.Value?.OnlineInfo?.Availability?.DownloadDisabled ?? false) + { + button.Enabled.Value = false; + button.TooltipText = "this beatmap is currently not available for download."; + } + + break; + } + }, true); + } + } +} diff --git a/osu.Game/Overlays/Direct/DownloadProgressBar.cs b/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs similarity index 86% rename from osu.Game/Overlays/Direct/DownloadProgressBar.cs rename to osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs index a6cefaae84..93cf8799b5 100644 --- a/osu.Game/Overlays/Direct/DownloadProgressBar.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/DownloadProgressBar.cs @@ -10,7 +10,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { public class DownloadProgressBar : BeatmapDownloadTrackingComposite { @@ -19,7 +19,7 @@ namespace osu.Game.Overlays.Direct public DownloadProgressBar(BeatmapSetInfo beatmapSet) : base(beatmapSet) { - AddInternal(progressBar = new ProgressBar + AddInternal(progressBar = new InteractionDisabledProgressBar { Height = 0, Alpha = 0, @@ -64,5 +64,11 @@ namespace osu.Game.Overlays.Direct } }, true); } + + private class InteractionDisabledProgressBar : ProgressBar + { + public override bool HandlePositionalInput => false; + public override bool HandleNonPositionalInput => false; + } } } diff --git a/osu.Game/Overlays/Direct/DirectGridPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs similarity index 95% rename from osu.Game/Overlays/Direct/DirectGridPanel.cs rename to osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs index 243e79eb9b..28c36e6c56 100644 --- a/osu.Game/Overlays/Direct/DirectGridPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs @@ -1,25 +1,25 @@ // 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. -using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { - public class DirectGridPanel : DirectPanel + public class GridBeatmapPanel : BeatmapPanel { private const float horizontal_padding = 10; private const float vertical_padding = 5; @@ -31,11 +31,11 @@ namespace osu.Game.Overlays.Direct protected override PlayButton PlayButton => playButton; protected override Box PreviewBar => progressBar; - public DirectGridPanel(BeatmapSetInfo beatmap) + public GridBeatmapPanel(BeatmapSetInfo beatmap) : base(beatmap) { Width = 380; - Height = 140 + vertical_padding; //full height of all the elements plus vertical padding (autosize uses the image) + Height = 140 + vertical_padding; // full height of all the elements plus vertical padding (autosize uses the image) } protected override void LoadComplete() @@ -151,11 +151,12 @@ namespace osu.Game.Overlays.Direct AutoSizeAxes = Axes.X, Height = 20, Margin = new MarginPadding { Top = vertical_padding, Bottom = vertical_padding }, - Children = GetDifficultyIcons(), + Spacing = new Vector2(3), + Children = GetDifficultyIcons(colours), }, }, }, - new PanelDownloadButton(SetInfo) + new BeatmapPanelDownloadButton(SetInfo) { Size = new Vector2(50, 30), Margin = new MarginPadding(horizontal_padding), diff --git a/osu.Game/Overlays/Direct/IconPill.cs b/osu.Game/Overlays/BeatmapListing/Panels/IconPill.cs similarity index 96% rename from osu.Game/Overlays/Direct/IconPill.cs rename to osu.Game/Overlays/BeatmapListing/Panels/IconPill.cs index d63bb2a292..1cb6c84f13 100644 --- a/osu.Game/Overlays/Direct/IconPill.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/IconPill.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics.Sprites; using osuTK; using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { public class IconPill : CircularContainer { diff --git a/osu.Game/Overlays/Direct/DirectListPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs similarity index 96% rename from osu.Game/Overlays/Direct/DirectListPanel.cs rename to osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs index 5757e1445b..433ea37f06 100644 --- a/osu.Game/Overlays/Direct/DirectListPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs @@ -1,25 +1,25 @@ // 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. -using osuTK; -using osuTK.Graphics; +using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Colour; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Framework.Allocation; +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.Beatmaps.Drawables; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { - public class DirectListPanel : DirectPanel + public class ListBeatmapPanel : BeatmapPanel { private const float transition_duration = 120; private const float horizontal_padding = 10; @@ -27,7 +27,7 @@ namespace osu.Game.Overlays.Direct private const float height = 70; private FillFlowContainer statusContainer; - protected PanelDownloadButton DownloadButton; + protected BeatmapPanelDownloadButton DownloadButton; private PlayButton playButton; private Box progressBar; @@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Direct protected override PlayButton PlayButton => playButton; protected override Box PreviewBar => progressBar; - public DirectListPanel(BeatmapSetInfo beatmap) + public ListBeatmapPanel(BeatmapSetInfo beatmap) : base(beatmap) { RelativeSizeAxes = Axes.X; @@ -129,7 +129,8 @@ namespace osu.Game.Overlays.Direct AutoSizeAxes = Axes.X, Height = 20, Margin = new MarginPadding { Top = vertical_padding, Bottom = vertical_padding }, - Children = GetDifficultyIcons(), + Spacing = new Vector2(3), + Children = GetDifficultyIcons(colours), }, }, }, @@ -150,7 +151,7 @@ namespace osu.Game.Overlays.Direct Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, AutoSizeAxes = Axes.Both, - Child = DownloadButton = new PanelDownloadButton(SetInfo) + Child = DownloadButton = new BeatmapPanelDownloadButton(SetInfo) { Size = new Vector2(height - vertical_padding * 3), Margin = new MarginPadding { Left = vertical_padding * 2, Right = vertical_padding }, diff --git a/osu.Game/Overlays/Direct/PlayButton.cs b/osu.Game/Overlays/BeatmapListing/Panels/PlayButton.cs similarity index 90% rename from osu.Game/Overlays/Direct/PlayButton.cs rename to osu.Game/Overlays/BeatmapListing/Panels/PlayButton.cs index 2a77e7ca26..e95fdeecf4 100644 --- a/osu.Game/Overlays/Direct/PlayButton.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/PlayButton.cs @@ -14,7 +14,7 @@ using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; -namespace osu.Game.Overlays.Direct +namespace osu.Game.Overlays.BeatmapListing.Panels { public class PlayButton : Container { @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Direct private Color4 hoverColour; private readonly SpriteIcon icon; - private readonly LoadingAnimation loadingAnimation; + private readonly LoadingSpinner loadingSpinner; private const float transition_duration = 500; @@ -53,12 +53,12 @@ namespace osu.Game.Overlays.Direct if (value) { icon.FadeTo(0.5f, transition_duration, Easing.OutQuint); - loadingAnimation.Show(); + loadingSpinner.Show(); } else { icon.FadeTo(1, transition_duration, Easing.OutQuint); - loadingAnimation.Hide(); + loadingSpinner.Hide(); } } } @@ -76,7 +76,7 @@ namespace osu.Game.Overlays.Direct RelativeSizeAxes = Axes.Both, Icon = FontAwesome.Solid.Play, }, - loadingAnimation = new LoadingAnimation + loadingSpinner = new LoadingSpinner { Size = new Vector2(15), }, @@ -85,13 +85,12 @@ namespace osu.Game.Overlays.Direct Playing.ValueChanged += playingStateChanged; } - private PreviewTrackManager previewTrackManager; + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colour, PreviewTrackManager previewTrackManager) + private void load(OsuColour colour) { - this.previewTrackManager = previewTrackManager; - hoverColour = colour.Yellow; } diff --git a/osu.Game/Overlays/BeatmapListing/SearchCategory.cs b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs new file mode 100644 index 0000000000..84859bf5b5 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs @@ -0,0 +1,26 @@ +// 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. + +using System.ComponentModel; + +namespace osu.Game.Overlays.BeatmapListing +{ + public enum SearchCategory + { + Any, + + [Description("Has Leaderboard")] + Leaderboard, + Ranked, + Qualified, + Loved, + Favourites, + + [Description("Pending & WIP")] + Pending, + Graveyard, + + [Description("My Maps")] + Mine, + } +} diff --git a/osu.Game/Overlays/BeatmapListing/SearchExtra.cs b/osu.Game/Overlays/BeatmapListing/SearchExtra.cs new file mode 100644 index 0000000000..af37e3264f --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/SearchExtra.cs @@ -0,0 +1,16 @@ +// 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. + +using System.ComponentModel; + +namespace osu.Game.Overlays.BeatmapListing +{ + public enum SearchExtra + { + [Description("Has Video")] + Video, + + [Description("Has Storyboard")] + Storyboard + } +} diff --git a/osu.Game/Overlays/BeatmapListing/SearchGenre.cs b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs new file mode 100644 index 0000000000..de437fac3e --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs @@ -0,0 +1,29 @@ +// 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. + +using System.ComponentModel; + +namespace osu.Game.Overlays.BeatmapListing +{ + public enum SearchGenre + { + Any = 0, + Unspecified = 1, + + [Description("Video Game")] + VideoGame = 2, + Anime = 3, + Rock = 4, + Pop = 5, + Other = 6, + Novelty = 7, + + [Description("Hip Hop")] + HipHop = 9, + Electronic = 10, + Metal = 11, + Classical = 12, + Folk = 13, + Jazz = 14 + } +} diff --git a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs new file mode 100644 index 0000000000..eee5d8f7e1 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs @@ -0,0 +1,56 @@ +// 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. + +using osu.Game.Utils; + +namespace osu.Game.Overlays.BeatmapListing +{ + [HasOrderedElements] + public enum SearchLanguage + { + [Order(0)] + Any, + + [Order(14)] + Unspecified, + + [Order(1)] + English, + + [Order(6)] + Japanese, + + [Order(2)] + Chinese, + + [Order(12)] + Instrumental, + + [Order(7)] + Korean, + + [Order(3)] + French, + + [Order(4)] + German, + + [Order(9)] + Swedish, + + [Order(8)] + Spanish, + + [Order(5)] + Italian, + + [Order(10)] + Russian, + + [Order(11)] + Polish, + + [Order(13)] + Other + } +} diff --git a/osu.Game.Rulesets.Taiko/Objects/RimHit.cs b/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs similarity index 58% rename from osu.Game.Rulesets.Taiko/Objects/RimHit.cs rename to osu.Game/Overlays/BeatmapListing/SearchPlayed.cs index 6f6b089e03..eb7fb46158 100644 --- a/osu.Game.Rulesets.Taiko/Objects/RimHit.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs @@ -1,9 +1,12 @@ // 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. -namespace osu.Game.Rulesets.Taiko.Objects +namespace osu.Game.Overlays.BeatmapListing { - public class RimHit : Hit + public enum SearchPlayed { + Any, + Played, + Unplayed } } diff --git a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs new file mode 100644 index 0000000000..e409cbdda7 --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs @@ -0,0 +1,17 @@ +// 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. + +namespace osu.Game.Overlays.BeatmapListing +{ + public enum SortCriteria + { + Title, + Artist, + Difficulty, + Ranked, + Rating, + Plays, + Favourites, + Relevance + } +} diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs new file mode 100644 index 0000000000..1e29e713af --- /dev/null +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -0,0 +1,265 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.BeatmapListing; +using osu.Game.Overlays.BeatmapListing.Panels; +using osuTK; + +namespace osu.Game.Overlays +{ + public class BeatmapListingOverlay : FullscreenOverlay<BeatmapListingHeader> + { + [Resolved] + private PreviewTrackManager previewTrackManager { get; set; } + + private Drawable currentContent; + private LoadingLayer loadingLayer; + private Container panelTarget; + private FillFlowContainer<BeatmapPanel> foundContent; + private NotFoundDrawable notFoundContent; + + private OverlayScrollContainer resultScrollContainer; + + public BeatmapListingOverlay() + : base(OverlayColourScheme.Blue, new BeatmapListingHeader()) + { + } + + private BeatmapListingFilterControl filterControl; + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background6 + }, + resultScrollContainer = new OverlayScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new ReverseChildIDFillFlowContainer<Drawable> + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + Header, + filterControl = new BeatmapListingFilterControl + { + TypingStarted = onTypingStarted, + SearchStarted = onSearchStarted, + SearchFinished = onSearchFinished, + }, + new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background4, + }, + panelTarget = new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Horizontal = 20 }, + Children = new Drawable[] + { + foundContent = new FillFlowContainer<BeatmapPanel>(), + notFoundContent = new NotFoundDrawable(), + loadingLayer = new LoadingLayer(panelTarget) + } + } + } + }, + } + } + } + }; + } + + private void onTypingStarted() + { + // temporary until the textbox/header is updated to always stay on screen. + resultScrollContainer.ScrollToStart(); + } + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + filterControl.TakeFocus(); + } + + private CancellationTokenSource cancellationToken; + + private void onSearchStarted() + { + cancellationToken?.Cancel(); + + previewTrackManager.StopAnyPlaying(this); + + if (panelTarget.Any()) + loadingLayer.Show(); + } + + private Task panelLoadDelegate; + + private void onSearchFinished(List<BeatmapSetInfo> beatmaps) + { + var newPanels = beatmaps.Select<BeatmapSetInfo, BeatmapPanel>(b => new GridBeatmapPanel(b) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }); + + if (filterControl.CurrentPage == 0) + { + //No matches case + if (!newPanels.Any()) + { + LoadComponentAsync(notFoundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + return; + } + + // spawn new children with the contained so we only clear old content at the last moment. + var content = new FillFlowContainer<BeatmapPanel> + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Alpha = 0, + Margin = new MarginPadding { Vertical = 15 }, + ChildrenEnumerable = newPanels + }; + + panelLoadDelegate = LoadComponentAsync(foundContent = content, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + } + else + { + panelLoadDelegate = LoadComponentsAsync(newPanels, loaded => + { + lastFetchDisplayedTime = Time.Current; + foundContent.AddRange(loaded); + loaded.ForEach(p => p.FadeIn(200, Easing.OutQuint)); + }); + } + } + + private void addContentToPlaceholder(Drawable content) + { + loadingLayer.Hide(); + lastFetchDisplayedTime = Time.Current; + + var lastContent = currentContent; + + if (lastContent != null) + { + lastContent.FadeOut(100, Easing.OutQuint).Expire(); + + // Consider the case when the new content is smaller than the last content. + // If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird. + // At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0. + // To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so. + lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y).Then().Schedule(() => panelTarget.Remove(lastContent)); + } + + if (!content.IsAlive) + panelTarget.Add(content); + content.FadeIn(200, Easing.OutQuint); + + currentContent = content; + } + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + + private class NotFoundDrawable : CompositeDrawable + { + public NotFoundDrawable() + { + RelativeSizeAxes = Axes.X; + Height = 250; + Alpha = 0; + Margin = new MarginPadding { Top = 15 }; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AddInternal(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get(@"Online/not-found") + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = @"... nope, nothing found.", + } + } + }); + } + } + + private const double time_between_fetches = 500; + + private double lastFetchDisplayedTime; + + protected override void Update() + { + base.Update(); + + const int pagination_scroll_distance = 500; + + bool shouldShowMore = panelLoadDelegate?.IsCompleted != false + && Time.Current - lastFetchDisplayedTime > time_between_fetches + && (resultScrollContainer.ScrollableExtent > 0 && resultScrollContainer.IsScrolledToEnd(pagination_scroll_distance)); + + if (shouldShowMore) + filterControl.FetchNextPage(); + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs index 096e91b65b..31c1439c8f 100644 --- a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs +++ b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs @@ -1,6 +1,7 @@ // 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. +using System; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -50,7 +51,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("submitted on", online.Submitted.ToString(@"MMMM d, yyyy"), OsuFont.GetFont(weight: FontWeight.Bold)) + new Field("submitted", online.Submitted, OsuFont.GetFont(weight: FontWeight.Bold)) { Margin = new MarginPadding { Top = 5 }, }, @@ -58,11 +59,11 @@ namespace osu.Game.Overlays.BeatmapSet if (online.Ranked.HasValue) { - fields.Add(new Field("ranked on", online.Ranked.Value.ToString(@"MMMM d, yyyy"), OsuFont.GetFont(weight: FontWeight.Bold))); + fields.Add(new Field(online.Status.ToString().ToLowerInvariant(), online.Ranked.Value, OsuFont.GetFont(weight: FontWeight.Bold))); } else if (online.LastUpdated.HasValue) { - fields.Add(new Field("last updated on", online.LastUpdated.Value.ToString(@"MMMM d, yyyy"), OsuFont.GetFont(weight: FontWeight.Bold))); + fields.Add(new Field("last updated", online.LastUpdated.Value, OsuFont.GetFont(weight: FontWeight.Bold))); } } @@ -76,7 +77,7 @@ namespace osu.Game.Overlays.BeatmapSet new Container { AutoSizeAxes = Axes.Both, - CornerRadius = 3, + CornerRadius = 4, Masking = true, Child = avatar = new UpdateableAvatar { @@ -87,7 +88,7 @@ namespace osu.Game.Overlays.BeatmapSet { Colour = Color4.Black.Opacity(0.25f), Type = EdgeEffectType.Shadow, - Radius = 3, + Radius = 4, Offset = new Vector2(0f, 1f), }, }, @@ -117,15 +118,34 @@ namespace osu.Game.Overlays.BeatmapSet new OsuSpriteText { Text = $"{first} ", - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 11) }, new OsuSpriteText { Text = second, - Font = secondFont.With(size: 13) + Font = secondFont.With(size: 11) }, }; } + + public Field(string first, DateTimeOffset second, FontUsage secondFont) + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Horizontal; + + Children = new[] + { + new OsuSpriteText + { + Text = $"{first} ", + Font = OsuFont.GetFont(size: 13) + }, + new DrawableDate(second) + { + Font = secondFont.With(size: 13) + } + }; + } } } } diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index 5b10c4e0bb..a2464bef09 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -91,10 +92,9 @@ namespace osu.Game.Overlays.BeatmapSet private class Statistic : Container, IHasTooltip { - private readonly string name; private readonly OsuSpriteText value; - public string TooltipText => name; + public string TooltipText { get; } public string Value { @@ -104,9 +104,9 @@ namespace osu.Game.Overlays.BeatmapSet public Statistic(IconUsage icon, string name) { - this.name = name; + TooltipText = name; RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; + Height = 24f; Children = new Drawable[] { @@ -114,7 +114,8 @@ namespace osu.Game.Overlays.BeatmapSet { Anchor = Anchor.Centre, Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, Children = new Drawable[] { new SpriteIcon @@ -122,17 +123,17 @@ namespace osu.Game.Overlays.BeatmapSet Anchor = Anchor.CentreLeft, Origin = Anchor.Centre, Icon = FontAwesome.Solid.Square, - Size = new Vector2(13), + Size = new Vector2(12), Rotation = 45, - Colour = OsuColour.FromHex(@"441288"), + Colour = Color4Extensions.FromHex(@"441288"), }, new SpriteIcon { Anchor = Anchor.CentreLeft, Origin = Anchor.Centre, Icon = icon, - Size = new Vector2(13), - Colour = OsuColour.FromHex(@"f7dd55"), + Size = new Vector2(12), + Colour = Color4Extensions.FromHex(@"f7dd55"), Scale = new Vector2(0.8f), }, value = new OsuSpriteText @@ -140,7 +141,7 @@ namespace osu.Game.Overlays.BeatmapSet Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Margin = new MarginPadding { Left = 10 }, - Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), }, }, }, diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs index baf702eebc..66886b0882 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapPicker.cs @@ -6,7 +6,6 @@ using System.Linq; using osu.Framework; 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; @@ -17,8 +16,8 @@ using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { @@ -27,12 +26,12 @@ namespace osu.Game.Overlays.BeatmapSet private const float tile_icon_padding = 7; private const float tile_spacing = 2; - private readonly DifficultiesContainer difficulties; private readonly OsuSpriteText version, starRating; private readonly Statistic plays, favourites; - public readonly Bindable<BeatmapInfo> Beatmap = new Bindable<BeatmapInfo>(); + public readonly DifficultiesContainer Difficulties; + public readonly Bindable<BeatmapInfo> Beatmap = new Bindable<BeatmapInfo>(); private BeatmapSetInfo beatmapSet; public BeatmapSetInfo BeatmapSet @@ -43,38 +42,10 @@ namespace osu.Game.Overlays.BeatmapSet if (value == beatmapSet) return; beatmapSet = value; - updateDisplay(); } } - private void updateDisplay() - { - difficulties.Clear(); - - if (BeatmapSet != null) - { - difficulties.ChildrenEnumerable = BeatmapSet.Beatmaps.OrderBy(beatmap => beatmap.StarDifficulty).Select(b => new DifficultySelectorButton(b) - { - State = DifficultySelectorState.NotSelected, - OnHovered = beatmap => - { - showBeatmap(beatmap); - starRating.Text = beatmap.StarDifficulty.ToString("Star Difficulty 0.##"); - starRating.FadeIn(100); - }, - OnClicked = beatmap => { Beatmap.Value = beatmap; }, - }); - } - - starRating.FadeOut(100); - Beatmap.Value = BeatmapSet?.Beatmaps.FirstOrDefault(); - plays.Value = BeatmapSet?.OnlineInfo.PlayCount ?? 0; - favourites.Value = BeatmapSet?.OnlineInfo.FavouriteCount ?? 0; - - updateDifficultyButtons(); - } - public BeatmapPicker() { RelativeSizeAxes = Axes.X; @@ -89,10 +60,11 @@ namespace osu.Game.Overlays.BeatmapSet Direction = FillDirection.Vertical, Children = new Drawable[] { - difficulties = new DifficultiesContainer + Difficulties = new DifficultiesContainer { - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Left = -(tile_icon_padding + tile_spacing / 2) }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Left = -(tile_icon_padding + tile_spacing / 2), Bottom = 10 }, OnLostHover = () => { showBeatmap(Beatmap.Value); @@ -102,7 +74,6 @@ namespace osu.Game.Overlays.BeatmapSet new FillFlowContainer { AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 10 }, Spacing = new Vector2(5f), Children = new[] { @@ -110,13 +81,13 @@ namespace osu.Game.Overlays.BeatmapSet { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: 17, weight: FontWeight.Bold) }, starRating = new OsuSpriteText { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold), + Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold), Text = "Star Difficulty", Alpha = 0, Margin = new MarginPadding { Bottom = 1 }, @@ -146,6 +117,9 @@ namespace osu.Game.Overlays.BeatmapSet }; } + [Resolved] + private IBindable<RulesetInfo> ruleset { get; set; } + [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -157,10 +131,39 @@ namespace osu.Game.Overlays.BeatmapSet { base.LoadComplete(); + ruleset.ValueChanged += r => updateDisplay(); + // done here so everything can bind in intialization and get the first trigger Beatmap.TriggerChange(); } + private void updateDisplay() + { + Difficulties.Clear(); + + if (BeatmapSet != null) + { + Difficulties.ChildrenEnumerable = BeatmapSet.Beatmaps.Where(b => b.Ruleset.Equals(ruleset.Value)).OrderBy(b => b.StarDifficulty).Select(b => new DifficultySelectorButton(b) + { + State = DifficultySelectorState.NotSelected, + OnHovered = beatmap => + { + showBeatmap(beatmap); + starRating.Text = beatmap.StarDifficulty.ToString("Star Difficulty 0.##"); + starRating.FadeIn(100); + }, + OnClicked = beatmap => { Beatmap.Value = beatmap; }, + }); + } + + starRating.FadeOut(100); + Beatmap.Value = Difficulties.FirstOrDefault()?.Beatmap; + plays.Value = BeatmapSet?.OnlineInfo.PlayCount ?? 0; + favourites.Value = BeatmapSet?.OnlineInfo.FavouriteCount ?? 0; + + updateDifficultyButtons(); + } + private void showBeatmap(BeatmapInfo beatmap) { version.Text = beatmap?.Version; @@ -168,10 +171,10 @@ namespace osu.Game.Overlays.BeatmapSet private void updateDifficultyButtons() { - difficulties.Children.ToList().ForEach(diff => diff.State = diff.Beatmap == Beatmap.Value ? DifficultySelectorState.Selected : DifficultySelectorState.NotSelected); + Difficulties.Children.ToList().ForEach(diff => diff.State = diff.Beatmap == Beatmap.Value ? DifficultySelectorState.Selected : DifficultySelectorState.NotSelected); } - private class DifficultiesContainer : FillFlowContainer<DifficultySelectorButton> + public class DifficultiesContainer : FillFlowContainer<DifficultySelectorButton> { public Action OnLostHover; @@ -182,12 +185,14 @@ namespace osu.Game.Overlays.BeatmapSet } } - private class DifficultySelectorButton : OsuClickableContainer, IStateful<DifficultySelectorState> + public class DifficultySelectorButton : OsuClickableContainer, IStateful<DifficultySelectorState> { private const float transition_duration = 100; - private const float size = 52; + private const float size = 54; + private const float background_size = size - 2; - private readonly Container bg; + private readonly Container background; + private readonly Box backgroundBox; private readonly DifficultyIcon icon; public readonly BeatmapInfo Beatmap; @@ -223,18 +228,18 @@ namespace osu.Game.Overlays.BeatmapSet Children = new Drawable[] { - bg = new Container + background = new Container { - RelativeSizeAxes = Axes.Both, + Size = new Vector2(background_size), Masking = true, CornerRadius = 4, - Child = new Box + Child = backgroundBox = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f), - }, + Alpha = 0.5f + } }, - icon = new DifficultyIcon(beatmap) + icon = new DifficultyIcon(beatmap, shouldShowTooltip: false) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -266,15 +271,21 @@ namespace osu.Game.Overlays.BeatmapSet private void fadeIn() { - bg.FadeIn(transition_duration); + background.FadeIn(transition_duration); icon.FadeIn(transition_duration); } private void fadeOut() { - bg.FadeOut(); + background.FadeOut(); icon.FadeTo(0.7f, transition_duration); } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + backgroundBox.Colour = colourProvider.Background6; + } } private class Statistic : FillFlowContainer @@ -307,19 +318,19 @@ namespace osu.Game.Overlays.BeatmapSet Origin = Anchor.CentreLeft, Icon = icon, Shadow = true, - Size = new Vector2(13), + Size = new Vector2(12), }, text = new OsuSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold, italics: true) + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold, italics: true), }, }; } } - private enum DifficultySelectorState + public enum DifficultySelectorState { Selected, NotSelected, diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs new file mode 100644 index 0000000000..005d21726b --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetSelector.cs @@ -0,0 +1,33 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using System.Linq; + +namespace osu.Game.Overlays.BeatmapSet +{ + public class BeatmapRulesetSelector : OverlayRulesetSelector + { + private readonly Bindable<BeatmapSetInfo> beatmapSet = new Bindable<BeatmapSetInfo>(); + + public BeatmapSetInfo BeatmapSet + { + get => beatmapSet.Value; + set + { + // propagate value to tab items first to enable only available rulesets. + beatmapSet.Value = value; + + SelectTab(TabContainer.TabItems.FirstOrDefault(t => t.Enabled.Value)); + } + } + + protected override TabItem<RulesetInfo> CreateTabItem(RulesetInfo value) => new BeatmapRulesetTabItem(value) + { + BeatmapSet = { BindTarget = beatmapSet } + }; + } +} diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs new file mode 100644 index 0000000000..cb258edced --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/BeatmapRulesetTabItem.cs @@ -0,0 +1,76 @@ +// 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. + +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; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; +using System.Linq; + +namespace osu.Game.Overlays.BeatmapSet +{ + public class BeatmapRulesetTabItem : OverlayRulesetTabItem + { + public readonly Bindable<BeatmapSetInfo> BeatmapSet = new Bindable<BeatmapSetInfo>(); + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private OsuSpriteText count; + private Container countContainer; + + public BeatmapRulesetTabItem(RulesetInfo value) + : base(value) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Add(countContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + CornerRadius = 4f, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6 + }, + count = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 5f }, + Font = OsuFont.Default.With(weight: FontWeight.SemiBold), + Colour = colourProvider.Foreground1, + } + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + BeatmapSet.BindValueChanged(setInfo => + { + var beatmapsCount = setInfo.NewValue?.Beatmaps.Count(b => b.Ruleset.Equals(Value)) ?? 0; + + count.Text = beatmapsCount.ToString(); + countContainer.FadeTo(beatmapsCount > 0 ? 1 : 0); + + Enabled.Value = beatmapsCount > 0; + }, true); + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs new file mode 100644 index 0000000000..6511b15fc8 --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs @@ -0,0 +1,32 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets; + +namespace osu.Game.Overlays.BeatmapSet +{ + public class BeatmapSetHeader : OverlayHeader + { + public readonly Bindable<RulesetInfo> Ruleset = new Bindable<RulesetInfo>(); + + public BeatmapRulesetSelector RulesetSelector { get; private set; } + + protected override OverlayTitle CreateTitle() => new BeatmapHeaderTitle(); + + protected override Drawable CreateTitleContent() => RulesetSelector = new BeatmapRulesetSelector + { + Current = Ruleset + }; + + private class BeatmapHeaderTitle : OverlayTitle + { + public BeatmapHeaderTitle() + { + Title = "beatmap info"; + IconTexture = "Icons/Hexacons/beatmap"; + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetLayoutSection.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetLayoutSection.cs new file mode 100644 index 0000000000..e6d433f7bc --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetLayoutSection.cs @@ -0,0 +1,29 @@ +// 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. + +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.BeatmapSet +{ + public class BeatmapSetLayoutSection : Container + { + public BeatmapSetLayoutSection() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.25f), + Type = EdgeEffectType.Shadow, + Radius = 3, + Offset = new Vector2(0f, 1f), + }; + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs index 11f56bc163..742b1055b2 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs @@ -1,52 +1,50 @@ // 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. +using System.Diagnostics; 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.Cursor; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Notifications; +using osu.Game.Users; using osuTK; namespace osu.Game.Overlays.BeatmapSet.Buttons { - public class FavouriteButton : HeaderButton + public class FavouriteButton : HeaderButton, IHasTooltip { public readonly Bindable<BeatmapSetInfo> BeatmapSet = new Bindable<BeatmapSetInfo>(); - private readonly Bindable<bool> favourited = new Bindable<bool>(); + private readonly BindableBool favourited = new BindableBool(); - [BackgroundDependencyLoader] - private void load() + private PostBeatmapFavouriteRequest request; + private LoadingLayer loading; + + private readonly Bindable<User> localUser = new Bindable<User>(); + + public string TooltipText + { + get + { + if (!Enabled.Value) return string.Empty; + + return (favourited.Value ? "Unfavourite" : "Favourite") + " this beatmapset"; + } + } + + [BackgroundDependencyLoader(true)] + private void load(IAPIProvider api, NotificationOverlay notifications) { - Container pink; SpriteIcon icon; + AddRange(new Drawable[] { - pink = new Container - { - RelativeSizeAxes = Axes.Both, - Alpha = 0f, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"9f015f"), - }, - new Triangles - { - RelativeSizeAxes = Axes.Both, - ColourLight = OsuColour.FromHex(@"cb2187"), - ColourDark = OsuColour.FromHex(@"9f015f"), - TriangleScale = 1.5f, - }, - }, - }, icon = new SpriteIcon { Anchor = Anchor.Centre, @@ -55,31 +53,55 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons Size = new Vector2(18), Shadow = false, }, + loading = new LoadingLayer(icon, false), }); + Action = () => + { + // guaranteed by disabled state above. + Debug.Assert(BeatmapSet.Value.OnlineBeatmapSetID != null); + + loading.Show(); + + request?.Cancel(); + + request = new PostBeatmapFavouriteRequest(BeatmapSet.Value.OnlineBeatmapSetID.Value, favourited.Value ? BeatmapFavouriteAction.UnFavourite : BeatmapFavouriteAction.Favourite); + + request.Success += () => + { + favourited.Toggle(); + loading.Hide(); + }; + + request.Failure += e => + { + notifications?.Post(new SimpleNotification + { + Text = e.Message, + Icon = FontAwesome.Solid.Times, + }); + + loading.Hide(); + }; + + api.Queue(request); + }; + + favourited.ValueChanged += favourited => icon.Icon = favourited.NewValue ? FontAwesome.Solid.Heart : FontAwesome.Regular.Heart; + + localUser.BindTo(api.LocalUser); + localUser.BindValueChanged(_ => updateEnabled()); + + // must be run after setting the Action to ensure correct enabled state (setting an Action forces a button to be enabled). BeatmapSet.BindValueChanged(setInfo => { - if (setInfo.NewValue?.OnlineInfo?.HasFavourited == null) - return; - - favourited.Value = setInfo.NewValue.OnlineInfo.HasFavourited; - }); - - favourited.ValueChanged += favourited => - { - if (favourited.NewValue) - { - pink.FadeIn(200); - icon.Icon = FontAwesome.Solid.Heart; - } - else - { - pink.FadeOut(200); - icon.Icon = FontAwesome.Regular.Heart; - } - }; + updateEnabled(); + favourited.Value = setInfo.NewValue?.OnlineInfo?.HasFavourited ?? false; + }, true); } + private void updateEnabled() => Enabled.Value = !(localUser.Value is GuestUser) && BeatmapSet.Value?.OnlineBeatmapSetID > 0; + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs index 6de1d3fca7..99b0b2ed3b 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderButton.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.BeatmapSet.Buttons @@ -19,9 +19,9 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons [BackgroundDependencyLoader] private void load() { - BackgroundColour = OsuColour.FromHex(@"094c5f"); - Triangles.ColourLight = OsuColour.FromHex(@"0f7c9b"); - Triangles.ColourDark = OsuColour.FromHex(@"094c5f"); + BackgroundColour = Color4Extensions.FromHex(@"094c5f"); + Triangles.ColourLight = Color4Extensions.FromHex(@"0f7c9b"); + Triangles.ColourDark = Color4Extensions.FromHex(@"094c5f"); Triangles.TriangleScale = 1.5f; } } diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs index fe10287491..56c0052bfe 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs @@ -13,7 +13,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Online; using osu.Game.Online.API; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Users; using osuTK; using osuTK.Graphics; @@ -22,9 +22,11 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { public class HeaderDownloadButton : BeatmapDownloadTrackingComposite, IHasTooltip { + private const int text_size = 12; + private readonly bool noVideo; - public string TooltipText => button.Enabled.Value ? "Download this beatmap" : "Login to download"; + public string TooltipText => button.Enabled.Value ? "download this beatmap" : "login to download"; private readonly IBindable<User> localUser = new Bindable<User>(); @@ -80,8 +82,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Icon = FontAwesome.Solid.Download, - Size = new Vector2(16), - Margin = new MarginPadding { Right = 5 }, + Size = new Vector2(18), }, } }, @@ -120,7 +121,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons new OsuSpriteText { Text = "Downloading...", - Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, }; break; @@ -131,7 +132,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons new OsuSpriteText { Text = "Importing...", - Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, }; break; @@ -146,12 +147,12 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons new OsuSpriteText { Text = "Download", - Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: text_size, weight: FontWeight.Bold) }, new OsuSpriteText { - Text = BeatmapSet.Value.OnlineInfo.HasVideo && noVideo ? "without Video" : string.Empty, - Font = OsuFont.GetFont(size: 11, weight: FontWeight.Bold) + Text = getVideoSuffixText(), + Font = OsuFont.GetFont(size: text_size - 2, weight: FontWeight.Bold) }, }; this.FadeIn(200); @@ -163,5 +164,13 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private void userChanged(ValueChangedEvent<User> e) => button.Enabled.Value = !(e.NewValue is GuestUser); private void enabledChanged(ValueChangedEvent<bool> e) => this.FadeColour(e.NewValue ? Color4.White : Color4.Gray, 200, Easing.OutQuint); + + private string getVideoSuffixText() + { + if (!BeatmapSet.Value.OnlineInfo.HasVideo) + return string.Empty; + + return noVideo ? "without Video" : "with Video"; + } } } diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs index 8c884e0950..6accce7d77 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/PreviewButton.cs @@ -3,7 +3,6 @@ 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; @@ -12,9 +11,8 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Overlays.Direct; +using osu.Game.Overlays.BeatmapListing.Panels; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet.Buttons { @@ -22,7 +20,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons { private const float transition_duration = 500; - private readonly Box bg, progress; + private readonly Box background, progress; private readonly PlayButton playButton; private PreviewTrack preview => playButton.Preview; @@ -40,10 +38,10 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons Children = new Drawable[] { - bg = new Box + background = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.25f), + Alpha = 0.5f }, new Container { @@ -71,9 +69,10 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OverlayColourProvider colourProvider) { progress.Colour = colours.Yellow; + background.Colour = colourProvider.Background6; } protected override void Update() @@ -91,13 +90,13 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons protected override bool OnHover(HoverEvent e) { - bg.FadeColour(Color4.Black.Opacity(0.5f), 100); + background.FadeTo(0.75f, 80); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - bg.FadeColour(Color4.Black.Opacity(0.25f), 100); + background.FadeTo(0.5f, 80); base.OnHoverLost(e); } } diff --git a/osu.Game/Overlays/BeatmapSet/Details.cs b/osu.Game/Overlays/BeatmapSet/Details.cs index d76f6a43db..680487ffbb 100644 --- a/osu.Game/Overlays/BeatmapSet/Details.cs +++ b/osu.Game/Overlays/BeatmapSet/Details.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -10,7 +9,6 @@ using osu.Game.Beatmaps; using osu.Game.Overlays.BeatmapSet.Buttons; using osu.Game.Screens.Select.Details; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { @@ -21,6 +19,7 @@ namespace osu.Game.Overlays.BeatmapSet private readonly PreviewButton preview; private readonly BasicStats basic; private readonly AdvancedStats advanced; + private readonly DetailBox ratingBox; private BeatmapSetInfo beatmapSet; @@ -54,6 +53,7 @@ namespace osu.Game.Overlays.BeatmapSet private void updateDisplay() { Ratings.Metrics = BeatmapSet?.Metrics; + ratingBox.Alpha = BeatmapSet?.OnlineInfo?.Status > 0 ? 1 : 0; } public Details() @@ -74,7 +74,7 @@ namespace osu.Game.Overlays.BeatmapSet { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Vertical = 10 }, + Padding = new MarginPadding { Vertical = 10 } }, }, new DetailBox @@ -86,7 +86,7 @@ namespace osu.Game.Overlays.BeatmapSet Margin = new MarginPadding { Vertical = 7.5f }, }, }, - new DetailBox + ratingBox = new DetailBox { Child = Ratings = new UserRatings { @@ -107,6 +107,8 @@ namespace osu.Game.Overlays.BeatmapSet private class DetailBox : Container { private readonly Container content; + private readonly Box background; + protected override Container<Drawable> Content => content; public DetailBox() @@ -116,10 +118,10 @@ namespace osu.Game.Overlays.BeatmapSet InternalChildren = new Drawable[] { - new Box + background = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f), + Alpha = 0.5f }, content = new Container { @@ -129,6 +131,12 @@ namespace osu.Game.Overlays.BeatmapSet }, }; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background6; + } } } } diff --git a/osu.Game/Overlays/BeatmapSet/Header.cs b/osu.Game/Overlays/BeatmapSet/Header.cs index 260a989628..06e31277dd 100644 --- a/osu.Game/Overlays/BeatmapSet/Header.cs +++ b/osu.Game/Overlays/BeatmapSet/Header.cs @@ -3,6 +3,7 @@ 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; @@ -14,8 +15,9 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Overlays.BeatmapSet.Buttons; -using osu.Game.Overlays.Direct; +using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; @@ -24,12 +26,11 @@ namespace osu.Game.Overlays.BeatmapSet public class Header : BeatmapDownloadTrackingComposite { private const float transition_duration = 200; - private const float tabs_height = 50; private const float buttons_height = 45; private const float buttons_spacing = 5; - private readonly Box tabsBg; private readonly UpdateableBeatmapSetCover cover; + private readonly Box coverGradient; private readonly OsuSpriteText title, artist; private readonly AuthorInfo author; private readonly FillFlowContainer downloadButtonsContainer; @@ -39,13 +40,16 @@ namespace osu.Game.Overlays.BeatmapSet public bool DownloadButtonsVisible => downloadButtonsContainer.Any(); + public BeatmapRulesetSelector RulesetSelector => beatmapSetHeader.RulesetSelector; public readonly BeatmapPicker Picker; private readonly FavouriteButton favouriteButton; - private readonly FillFlowContainer fadeContent; + private readonly LoadingSpinner loading; + private readonly BeatmapSetHeader beatmapSetHeader; - private readonly LoadingAnimation loading; + [Cached(typeof(IBindable<RulesetInfo>))] + private readonly Bindable<RulesetInfo> ruleset = new Bindable<RulesetInfo>(); public Header() { @@ -63,148 +67,147 @@ namespace osu.Game.Overlays.BeatmapSet Offset = new Vector2(0f, 1f), }; - InternalChildren = new Drawable[] + InternalChild = new FillFlowContainer { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - Height = tabs_height, - Children = new[] + beatmapSetHeader = new BeatmapSetHeader { - tabsBg = new Box - { - RelativeSizeAxes = Axes.Both, - }, + Ruleset = { BindTarget = ruleset }, }, - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Top = tabs_height }, - Children = new Drawable[] + new Container { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + new Container { - cover = new UpdateableBeatmapSetCover + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.3f), Color4.Black.Opacity(0.8f)), - }, - }, - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding - { - Top = 20, - Bottom = 30, - Left = BeatmapSetOverlay.X_PADDING, - Right = BeatmapSetOverlay.X_PADDING + BeatmapSetOverlay.RIGHT_WIDTH, - }, - Children = new Drawable[] - { - fadeContent = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + cover = new UpdateableBeatmapSetCover { - new Container + RelativeSizeAxes = Axes.Both, + Masking = true, + }, + coverGradient = new Box + { + RelativeSizeAxes = Axes.Both + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Vertical = BeatmapSetOverlay.Y_PADDING, + Left = BeatmapSetOverlay.X_PADDING, + Right = BeatmapSetOverlay.X_PADDING + BeatmapSetOverlay.RIGHT_WIDTH, + }, + Children = new Drawable[] + { + fadeContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = Picker = new BeatmapPicker(), - }, - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] + new Container { - title = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 37, weight: FontWeight.Bold, italics: true) - }, - externalLink = new ExternalLinkButton - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Margin = new MarginPadding { Left = 3, Bottom = 4 }, //To better lineup with the font - }, - } - }, - artist = new OsuSpriteText { Font = OsuFont.GetFont(size: 25, weight: FontWeight.SemiBold, italics: true) }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Top = 20 }, - Child = author = new AuthorInfo(), - }, - beatmapAvailability = new BeatmapAvailability(), - new Container - { - RelativeSizeAxes = Axes.X, - Height = buttons_height, - Margin = new MarginPadding { Top = 10 }, - Children = new Drawable[] + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = Picker = new BeatmapPicker(), + }, + new FillFlowContainer { - favouriteButton = new FavouriteButton + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = 15 }, + Children = new Drawable[] { - BeatmapSet = { BindTarget = BeatmapSet } - }, - downloadButtonsContainer = new FillFlowContainer + title = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 30, weight: FontWeight.SemiBold, italics: true) + }, + externalLink = new ExternalLinkButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding { Left = 3, Bottom = 4 }, // To better lineup with the font + }, + } + }, + artist = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Medium, italics: true), + Margin = new MarginPadding { Bottom = 20 } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = author = new AuthorInfo(), + }, + beatmapAvailability = new BeatmapAvailability(), + new Container + { + RelativeSizeAxes = Axes.X, + Height = buttons_height, + Margin = new MarginPadding { Top = 10 }, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = buttons_height + buttons_spacing }, - Spacing = new Vector2(buttons_spacing), + favouriteButton = new FavouriteButton + { + BeatmapSet = { BindTarget = BeatmapSet } + }, + downloadButtonsContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = buttons_height + buttons_spacing }, + Spacing = new Vector2(buttons_spacing), + }, }, }, }, }, - }, - } - }, - loading = new LoadingAnimation - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(1.5f), - }, - new FillFlowContainer - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = BeatmapSetOverlay.TOP_PADDING, Right = BeatmapSetOverlay.X_PADDING }, - Direction = FillDirection.Vertical, - Spacing = new Vector2(10), - Children = new Drawable[] + } + }, + loading = new LoadingSpinner { - onlineStatusPill = new BeatmapSetOnlineStatusPill + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.5f), + }, + new FillFlowContainer + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = BeatmapSetOverlay.Y_PADDING, Right = BeatmapSetOverlay.X_PADDING }, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - TextSize = 14, - TextPadding = new MarginPadding { Horizontal = 25, Vertical = 8 } + onlineStatusPill = new BeatmapSetOnlineStatusPill + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + TextSize = 14, + TextPadding = new MarginPadding { Horizontal = 35, Vertical = 10 } + }, + Details = new Details(), }, - Details = new Details(), }, }, }, - }, + } }; Picker.Beatmap.ValueChanged += b => @@ -215,15 +218,16 @@ namespace osu.Game.Overlays.BeatmapSet } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - tabsBg.Colour = colours.Gray3; + coverGradient.Colour = ColourInfo.GradientVertical(colourProvider.Background6.Opacity(0.3f), colourProvider.Background6.Opacity(0.8f)); + onlineStatusPill.BackgroundColour = colourProvider.Background6; State.BindValueChanged(_ => updateDownloadButtons()); BeatmapSet.BindValueChanged(setInfo => { - Picker.BeatmapSet = author.BeatmapSet = beatmapAvailability.BeatmapSet = Details.BeatmapSet = setInfo.NewValue; + Picker.BeatmapSet = RulesetSelector.BeatmapSet = author.BeatmapSet = beatmapAvailability.BeatmapSet = Details.BeatmapSet = setInfo.NewValue; cover.BeatmapSet = setInfo.NewValue; if (setInfo.NewValue == null) @@ -260,7 +264,7 @@ namespace osu.Game.Overlays.BeatmapSet { if (BeatmapSet.Value == null) return; - if (BeatmapSet.Value.OnlineInfo.Availability?.DownloadDisabled ?? false) + if ((BeatmapSet.Value.OnlineInfo.Availability?.DownloadDisabled ?? false) && State.Value != DownloadState.LocallyAvailable) { downloadButtonsContainer.Clear(); return; @@ -270,10 +274,11 @@ namespace osu.Game.Overlays.BeatmapSet { case DownloadState.LocallyAvailable: // temporary for UX until new design is implemented. - downloadButtonsContainer.Child = new PanelDownloadButton(BeatmapSet.Value) + downloadButtonsContainer.Child = new BeatmapPanelDownloadButton(BeatmapSet.Value) { Width = 50, - RelativeSizeAxes = Axes.Y + RelativeSizeAxes = Axes.Y, + SelectedBeatmap = { BindTarget = Picker.Beatmap } }; break; diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index 44827f0a0c..bac658b76e 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -3,27 +3,26 @@ 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.Effects; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays.BeatmapSet { public class Info : Container { private const float transition_duration = 250; - private const float metadata_width = 225; + private const float metadata_width = 175; private const float spacing = 20; + private const float base_height = 220; private readonly Box successRateBackground; + private readonly Box background; private readonly SuccessRate successRate; public readonly Bindable<BeatmapSetInfo> BeatmapSet = new Bindable<BeatmapSetInfo>(); @@ -36,24 +35,17 @@ namespace osu.Game.Overlays.BeatmapSet public Info() { - MetadataSection source, tags; + MetadataSection source, tags, genre, language; + OsuSpriteText unrankedPlaceholder; + RelativeSizeAxes = Axes.X; - Height = 220; - Masking = true; - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.25f), - Type = EdgeEffectType.Shadow, - Radius = 3, - Offset = new Vector2(0f, 1f), - }; + Height = base_height; Children = new Drawable[] { - new Box + background = new Box { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, + RelativeSizeAxes = Axes.Both }, new Container { @@ -83,11 +75,12 @@ namespace osu.Game.Overlays.BeatmapSet { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - LayoutDuration = transition_duration, + Direction = FillDirection.Full, Children = new[] { source = new MetadataSection("Source"), + genre = new MetadataSection("Genre") { Width = 0.5f }, + language = new MetadataSection("Language") { Width = 0.5f }, tags = new MetadataSection("Tags"), }, }, @@ -109,6 +102,14 @@ namespace osu.Game.Overlays.BeatmapSet RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = 20, Horizontal = 15 }, }, + unrankedPlaceholder = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Text = "Unranked beatmap", + Font = OsuFont.GetFont(size: 12) + }, }, }, }, @@ -119,18 +120,24 @@ namespace osu.Game.Overlays.BeatmapSet { source.Text = b.NewValue?.Metadata.Source ?? string.Empty; tags.Text = b.NewValue?.Metadata.Tags ?? string.Empty; + genre.Text = b.NewValue?.OnlineInfo?.Genre?.Name ?? string.Empty; + language.Text = b.NewValue?.OnlineInfo?.Language?.Name ?? string.Empty; + var setHasLeaderboard = b.NewValue?.OnlineInfo?.Status > 0; + successRate.Alpha = setHasLeaderboard ? 1 : 0; + unrankedPlaceholder.Alpha = setHasLeaderboard ? 0 : 1; + Height = setHasLeaderboard ? 270 : base_height; }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - successRateBackground.Colour = colours.GrayE; + successRateBackground.Colour = colourProvider.Background4; + background.Colour = colourProvider.Background5; } private class MetadataSection : FillFlowContainer { - private readonly OsuSpriteText header; private readonly TextFlowContainer textFlow; public string Text @@ -139,22 +146,16 @@ namespace osu.Game.Overlays.BeatmapSet { if (string.IsNullOrEmpty(value)) { - this.FadeOut(transition_duration); + Hide(); return; } this.FadeIn(transition_duration); textFlow.Clear(); - textFlow.AddText(value, s => s.Font = s.Font.With(size: 14)); + textFlow.AddText(value, s => s.Font = s.Font.With(size: 12)); } } - public Color4 TextColour - { - get => textFlow.Colour; - set => textFlow.Colour = value; - } - public MetadataSection(string title) { RelativeSizeAxes = Axes.X; @@ -163,12 +164,11 @@ namespace osu.Game.Overlays.BeatmapSet InternalChildren = new Drawable[] { - header = new OsuSpriteText + new OsuSpriteText { Text = title, Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), - Shadow = false, - Margin = new MarginPadding { Top = 20 }, + Margin = new MarginPadding { Top = 15 }, }, textFlow = new OsuTextFlowContainer { @@ -177,12 +177,6 @@ namespace osu.Game.Overlays.BeatmapSet }, }; } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - header.Colour = textFlow.Colour = colours.Gray5; - } } } } diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs new file mode 100644 index 0000000000..60fd520681 --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs @@ -0,0 +1,144 @@ +// 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. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Mods; +using osu.Framework.Bindables; +using osu.Game.Rulesets; +using osuTK; +using osu.Game.Rulesets.UI; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osuTK.Graphics; +using System; +using System.Linq; +using osu.Framework.Extensions.IEnumerableExtensions; + +namespace osu.Game.Overlays.BeatmapSet +{ + public class LeaderboardModSelector : CompositeDrawable + { + public readonly BindableList<Mod> SelectedMods = new BindableList<Mod>(); + public readonly Bindable<RulesetInfo> Ruleset = new Bindable<RulesetInfo>(); + + private readonly FillFlowContainer<ModButton> modsContainer; + + public LeaderboardModSelector() + { + AutoSizeAxes = Axes.Both; + InternalChild = modsContainer = new FillFlowContainer<ModButton> + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Full, + Spacing = new Vector2(4), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Ruleset.BindValueChanged(onRulesetChanged, true); + } + + private void onRulesetChanged(ValueChangedEvent<RulesetInfo> ruleset) + { + SelectedMods.Clear(); + modsContainer.Clear(); + + if (ruleset.NewValue == null) + return; + + modsContainer.Add(new ModButton(new ModNoMod())); + modsContainer.AddRange(ruleset.NewValue.CreateInstance().GetAllMods().Where(m => m.Ranked).Select(m => new ModButton(m))); + + modsContainer.ForEach(button => button.OnSelectionChanged = selectionChanged); + } + + protected override bool OnHover(HoverEvent e) + { + updateHighlighted(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateHighlighted(); + } + + private void selectionChanged(Mod mod, bool selected) + { + if (selected) + SelectedMods.Add(mod); + else + SelectedMods.Remove(mod); + + updateHighlighted(); + } + + private void updateHighlighted() + { + if (SelectedMods.Any()) + return; + + modsContainer.Children.Where(button => !button.IsHovered).ForEach(button => button.Highlighted.Value = !IsHovered); + } + + public void DeselectAll() => modsContainer.ForEach(mod => mod.Selected.Value = false); + + private class ModButton : ModIcon + { + private const int duration = 200; + + public readonly BindableBool Highlighted = new BindableBool(); + public Action<Mod, bool> OnSelectionChanged; + + public ModButton(Mod mod) + : base(mod) + { + Scale = new Vector2(0.4f); + Add(new HoverClickSounds()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Highlighted.BindValueChanged(highlighted => + { + if (Selected.Value) + return; + + this.FadeColour(highlighted.NewValue ? Color4.White : Color4.DimGray, duration, Easing.OutQuint); + }, true); + + Selected.BindValueChanged(selected => + { + OnSelectionChanged?.Invoke(Mod, selected.NewValue); + Highlighted.TriggerChange(); + }, true); + } + + protected override bool OnClick(ClickEvent e) + { + Selected.Toggle(); + return true; + } + + protected override bool OnHover(HoverEvent e) + { + Highlighted.Value = true; + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + Highlighted.Value = false; + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs new file mode 100644 index 0000000000..607355b7bf --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardScopeSelector.cs @@ -0,0 +1,56 @@ +// 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. + +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Allocation; +using osuTK.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Graphics; + +namespace osu.Game.Overlays.BeatmapSet +{ + public class LeaderboardScopeSelector : GradientLineTabControl<BeatmapLeaderboardScope> + { + protected override bool AddEnumEntriesAutomatically => false; + + protected override TabItem<BeatmapLeaderboardScope> CreateTabItem(BeatmapLeaderboardScope value) => new ScopeSelectorTabItem(value); + + public LeaderboardScopeSelector() + { + AddItem(BeatmapLeaderboardScope.Global); + AddItem(BeatmapLeaderboardScope.Country); + AddItem(BeatmapLeaderboardScope.Friend); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AccentColour = colourProvider.Highlight1; + LineColour = colourProvider.Background1; + } + + private class ScopeSelectorTabItem : PageTabItem + { + public ScopeSelectorTabItem(BeatmapLeaderboardScope value) + : base(value) + { + } + + protected override bool OnHover(HoverEvent e) + { + Text.FadeColour(AccentColour); + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + + Text.FadeColour(Color4.White); + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs b/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs index d263483046..0ae8a8bef5 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/DrawableTopScore.cs @@ -7,8 +7,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Scoring; using osuTK; using osuTK.Graphics; @@ -17,20 +15,15 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { public class DrawableTopScore : CompositeDrawable { - private const float fade_duration = 100; - - private Color4 backgroundIdleColour; - private Color4 backgroundHoveredColour; - private readonly Box background; - public DrawableTopScore(ScoreInfo score, int position = 1) + public DrawableTopScore(ScoreInfo score, int? position = 1) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Masking = true; - CornerRadius = 10; + CornerRadius = 4; EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, @@ -49,7 +42,12 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Padding = new MarginPadding(10), + Padding = new MarginPadding + { + Vertical = 10, + Left = 10, + Right = 30, + }, Children = new Drawable[] { new AutoSizingGrid @@ -84,24 +82,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - backgroundIdleColour = colours.Gray3; - backgroundHoveredColour = colours.Gray4; - - background.Colour = backgroundIdleColour; - } - - protected override bool OnHover(HoverEvent e) - { - background.FadeColour(backgroundHoveredColour, fade_duration, Easing.OutQuint); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - background.FadeColour(backgroundIdleColour, fade_duration, Easing.OutQuint); - base.OnHoverLost(e); + background.Colour = colourProvider.Background4; } private class AutoSizingGrid : GridContainer diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs new file mode 100644 index 0000000000..391ba93a4b --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/Scores/NoScoresPlaceholder.cs @@ -0,0 +1,46 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Screens.Select.Leaderboards; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Overlays.BeatmapSet.Scores +{ + public class NoScoresPlaceholder : Container + { + private readonly SpriteText text; + + public NoScoresPlaceholder() + { + AutoSizeAxes = Axes.Both; + Child = text = new OsuSpriteText(); + } + + public override void Show() => this.FadeIn(200, Easing.OutQuint); + + public override void Hide() => this.FadeOut(200, Easing.OutQuint); + + public void ShowWithScope(BeatmapLeaderboardScope scope) + { + Show(); + + switch (scope) + { + default: + text.Text = @"No scores have been set yet. Maybe you can be the first!"; + break; + + case BeatmapLeaderboardScope.Friend: + text.Text = @"None of your friends have set a score on this map yet."; + break; + + case BeatmapLeaderboardScope.Country: + text.Text = @"No one from your country has set a score on this map yet."; + break; + } + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs b/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs new file mode 100644 index 0000000000..b2c87a1477 --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/Scores/NotSupporterPlaceholder.cs @@ -0,0 +1,49 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.BeatmapSet.Scores +{ + public class NotSupporterPlaceholder : Container + { + public NotSupporterPlaceholder() + { + LinkFlowContainer text; + + AutoSizeAxes = Axes.Both; + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = @"You need to be an osu!supporter to access the friend and country rankings!", + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), + }, + text = new LinkFlowContainer(t => t.Font = t.Font.With(size: 11)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + } + } + }; + + text.AddText("Click "); + text.AddLink("here", "/home/support"); + text.AddText(" to see all the fancy features that you can get!"); + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 347522fb48..324299ccba 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -11,9 +11,11 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osu.Game.Users.Drawables; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -22,8 +24,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores public class ScoreTable : TableContainer { private const float horizontal_inset = 20; - private const float row_height = 25; - private const int text_size = 14; + private const float row_height = 22; + private const int text_size = 12; + + [Resolved] + private ScoreManager scoreManager { get; set; } private readonly FillFlowContainer backgroundFlow; @@ -52,50 +57,83 @@ namespace osu.Game.Overlays.BeatmapSet.Scores highAccuracyColour = colours.GreenLight; } - public IReadOnlyList<ScoreInfo> Scores + /// <summary> + /// The statistics that appear in the table, in order of appearance. + /// </summary> + private readonly List<(HitResult result, string displayName)> statisticResultTypes = new List<(HitResult, string)>(); + + private bool showPerformancePoints; + + public void DisplayScores(IReadOnlyList<ScoreInfo> scores, bool showPerformanceColumn) { - set - { - Content = null; - backgroundFlow.Clear(); + ClearScores(); - if (value?.Any() != true) - return; + if (!scores.Any()) + return; - for (int i = 0; i < value.Count; i++) - backgroundFlow.Add(new ScoreTableRowBackground(i)); + showPerformancePoints = showPerformanceColumn; + statisticResultTypes.Clear(); - Columns = createHeaders(value[0]); - Content = value.Select((s, i) => createContent(i, s)).ToArray().ToRectangular(); - } + for (int i = 0; i < scores.Count; i++) + backgroundFlow.Add(new ScoreTableRowBackground(i, scores[i], row_height)); + + Columns = createHeaders(scores); + Content = scores.Select((s, i) => createContent(i, s)).ToArray().ToRectangular(); } - private TableColumn[] createHeaders(ScoreInfo score) + public void ClearScores() + { + Content = null; + backgroundFlow.Clear(); + } + + private TableColumn[] createHeaders(IReadOnlyList<ScoreInfo> scores) { var columns = new List<TableColumn> { new TableColumn("rank", Anchor.CentreRight, new Dimension(GridSizeMode.AutoSize)), new TableColumn("", Anchor.Centre, new Dimension(GridSizeMode.Absolute, 70)), // grade new TableColumn("score", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("accuracy", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("player", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 150)), - new TableColumn("max combo", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 70, maxSize: 90)) + new TableColumn("accuracy", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, minSize: 60, maxSize: 70)), + new TableColumn("", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, 25)), // flag + new TableColumn("player", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 125)), + new TableColumn("max combo", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 70, maxSize: 120)) }; - foreach (var statistic in score.Statistics) - columns.Add(new TableColumn(statistic.Key.GetDescription(), Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 50, maxSize: 70))); + // All statistics across all scores, unordered. + var allScoreStatistics = scores.SelectMany(s => s.GetStatisticsForDisplay().Select(stat => stat.Result)).ToHashSet(); - columns.AddRange(new[] + var ruleset = scores.First().Ruleset.CreateInstance(); + + foreach (var result in OrderAttributeUtils.GetValuesInOrder<HitResult>()) { - new TableColumn("pp", Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 40, maxSize: 70)), - new TableColumn("mods", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize)), - }); + if (!allScoreStatistics.Contains(result)) + continue; + + // for the time being ignore bonus result types. + // this is not being sent from the API and will be empty in all cases. + if (result.IsBonus()) + continue; + + string displayName = ruleset.GetDisplayNameForHitResult(result); + + columns.Add(new TableColumn(displayName, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 35, maxSize: 60))); + statisticResultTypes.Add((result, displayName)); + } + + if (showPerformancePoints) + columns.Add(new TableColumn("pp", Anchor.CentreLeft, new Dimension(GridSizeMode.Absolute, 30))); + + columns.Add(new TableColumn("mods", Anchor.CentreLeft, new Dimension(GridSizeMode.AutoSize))); return columns.ToArray(); } private Drawable[] createContent(int index, ScoreInfo score) { + var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: text_size)) { AutoSizeAxes = Axes.Both }; + username.AddUserLink(score.User); + var content = new List<Drawable> { new OsuSpriteText @@ -105,78 +143,69 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }, new UpdateableRank(score.Rank) { - Size = new Vector2(30, 20) + Size = new Vector2(28, 14) }, new OsuSpriteText { Margin = new MarginPadding { Right = horizontal_inset }, - Text = $@"{score.TotalScore:N0}", + Current = scoreManager.GetBindableTotalScoreString(score), Font = OsuFont.GetFont(size: text_size, weight: index == 0 ? FontWeight.Bold : FontWeight.Medium) }, new OsuSpriteText { Margin = new MarginPadding { Right = horizontal_inset }, - Text = $@"{score.Accuracy:P2}", + Text = score.DisplayAccuracy, Font = OsuFont.GetFont(size: text_size), Colour = score.Accuracy == 1 ? highAccuracyColour : Color4.White }, - }; - - var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: text_size)) { AutoSizeAxes = Axes.Both }; - username.AddUserLink(score.User); - - content.AddRange(new Drawable[] - { - new FillFlowContainer + new UpdateableFlag(score.User.Country) { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Right = horizontal_inset }, - Spacing = new Vector2(5, 0), - Children = new Drawable[] - { - new UpdateableFlag(score.User.Country) - { - Size = new Vector2(20, 13), - ShowPlaceholderOnNull = false, - }, - username - } + Size = new Vector2(19, 13), + ShowPlaceholderOnNull = false, }, + username, new OsuSpriteText { Text = $@"{score.MaxCombo:N0}x", - Font = OsuFont.GetFont(size: text_size) + Font = OsuFont.GetFont(size: text_size), + Colour = score.MaxCombo == score.Beatmap?.MaxCombo ? highAccuracyColour : Color4.White } - }); + }; - foreach (var kvp in score.Statistics) + var availableStatistics = score.GetStatisticsForDisplay().ToDictionary(tuple => tuple.Result); + + foreach (var result in statisticResultTypes) { + if (!availableStatistics.TryGetValue(result.result, out var stat)) + stat = new HitResultDisplayStatistic(result.result, 0, null, result.displayName); + content.Add(new OsuSpriteText { - Text = $"{kvp.Value}", + Text = stat.MaxCount == null ? $"{stat.Count}" : $"{stat.Count}/{stat.MaxCount}", Font = OsuFont.GetFont(size: text_size), - Colour = kvp.Value == 0 ? Color4.Gray : Color4.White + Colour = stat.Count == 0 ? Color4.Gray : Color4.White }); } - content.AddRange(new Drawable[] + if (showPerformancePoints) { - new OsuSpriteText + content.Add(new OsuSpriteText { Text = $@"{score.PP:N0}", Font = OsuFont.GetFont(size: text_size) - }, - new FillFlowContainer + }); + } + + content.Add(new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(1), + ChildrenEnumerable = score.Mods.Select(m => new ModIcon(m) { - Direction = FillDirection.Horizontal, AutoSizeAxes = Axes.Both, - ChildrenEnumerable = score.Mods.Select(m => new ModIcon(m) - { - AutoSizeAxes = Axes.Both, - Scale = new Vector2(0.3f) - }) - }, + Scale = new Vector2(0.3f) + }) }); return content.ToArray(); @@ -189,7 +218,13 @@ namespace osu.Game.Overlays.BeatmapSet.Scores public HeaderText(string text) { Text = text.ToUpper(); - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Black); + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Bold); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Foreground1; } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs index d820f4d89d..d84e1eff8c 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTableRowBackground.cs @@ -7,6 +7,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Graphics; +using osu.Game.Online.API; +using osu.Game.Scoring; namespace osu.Game.Overlays.BeatmapSet.Scores { @@ -17,12 +19,18 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly Box hoveredBackground; private readonly Box background; - public ScoreTableRowBackground(int index) - { - RelativeSizeAxes = Axes.X; - Height = 25; + private readonly int index; + private readonly ScoreInfo score; - CornerRadius = 3; + public ScoreTableRowBackground(int index, ScoreInfo score, float height) + { + this.index = index; + this.score = score; + + RelativeSizeAxes = Axes.X; + Height = height; + + CornerRadius = 5; Masking = true; InternalChildren = new Drawable[] @@ -37,16 +45,21 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Alpha = 0, }, }; - - if (index % 2 != 0) - background.Alpha = 0; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OverlayColourProvider colourProvider, IAPIProvider api) { - hoveredBackground.Colour = colours.Gray4; - background.Colour = colours.Gray3; + var isOwnScore = api.LocalUser.Value.Id == score.UserID; + + if (isOwnScore) + background.Colour = colours.GreenDarker; + else if (index % 2 == 0) + background.Colour = colourProvider.Background4; + else + background.Alpha = 0; + + hoveredBackground.Colour = isOwnScore ? colours.GreenDark : colourProvider.Background3; } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index a6cc2b0500..a58d662de7 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -5,81 +5,77 @@ 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.UserInterface; using osuTK; using System.Linq; using osu.Game.Online.API.Requests.Responses; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests; +using osu.Framework.Bindables; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Users; namespace osu.Game.Overlays.BeatmapSet.Scores { - public class ScoresContainer : CompositeDrawable + public class ScoresContainer : BeatmapSetLayoutSection { private const int spacing = 15; + public readonly Bindable<BeatmapInfo> Beatmap = new Bindable<BeatmapInfo>(); + private readonly Bindable<RulesetInfo> ruleset = new Bindable<RulesetInfo>(); + private readonly Bindable<BeatmapLeaderboardScope> scope = new Bindable<BeatmapLeaderboardScope>(BeatmapLeaderboardScope.Global); + private readonly Bindable<User> user = new Bindable<User>(); + private readonly Box background; private readonly ScoreTable scoreTable; private readonly FillFlowContainer topScoresContainer; - private readonly LoadingAnimation loadingAnimation; + private readonly LoadingLayer loading; + private readonly LeaderboardModSelector modSelector; + private readonly NoScoresPlaceholder noScoresPlaceholder; + private readonly NotSupporterPlaceholder notSupporterPlaceholder; [Resolved] private IAPIProvider api { get; set; } + [Resolved] + private RulesetStore rulesets { get; set; } + private GetScoresRequest getScoresRequest; - private BeatmapInfo beatmap; - - public BeatmapInfo Beatmap - { - get => beatmap; - set - { - if (beatmap == value) - return; - - beatmap = value; - - getScores(beatmap); - } - } - protected APILegacyScores Scores { - set + set => Schedule(() => { - Schedule(() => + topScoresContainer.Clear(); + + if (value?.Scores.Any() != true) { - topScoresContainer.Clear(); + scoreTable.ClearScores(); + scoreTable.Hide(); + return; + } - if (value?.Scores.Any() != true) - { - scoreTable.Scores = null; - scoreTable.Hide(); - return; - } + var scoreInfos = value.Scores.Select(s => s.CreateScoreInfo(rulesets)).ToList(); + var topScore = scoreInfos.First(); - scoreTable.Scores = value.Scores; - scoreTable.Show(); + scoreTable.DisplayScores(scoreInfos, topScore.Beatmap?.Status == BeatmapSetOnlineStatus.Ranked); + scoreTable.Show(); - var topScore = value.Scores.First(); - var userScore = value.UserScore; + var userScore = value.UserScore; + var userScoreInfo = userScore?.Score.CreateScoreInfo(rulesets); - topScoresContainer.Add(new DrawableTopScore(topScore)); + topScoresContainer.Add(new DrawableTopScore(topScore)); - if (userScore != null && userScore.Score.OnlineScoreID != topScore.OnlineScoreID) - topScoresContainer.Add(new DrawableTopScore(userScore.Score, userScore.Position)); - }); - } + if (userScoreInfo != null && userScoreInfo.OnlineScoreID != topScore.OnlineScoreID) + topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); + }); } public ScoresContainer() { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - InternalChildren = new Drawable[] + AddRange(new Drawable[] { background = new Box { @@ -91,58 +87,169 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Origin = Anchor.TopCentre, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Width = 0.95f, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, spacing), - Margin = new MarginPadding { Vertical = spacing }, + Padding = new MarginPadding { Horizontal = 50 }, + Margin = new MarginPadding { Vertical = 20 }, Children = new Drawable[] { - topScoresContainer = new FillFlowContainer + new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), + Spacing = new Vector2(0, spacing), + Children = new Drawable[] + { + new LeaderboardScopeSelector + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Current = { BindTarget = scope } + }, + modSelector = new LeaderboardModSelector + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Ruleset = { BindTarget = ruleset } + } + } }, - scoreTable = new ScoreTable + new Container { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Top = spacing }, + Children = new Drawable[] + { + noScoresPlaceholder = new NoScoresPlaceholder + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Alpha = 0, + AlwaysPresent = true, + Margin = new MarginPadding { Vertical = 10 } + }, + notSupporterPlaceholder = new NotSupporterPlaceholder + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Alpha = 0, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, spacing), + Children = new Drawable[] + { + topScoresContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + }, + scoreTable = new ScoreTable + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + } + } + }, + loading = new LoadingLayer() + } } } - }, - loadingAnimation = new LoadingAnimation - { - Alpha = 0, - Margin = new MarginPadding(20), - }, - }; + } + }); } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - background.Colour = colours.Gray2; + background.Colour = colourProvider.Background5; + + user.BindTo(api.LocalUser); } - private void getScores(BeatmapInfo beatmap) + protected override void LoadComplete() + { + base.LoadComplete(); + scope.BindValueChanged(_ => getScores()); + ruleset.BindValueChanged(_ => getScores()); + + modSelector.SelectedMods.CollectionChanged += (_, __) => getScores(); + + Beatmap.BindValueChanged(onBeatmapChanged); + user.BindValueChanged(onUserChanged, true); + } + + private void onBeatmapChanged(ValueChangedEvent<BeatmapInfo> beatmap) + { + var beatmapRuleset = beatmap.NewValue?.Ruleset; + + if (ruleset.Value?.Equals(beatmapRuleset) ?? false) + { + modSelector.DeselectAll(); + ruleset.TriggerChange(); + } + else + ruleset.Value = beatmapRuleset; + + scope.Value = BeatmapLeaderboardScope.Global; + } + + private void onUserChanged(ValueChangedEvent<User> user) + { + if (modSelector.SelectedMods.Any()) + modSelector.DeselectAll(); + else + getScores(); + + modSelector.FadeTo(userIsSupporter ? 1 : 0); + } + + private void getScores() { getScoresRequest?.Cancel(); getScoresRequest = null; - Scores = null; + noScoresPlaceholder.Hide(); - if (beatmap?.OnlineBeatmapID.HasValue != true) + if (Beatmap.Value?.OnlineBeatmapID.HasValue != true || Beatmap.Value.Status <= BeatmapSetOnlineStatus.Pending) + { + Scores = null; + Hide(); return; + } - loadingAnimation.Show(); - getScoresRequest = new GetScoresRequest(beatmap, beatmap.Ruleset); + if (scope.Value != BeatmapLeaderboardScope.Global && !userIsSupporter) + { + Scores = null; + notSupporterPlaceholder.Show(); + loading.Hide(); + return; + } + + notSupporterPlaceholder.Hide(); + + Show(); + loading.Show(); + + getScoresRequest = new GetScoresRequest(Beatmap.Value, Beatmap.Value.Ruleset, scope.Value, modSelector.SelectedMods); getScoresRequest.Success += scores => { - loadingAnimation.Hide(); + loading.Hide(); Scores = scores; + + if (!scores.Scores.Any()) + noScoresPlaceholder.ShowWithScope(scope.Value); }; + api.Queue(getScoresRequest); } + + private bool userIsSupporter => api.IsLoggedIn && api.LocalUser.Value.IsSupporter; } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index 6761d0f710..93744dd6a3 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -4,16 +4,16 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Extensions; +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.Localisation; +using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osuTK; @@ -23,9 +23,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores public class TopScoreStatisticsSection : CompositeDrawable { private const float margin = 10; + private const float top_columns_min_width = 64; + private const float bottom_columns_min_width = 45; - private readonly FontUsage smallFont = OsuFont.GetFont(size: 20); - private readonly FontUsage largeFont = OsuFont.GetFont(size: 25); + private readonly FontUsage smallFont = OsuFont.GetFont(size: 16); + private readonly FontUsage largeFont = OsuFont.GetFont(size: 22, weight: FontWeight.Light); private readonly TextColumn totalScoreColumn; private readonly TextColumn accuracyColumn; @@ -35,6 +37,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly FillFlowContainer<InfoColumn> statisticsColumns; private readonly ModsInfoColumn modsColumn; + [Resolved] + private ScoreManager scoreManager { get; set; } + public TopScoreStatisticsSection() { RelativeSizeAxes = Axes.X; @@ -44,9 +49,23 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10, 0), + Direction = FillDirection.Vertical, Children = new Drawable[] { + new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(margin, 0), + Children = new Drawable[] + { + totalScoreColumn = new TextColumn("total score", largeFont, top_columns_min_width), + accuracyColumn = new TextColumn("accuracy", largeFont, top_columns_min_width), + maxComboColumn = new TextColumn("max combo", largeFont, top_columns_min_width) + } + }, new FillFlowContainer { Anchor = Anchor.TopRight, @@ -62,28 +81,23 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Direction = FillDirection.Horizontal, Spacing = new Vector2(margin, 0), }, - ppColumn = new TextColumn("pp", smallFont), + ppColumn = new TextColumn("pp", smallFont, bottom_columns_min_width), modsColumn = new ModsInfoColumn(), } }, - new FillFlowContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(margin, 0), - Children = new Drawable[] - { - totalScoreColumn = new TextColumn("total score", largeFont), - accuracyColumn = new TextColumn("accuracy", largeFont), - maxComboColumn = new TextColumn("max combo", largeFont) - } - }, } }; } + [BackgroundDependencyLoader] + private void load() + { + if (score != null) + totalScoreColumn.Current = scoreManager.GetBindableTotalScoreString(score); + } + + private ScoreInfo score; + /// <summary> /// Sets the score to be displayed. /// </summary> @@ -91,55 +105,87 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { set { - totalScoreColumn.Text = $@"{value.TotalScore:N0}"; - accuracyColumn.Text = $@"{value.Accuracy:P2}"; + if (score == value) + return; + + score = value; + + accuracyColumn.Text = value.DisplayAccuracy; maxComboColumn.Text = $@"{value.MaxCombo:N0}x"; + ppColumn.Alpha = value.Beatmap?.Status == BeatmapSetOnlineStatus.Ranked ? 1 : 0; ppColumn.Text = $@"{value.PP:N0}"; - statisticsColumns.ChildrenEnumerable = value.Statistics.Select(kvp => createStatisticsColumn(kvp.Key, kvp.Value)); + statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(createStatisticsColumn); modsColumn.Mods = value.Mods; + + if (scoreManager != null) + totalScoreColumn.Current = scoreManager.GetBindableTotalScoreString(value); } } - private TextColumn createStatisticsColumn(HitResult hitResult, int count) => new TextColumn(hitResult.GetDescription(), smallFont) + private TextColumn createStatisticsColumn(HitResultDisplayStatistic stat) => new TextColumn(stat.DisplayName, smallFont, bottom_columns_min_width) { - Text = count.ToString() + Text = stat.MaxCount == null ? $"{stat.Count}" : $"{stat.Count}/{stat.MaxCount}" }; private class InfoColumn : CompositeDrawable { private readonly Box separator; + private readonly OsuSpriteText text; - public InfoColumn(string title, Drawable content) + public InfoColumn(string title, Drawable content, float? minWidth = null) { AutoSizeAxes = Axes.Both; + Margin = new MarginPadding { Vertical = 5 }; - InternalChild = new FillFlowContainer + InternalChild = new GridContainer { AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 2), - Children = new[] + ColumnDimensions = new[] { - new OsuSpriteText + new Dimension(GridSizeMode.AutoSize, minSize: minWidth ?? 0) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 2), + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] { - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Black), - Text = title.ToUpper() + text = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Bold), + Text = title.ToUpper(), + // 2px padding bottom + 1px vertical to compensate for the additional spacing because of 1.25 line-height in osu-web + Padding = new MarginPadding { Top = 1, Bottom = 3 } + } }, - separator = new Box + new Drawable[] { - RelativeSizeAxes = Axes.X, - Height = 2 + separator = new Box + { + Anchor = Anchor.TopLeft, + RelativeSizeAxes = Axes.X, + Height = 2, + }, }, - content + new[] + { + // osu-web has 4px margin here but also uses 0.9 line-height, reducing margin to 2px seems like a good alternative to that + content.With(c => c.Margin = new MarginPadding { Top = 2 }) + } } }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - separator.Colour = colours.Gray5; + text.Colour = colourProvider.Foreground1; + separator.Colour = colourProvider.Background3; } } @@ -147,13 +193,13 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { private readonly SpriteText text; - public TextColumn(string title, FontUsage font) - : this(title, new OsuSpriteText { Font = font }) + public TextColumn(string title, FontUsage font, float? minWidth = null) + : this(title, new OsuSpriteText { Font = font }, minWidth) { } - private TextColumn(string title, SpriteText text) - : base(title, text) + private TextColumn(string title, SpriteText text, float? minWidth = null) + : base(title, text, minWidth) { this.text = text; } @@ -162,6 +208,12 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { set => text.Text = value; } + + public Bindable<string> Current + { + get => text.Current; + set => text.Current = value; + } } private class ModsInfoColumn : InfoColumn @@ -171,8 +223,10 @@ namespace osu.Game.Overlays.BeatmapSet.Scores public ModsInfoColumn() : this(new FillFlowContainer { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(1), + Height = 18f }) { } @@ -188,15 +242,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores set { modsContainer.Clear(); - - foreach (Mod mod in value) + modsContainer.Children = value.Select(mod => new ModIcon(mod) { - modsContainer.Add(new ModIcon(mod) - { - AutoSizeAxes = Axes.Both, - Scale = new Vector2(0.3f), - }); - } + AutoSizeAxes = Axes.Both, + Scale = new Vector2(0.25f), + }).ToList(); } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs index ffc39e5af2..9111a0cfc7 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs @@ -1,8 +1,7 @@ // 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. -using Humanizer; -using osu.Framework.Allocation; +using System; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -25,7 +24,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores private readonly UpdateableRank rank; private readonly UpdateableAvatar avatar; private readonly LinkFlowContainer usernameText; - private readonly SpriteText date; + private readonly DrawableDate achievedOn; private readonly UpdateableFlag flag; public TopScoreUserSection() @@ -51,13 +50,13 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 24, weight: FontWeight.Bold, italics: true) + Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold) }, rank = new UpdateableRank(ScoreRank.D) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(40), + Size = new Vector2(28), FillMode = FillMode.Fit, }, } @@ -66,9 +65,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(80), + Size = new Vector2(70), Masking = true, - CornerRadius = 5, + CornerRadius = 4, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, @@ -87,23 +86,37 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Spacing = new Vector2(0, 3), Children = new Drawable[] { - usernameText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold, italics: true)) + usernameText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold, italics: true)) { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, AutoSizeAxes = Axes.Both, }, - date = new OsuSpriteText + new FillFlowContainer { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold) + Children = new[] + { + new OsuSpriteText + { + Text = "achieved ", + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Bold) + }, + achievedOn = new DrawableDate(DateTimeOffset.MinValue) + { + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Bold) + }, + } }, flag = new UpdateableFlag { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(20, 13), + Size = new Vector2(19, 13), + Margin = new MarginPadding { Top = 3 }, // makes spacing look more even ShowPlaceholderOnNull = false, }, } @@ -112,15 +125,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + public int? ScorePosition { - rankText.Colour = colours.Yellow; - } - - public int ScorePosition - { - set => rankText.Text = $"#{value}"; + set => rankText.Text = value == null ? "-" : $"#{value}"; } /// <summary> @@ -132,7 +139,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { avatar.User = value.User; flag.Country = value.User.Country; - date.Text = $@"achieved {value.Date.Humanize()}"; + achievedOn.Date = value.Date; usernameText.Clear(); usernameText.AddUserLink(value.User); diff --git a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs index 0258a0301a..3bb36545cd 100644 --- a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs +++ b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs @@ -17,7 +17,7 @@ namespace osu.Game.Overlays.BeatmapSet protected readonly FailRetryGraph Graph; private readonly FillFlowContainer header; - private readonly OsuSpriteText successRateLabel, successPercent, graphLabel; + private readonly OsuSpriteText successPercent; private readonly Bar successRate; private readonly Container percentContainer; @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.BeatmapSet int playCount = beatmap?.OnlineInfo?.PlayCount ?? 0; var rate = playCount != 0 ? (float)passCount / playCount : 0; - successPercent.Text = rate.ToString("P0"); + successPercent.Text = rate.ToString("0.#%"); successRate.Length = rate; percentContainer.ResizeWidthTo(successRate.Length, 250, Easing.InOutCubic); @@ -60,12 +60,12 @@ namespace osu.Game.Overlays.BeatmapSet Direction = FillDirection.Vertical, Children = new Drawable[] { - successRateLabel = new OsuSpriteText + new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = "Success Rate", - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12) }, successRate = new Bar { @@ -82,15 +82,15 @@ namespace osu.Game.Overlays.BeatmapSet { Anchor = Anchor.TopRight, Origin = Anchor.TopCentre, - Font = OsuFont.GetFont(size: 13), + Font = OsuFont.GetFont(size: 12), }, }, - graphLabel = new OsuSpriteText + new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = "Points of Failure", - Font = OsuFont.GetFont(size: 13), + Font = OsuFont.GetFont(size: 12), Margin = new MarginPadding { Vertical = 20 }, }, }, @@ -105,11 +105,10 @@ namespace osu.Game.Overlays.BeatmapSet } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OverlayColourProvider colourProvider) { - successRateLabel.Colour = successPercent.Colour = graphLabel.Colour = colours.Gray5; successRate.AccentColour = colours.Green; - successRate.BackgroundColour = colours.GrayD; + successRate.BackgroundColour = colourProvider.Background6; updateDisplay(); } diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index c20e6368d8..bbec62a85a 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -9,57 +9,79 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Beatmaps; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests; using osu.Game.Overlays.BeatmapSet; using osu.Game.Overlays.BeatmapSet.Scores; +using osu.Game.Overlays.Comments; using osu.Game.Rulesets; using osuTK; namespace osu.Game.Overlays { - public class BeatmapSetOverlay : FullscreenOverlay + public class BeatmapSetOverlay : FullscreenOverlay<OverlayHeader> // we don't provide a standard header for now. { public const float X_PADDING = 40; - public const float TOP_PADDING = 25; + public const float Y_PADDING = 25; public const float RIGHT_WIDTH = 275; - protected readonly Header Header; - private RulesetStore rulesets; + //todo: should be an OverlayHeader? or maybe not? + protected new readonly Header Header; + + [Resolved] + private RulesetStore rulesets { get; set; } private readonly Bindable<BeatmapSetInfo> beatmapSet = new Bindable<BeatmapSetInfo>(); // receive input outside our bounds so we can trigger a close event on ourselves. public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + private readonly Box background; + public BeatmapSetOverlay() + : base(OverlayColourScheme.Blue, null) { - OsuScrollContainer scroll; + OverlayScrollContainer scroll; Info info; - ScoresContainer scoreContainer; + CommentsSection comments; Children = new Drawable[] { - new Box + background = new Box { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.2f) + RelativeSizeAxes = Axes.Both }, - scroll = new OsuScrollContainer + scroll = new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, - Child = new ReverseChildIDFillFlowContainer<Drawable> + Child = new ReverseChildIDFillFlowContainer<BeatmapSetLayoutSection> { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Children = new Drawable[] + Spacing = new Vector2(0, 20), + Children = new[] { - Header = new Header(), - info = new Info(), - scoreContainer = new ScoresContainer(), + new BeatmapSetLayoutSection + { + Child = new ReverseChildIDFillFlowContainer<Drawable> + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + Header = new Header(), + info = new Info() + } + }, + }, + new ScoresContainer + { + Beatmap = { BindTarget = Header.Picker.Beatmap } + }, + comments = new CommentsSection() }, }, }, @@ -67,20 +89,20 @@ namespace osu.Game.Overlays Header.BeatmapSet.BindTo(beatmapSet); info.BeatmapSet.BindTo(beatmapSet); + comments.BeatmapSet.BindTo(beatmapSet); Header.Picker.Beatmap.ValueChanged += b => { info.Beatmap = b.NewValue; - scoreContainer.Beatmap = b.NewValue; scroll.ScrollToStart(); }; } [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) + private void load() { - this.rulesets = rulesets; + background.Colour = ColourProvider.Background6; } protected override void PopOutComplete() @@ -130,5 +152,30 @@ namespace osu.Game.Overlays beatmapSet.Value = set; Show(); } + + private class CommentsSection : BeatmapSetLayoutSection + { + public readonly Bindable<BeatmapSetInfo> BeatmapSet = new Bindable<BeatmapSetInfo>(); + + public CommentsSection() + { + CommentsContainer comments; + + Add(comments = new CommentsContainer()); + + BeatmapSet.BindValueChanged(beatmapSet => + { + if (beatmapSet.NewValue?.OnlineBeatmapSetID is int onlineBeatmapSetID) + { + Show(); + comments.ShowComments(CommentableType.Beatmapset, onlineBeatmapSetID); + } + else + { + Hide(); + } + }, true); + } + } } } diff --git a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs new file mode 100644 index 0000000000..81315f9638 --- /dev/null +++ b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs @@ -0,0 +1,53 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays +{ + public abstract class BreadcrumbControlOverlayHeader : TabControlOverlayHeader<string> + { + protected override OsuTabControl<string> CreateTabControl() => new OverlayHeaderBreadcrumbControl(); + + public class OverlayHeaderBreadcrumbControl : BreadcrumbControl<string> + { + public OverlayHeaderBreadcrumbControl() + { + RelativeSizeAxes = Axes.X; + Height = 47; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AccentColour = colourProvider.Light2; + } + + protected override TabItem<string> CreateTabItem(string value) => new ControlTabItem(value); + + private class ControlTabItem : BreadcrumbTabItem + { + protected override float ChevronSize => 8; + + public ControlTabItem(string value) + : base(value) + { + RelativeSizeAxes = Axes.Y; + Text.Font = Text.Font.With(size: 14); + Text.Anchor = Anchor.CentreLeft; + Text.Origin = Anchor.CentreLeft; + Chevron.Y = 1; + Bar.Height = 0; + } + + // base OsuTabItem makes font bold on activation, we don't want that here + protected override void OnActivated() => FadeHovered(); + + protected override void OnDeactivated() => FadeUnhovered(); + } + } + } +} diff --git a/osu.Game/Overlays/Changelog/ChangelogBuild.cs b/osu.Game/Overlays/Changelog/ChangelogBuild.cs index 3d145af562..48bf6c2ddd 100644 --- a/osu.Game/Overlays/Changelog/ChangelogBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogBuild.cs @@ -14,6 +14,9 @@ using osu.Game.Graphics.Sprites; using osu.Game.Users; using osuTK.Graphics; using osu.Framework.Allocation; +using System.Net; +using osuTK; +using osu.Framework.Extensions.Color4Extensions; namespace osu.Game.Overlays.Changelog { @@ -49,39 +52,51 @@ namespace osu.Game.Overlays.Changelog } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OverlayColourProvider colourProvider) { foreach (var categoryEntries in Build.ChangelogEntries.GroupBy(b => b.Category).OrderBy(c => c.Key)) { ChangelogEntries.Add(new OsuSpriteText { Text = categoryEntries.Key, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 24), + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 18), Margin = new MarginPadding { Top = 35, Bottom = 15 }, }); - var fontLarge = OsuFont.GetFont(size: 18); - var fontMedium = OsuFont.GetFont(size: 14); - var fontSmall = OsuFont.GetFont(size: 12); + var fontLarge = OsuFont.GetFont(size: 16); + var fontMedium = OsuFont.GetFont(size: 12); - foreach (APIChangelogEntry entry in categoryEntries) + foreach (var entry in categoryEntries) { - LinkFlowContainer title = new LinkFlowContainer - { - Direction = FillDirection.Full, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Vertical = 5 }, - }; - var entryColour = entry.Major ? colours.YellowLight : Color4.White; - title.AddIcon(FontAwesome.Solid.Check, t => + LinkFlowContainer title; + + var titleContainer = new Container { - t.Font = fontSmall; - t.Colour = entryColour; - t.Padding = new MarginPadding { Left = -17, Right = 5 }; - }); + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Vertical = 5 }, + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Size = new Vector2(10), + Icon = entry.Type == ChangelogEntryType.Fix ? FontAwesome.Solid.Check : FontAwesome.Solid.Plus, + Colour = entryColour.Opacity(0.5f), + Margin = new MarginPadding { Right = 5 }, + }, + title = new LinkFlowContainer + { + Direction = FillDirection.Full, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.BottomLeft, + } + } + }; title.AddText(entry.Title, t => { @@ -96,7 +111,7 @@ namespace osu.Game.Overlays.Changelog t.Font = fontLarge; t.Colour = entryColour; }); - title.AddLink($"{entry.Repository.Replace("ppy/", "")}#{entry.GithubPullRequestId}", entry.GithubUrl, Online.Chat.LinkAction.External, + title.AddLink($"{entry.Repository.Replace("ppy/", "")}#{entry.GithubPullRequestId}", entry.GithubUrl, creationParameters: t => { t.Font = fontLarge; @@ -109,13 +124,15 @@ namespace osu.Game.Overlays.Changelog }); } - title.AddText(" by ", t => + title.AddText("by ", t => { t.Font = fontMedium; t.Colour = entryColour; + t.Padding = new MarginPadding { Left = 10 }; }); if (entry.GithubUser.UserId != null) + { title.AddUserLink(new User { Username = entry.GithubUser.OsuUsername, @@ -125,34 +142,39 @@ namespace osu.Game.Overlays.Changelog t.Font = fontMedium; t.Colour = entryColour; }); + } else if (entry.GithubUser.GithubUrl != null) - title.AddLink(entry.GithubUser.DisplayName, entry.GithubUser.GithubUrl, Online.Chat.LinkAction.External, null, null, t => + { + title.AddLink(entry.GithubUser.DisplayName, entry.GithubUser.GithubUrl, t => { t.Font = fontMedium; t.Colour = entryColour; }); + } else + { title.AddText(entry.GithubUser.DisplayName, t => { - t.Font = fontSmall; + t.Font = fontMedium; t.Colour = entryColour; }); + } - ChangelogEntries.Add(title); + ChangelogEntries.Add(titleContainer); if (!string.IsNullOrEmpty(entry.MessageHtml)) { - TextFlowContainer message = new TextFlowContainer + var message = new TextFlowContainer { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, }; // todo: use markdown parsing once API returns markdown - message.AddText(Regex.Replace(entry.MessageHtml, @"<(.|\n)*?>", string.Empty), t => + message.AddText(WebUtility.HtmlDecode(Regex.Replace(entry.MessageHtml, @"<(.|\n)*?>", string.Empty)), t => { - t.Font = fontSmall; - t.Colour = new Color4(235, 184, 254, 255); + t.Font = fontMedium; + t.Colour = colourProvider.Foreground1; }); ChangelogEntries.Add(message); diff --git a/osu.Game/Overlays/Changelog/ChangelogContent.cs b/osu.Game/Overlays/Changelog/ChangelogContent.cs index f8d5bbd66c..49dd9bb835 100644 --- a/osu.Game/Overlays/Changelog/ChangelogContent.cs +++ b/osu.Game/Overlays/Changelog/ChangelogContent.cs @@ -19,7 +19,6 @@ namespace osu.Game.Overlays.Changelog RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Direction = FillDirection.Vertical; - Padding = new MarginPadding { Bottom = 100 }; } } } diff --git a/osu.Game/Overlays/Changelog/ChangelogHeader.cs b/osu.Game/Overlays/Changelog/ChangelogHeader.cs index fca62fbb44..f4be4328e7 100644 --- a/osu.Game/Overlays/Changelog/ChangelogHeader.cs +++ b/osu.Game/Overlays/Changelog/ChangelogHeader.cs @@ -2,57 +2,53 @@ // 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.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; -using osuTK; namespace osu.Game.Overlays.Changelog { - public class ChangelogHeader : OverlayHeader + public class ChangelogHeader : BreadcrumbControlOverlayHeader { - public readonly Bindable<APIChangelogBuild> Current = new Bindable<APIChangelogBuild>(); + public readonly Bindable<APIChangelogBuild> Build = new Bindable<APIChangelogBuild>(); public Action ListingSelected; - public UpdateStreamBadgeArea Streams; + public ChangelogUpdateStreamControl Streams; - private const string listing_string = "Listing"; + private const string listing_string = "listing"; + + private Box streamsBackground; public ChangelogHeader() { TabControl.AddItem(listing_string); - TabControl.Current.ValueChanged += e => + Current.ValueChanged += e => { if (e.NewValue == listing_string) ListingSelected?.Invoke(); }; - Current.ValueChanged += showBuild; + Build.ValueChanged += showBuild; Streams.Current.ValueChanged += e => { - if (e.NewValue?.LatestBuild != null && e.NewValue != Current.Value?.UpdateStream) - Current.Value = e.NewValue.LatestBuild; + if (e.NewValue?.LatestBuild != null && !e.NewValue.Equals(Build.Value?.UpdateStream)) + Build.Value = e.NewValue.LatestBuild; }; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - TabControl.AccentColour = colours.Violet; + streamsBackground.Colour = colourProvider.Background5; } - private ChangelogHeaderTitle title; - private void showBuild(ValueChangedEvent<APIChangelogBuild> e) { if (e.OldValue != null) @@ -61,21 +57,18 @@ namespace osu.Game.Overlays.Changelog if (e.NewValue != null) { TabControl.AddItem(e.NewValue.ToString()); - TabControl.Current.Value = e.NewValue.ToString(); + Current.Value = e.NewValue.ToString(); - Streams.Current.Value = Streams.Items.FirstOrDefault(s => s.Name == e.NewValue.UpdateStream.Name); - - title.Version = e.NewValue.UpdateStream.DisplayName; + updateCurrentStream(); } else { - TabControl.Current.Value = listing_string; + Current.Value = listing_string; Streams.Current.Value = null; - title.Version = null; } } - protected override Drawable CreateBackground() => new HeaderBackground(); + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/changelog"); protected override Drawable CreateContent() => new Container { @@ -83,87 +76,47 @@ namespace osu.Game.Overlays.Changelog AutoSizeAxes = Axes.Y, Children = new Drawable[] { - Streams = new UpdateStreamBadgeArea(), + streamsBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 65, + Vertical = 20 + }, + Child = Streams = new ChangelogUpdateStreamControl() + } } }; - protected override ScreenTitle CreateTitle() => title = new ChangelogHeaderTitle(); + protected override OverlayTitle CreateTitle() => new ChangelogHeaderTitle(); - public class HeaderBackground : Sprite + public void Populate(List<APIUpdateStream> streams) { - public HeaderBackground() - { - RelativeSizeAxes = Axes.Both; - FillMode = FillMode.Fill; - } - - [BackgroundDependencyLoader] - private void load(TextureStore textures) - { - Texture = textures.Get(@"Headers/changelog"); - } + Streams.Populate(streams); + updateCurrentStream(); } - private class ChangelogHeaderTitle : ScreenTitle + private void updateCurrentStream() { - public string Version - { - set => Section = value ?? listing_string; - } + if (Build.Value == null) + return; + Streams.Current.Value = Streams.Items.FirstOrDefault(s => s.Name == Build.Value.UpdateStream.Name); + } + + private class ChangelogHeaderTitle : OverlayTitle + { public ChangelogHeaderTitle() { - Title = "Changelog"; - Version = null; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AccentColour = colours.Violet; - } - - protected override Drawable CreateIcon() => new ChangelogIcon(); - - internal class ChangelogIcon : CompositeDrawable - { - private const float circle_allowance = 0.8f; - - [BackgroundDependencyLoader] - private void load(TextureStore textures, OsuColour colours) - { - Size = new Vector2(ICON_SIZE / circle_allowance); - - InternalChildren = new Drawable[] - { - new CircularContainer - { - Masking = true, - BorderColour = colours.Violet, - BorderThickness = 3, - MaskingSmoothness = 1, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Sprite - { - RelativeSizeAxes = Axes.Both, - Texture = textures.Get(@"Icons/changelog"), - Size = new Vector2(circle_allowance), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colours.Violet, - Alpha = 0, - AlwaysPresent = true, - }, - } - }, - }; - } + Title = "changelog"; + Description = "track recent dev updates in the osu! ecosystem"; + IconTexture = "Icons/Hexacons/devtools"; } } } diff --git a/osu.Game/Overlays/Changelog/ChangelogListing.cs b/osu.Game/Overlays/Changelog/ChangelogListing.cs index 41d8228475..9b74a8da6d 100644 --- a/osu.Game/Overlays/Changelog/ChangelogListing.cs +++ b/osu.Game/Overlays/Changelog/ChangelogListing.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; -using osuTK.Graphics; namespace osu.Game.Overlays.Changelog { @@ -24,13 +23,13 @@ namespace osu.Game.Overlays.Changelog } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { - DateTime currentDate = DateTime.MinValue; + var currentDate = DateTime.MinValue; if (entries == null) return; - foreach (APIChangelogBuild build in entries) + foreach (var build in entries) { if (build.CreatedAt.Date != currentDate) { @@ -40,7 +39,7 @@ namespace osu.Game.Overlays.Changelog { RelativeSizeAxes = Axes.X, Height = 2, - Colour = new Color4(17, 17, 17, 255), + Colour = colourProvider.Background6, Margin = new MarginPadding { Top = 30 }, }); } @@ -49,10 +48,9 @@ namespace osu.Game.Overlays.Changelog { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Margin = new MarginPadding { Top = 15 }, - Text = build.CreatedAt.Date.ToString("dd MMM yyyy"), + Margin = new MarginPadding { Top = 20 }, + Text = build.CreatedAt.Date.ToString("dd MMMM yyyy"), Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 24), - Colour = OsuColour.FromHex(@"FD5"), }); currentDate = build.CreatedAt.Date; @@ -68,7 +66,7 @@ namespace osu.Game.Overlays.Changelog Child = new Box { RelativeSizeAxes = Axes.Both, - Colour = new Color4(32, 24, 35, 255), + Colour = colourProvider.Background6, } }); } diff --git a/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs b/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs index 9c3504f477..8b89d63aab 100644 --- a/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs +++ b/osu.Game/Overlays/Changelog/ChangelogSingleBuild.cs @@ -4,11 +4,11 @@ using System; using System.Linq; using System.Threading; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -17,6 +17,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Comments; using osuTK; namespace osu.Game.Overlays.Changelog @@ -31,7 +32,7 @@ namespace osu.Game.Overlays.Changelog } [BackgroundDependencyLoader] - private void load(CancellationToken? cancellation, IAPIProvider api) + private void load(CancellationToken? cancellation, IAPIProvider api, OverlayColourProvider colourProvider) { bool complete = false; @@ -43,8 +44,7 @@ namespace osu.Game.Overlays.Changelog }; req.Failure += _ => complete = true; - // This is done on a separate thread to support cancellation below - Task.Run(() => req.Perform(api)); + api.PerformAsync(req); while (!complete) { @@ -58,11 +58,24 @@ namespace osu.Game.Overlays.Changelog } if (build != null) + { + CommentsContainer comments; + Children = new Drawable[] { new ChangelogBuildWithNavigation(build) { SelectBuild = SelectBuild }, - new Comments(build) + new Box + { + RelativeSizeAxes = Axes.X, + Height = 2, + Colour = colourProvider.Background6, + Margin = new MarginPadding { Top = 30 }, + }, + comments = new CommentsContainer() }; + + comments.ShowComments(CommentableType.Build, build.Id); + } } public class ChangelogBuildWithNavigation : ChangelogBuild @@ -72,6 +85,8 @@ namespace osu.Game.Overlays.Changelog { } + private OsuSpriteText date; + protected override FillFlowContainer CreateHeader() { var fill = base.CreateHeader(); @@ -81,11 +96,10 @@ namespace osu.Game.Overlays.Changelog existing.Scale = new Vector2(1.25f); existing.Action = null; - existing.Add(new OsuSpriteText + existing.Add(date = new OsuSpriteText { - Text = Build.CreatedAt.Date.ToString("dd MMM yyyy"), + Text = Build.CreatedAt.Date.ToString("dd MMMM yyyy"), Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 14), - Colour = OsuColour.FromHex(@"FD5"), Anchor = Anchor.BottomCentre, Origin = Anchor.TopCentre, Margin = new MarginPadding { Top = 5 }, @@ -105,6 +119,12 @@ namespace osu.Game.Overlays.Changelog return fill; } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + date.Colour = colourProvider.Light1; + } } private class NavigationIconButton : IconButton diff --git a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamControl.cs b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamControl.cs new file mode 100644 index 0000000000..509a6dabae --- /dev/null +++ b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamControl.cs @@ -0,0 +1,12 @@ +// 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. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Overlays.Changelog +{ + public class ChangelogUpdateStreamControl : OverlayStreamControl<APIUpdateStream> + { + protected override OverlayStreamItem<APIUpdateStream> CreateStreamItem(APIUpdateStream value) => new ChangelogUpdateStreamItem(value); + } +} diff --git a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs new file mode 100644 index 0000000000..f8e1ac0c84 --- /dev/null +++ b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs @@ -0,0 +1,28 @@ +// 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. + +using Humanizer; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Changelog +{ + public class ChangelogUpdateStreamItem : OverlayStreamItem<APIUpdateStream> + { + public ChangelogUpdateStreamItem(APIUpdateStream stream) + : base(stream) + { + if (stream.IsFeatured) + Width *= 2; + } + + protected override string MainText => Value.DisplayName; + + protected override string AdditionalText => Value.LatestBuild.DisplayVersion; + + protected override string InfoText => Value.LatestBuild.Users > 0 ? $"{"user".ToQuantity(Value.LatestBuild.Users, "N0")} online" : null; + + protected override Color4 GetBarColour(OsuColour colours) => Value.Colour; + } +} diff --git a/osu.Game/Overlays/Changelog/Comments.cs b/osu.Game/Overlays/Changelog/Comments.cs deleted file mode 100644 index 4cf39e7b44..0000000000 --- a/osu.Game/Overlays/Changelog/Comments.cs +++ /dev/null @@ -1,79 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Online.API.Requests.Responses; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Changelog -{ - public class Comments : CompositeDrawable - { - private readonly APIChangelogBuild build; - - public Comments(APIChangelogBuild build) - { - this.build = build; - - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - Padding = new MarginPadding - { - Horizontal = 50, - Vertical = 20, - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - LinkFlowContainer text; - - InternalChildren = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 10, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colours.GreyVioletDarker - }, - }, - text = new LinkFlowContainer(t => - { - t.Colour = colours.PinkLighter; - t.Font = OsuFont.Default.With(size: 14); - }) - { - Padding = new MarginPadding(20), - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - } - }; - - text.AddParagraph("Got feedback?", t => - { - t.Colour = Color4.White; - t.Font = OsuFont.Default.With(italics: true, size: 20); - t.Padding = new MarginPadding { Bottom = 20 }; - }); - - text.AddParagraph("We would love to hear what you think of this update! "); - text.AddIcon(FontAwesome.Regular.GrinHearts); - - text.AddParagraph("Please visit the "); - text.AddLink("web version", $"{build.Url}#comments"); - text.AddText(" of this changelog to leave any comments."); - } - } -} diff --git a/osu.Game/Overlays/Changelog/UpdateStreamBadge.cs b/osu.Game/Overlays/Changelog/UpdateStreamBadge.cs deleted file mode 100644 index 52b77604d9..0000000000 --- a/osu.Game/Overlays/Changelog/UpdateStreamBadge.cs +++ /dev/null @@ -1,157 +0,0 @@ -// 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. - -using Humanizer; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Online.API.Requests.Responses; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Changelog -{ - public class UpdateStreamBadge : TabItem<APIUpdateStream> - { - private const float badge_height = 66.5f; - private const float badge_width = 100; - private const float transition_duration = 100; - - private readonly ExpandingBar expandingBar; - private SampleChannel sampleClick; - private SampleChannel sampleHover; - - private readonly FillFlowContainer<SpriteText> text; - - public readonly Bindable<APIUpdateStream> SelectedTab = new Bindable<APIUpdateStream>(); - - private readonly Container fadeContainer; - - public UpdateStreamBadge(APIUpdateStream stream) - : base(stream) - { - Size = new Vector2(stream.IsFeatured ? badge_width * 2 : badge_width, badge_height); - Padding = new MarginPadding(5); - - Child = fadeContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - text = new FillFlowContainer<SpriteText> - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new[] - { - new OsuSpriteText - { - Text = stream.DisplayName, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 12), - Margin = new MarginPadding { Top = 6 }, - }, - new OsuSpriteText - { - Text = stream.LatestBuild.DisplayVersion, - Font = OsuFont.GetFont(weight: FontWeight.Light, size: 16), - }, - new OsuSpriteText - { - Text = stream.LatestBuild.Users > 0 ? $"{stream.LatestBuild.Users:N0} {"user".Pluralize(stream.LatestBuild.Users == 1)} online" : null, - Font = OsuFont.GetFont(weight: FontWeight.Regular, size: 10), - Colour = new Color4(203, 164, 218, 255), - }, - } - }, - expandingBar = new ExpandingBar - { - Anchor = Anchor.TopCentre, - Colour = stream.Colour, - ExpandedSize = 4, - CollapsedSize = 2, - IsCollapsed = true - }, - } - }; - - SelectedTab.BindValueChanged(_ => updateState(), true); - } - - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - sampleClick = audio.Samples.Get(@"UI/generic-select-soft"); - sampleHover = audio.Samples.Get(@"UI/generic-hover-soft"); - } - - protected override void OnActivated() => updateState(); - - protected override void OnDeactivated() => updateState(); - - protected override bool OnClick(ClickEvent e) - { - sampleClick?.Play(); - return base.OnClick(e); - } - - protected override bool OnHover(HoverEvent e) - { - sampleHover?.Play(); - updateState(); - - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - updateState(); - base.OnHoverLost(e); - } - - private void updateState() - { - // Expand based on the local state - bool shouldExpand = Active.Value || IsHovered; - - // Expand based on whether no build is selected and the badge area is hovered - shouldExpand |= SelectedTab.Value == null && !externalDimRequested; - - if (shouldExpand) - { - expandingBar.Expand(); - fadeContainer.FadeTo(1, transition_duration); - } - else - { - expandingBar.Collapse(); - fadeContainer.FadeTo(0.5f, transition_duration); - } - - text.FadeTo(externalDimRequested && !IsHovered ? 0.5f : 1, transition_duration); - } - - private bool externalDimRequested; - - public void EnableDim() - { - externalDimRequested = true; - updateState(); - } - - public void DisableDim() - { - externalDimRequested = false; - updateState(); - } - } -} diff --git a/osu.Game/Overlays/Changelog/UpdateStreamBadgeArea.cs b/osu.Game/Overlays/Changelog/UpdateStreamBadgeArea.cs deleted file mode 100644 index 2b48811bd6..0000000000 --- a/osu.Game/Overlays/Changelog/UpdateStreamBadgeArea.cs +++ /dev/null @@ -1,75 +0,0 @@ -// 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. - -using osu.Framework.Graphics; -using osu.Framework.Input.Events; -using osu.Game.Online.API.Requests.Responses; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Changelog -{ - public class UpdateStreamBadgeArea : TabControl<APIUpdateStream> - { - public UpdateStreamBadgeArea() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - AddInternal(new Box - { - Colour = Color4.Black, - Alpha = 0.12f, - RelativeSizeAxes = Axes.Both, - }); - } - - public void Populate(List<APIUpdateStream> streams) - { - Current.Value = null; - - foreach (APIUpdateStream updateStream in streams) - AddItem(updateStream); - } - - protected override bool OnHover(HoverEvent e) - { - foreach (UpdateStreamBadge streamBadge in TabContainer.Children.OfType<UpdateStreamBadge>()) - streamBadge.EnableDim(); - - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - foreach (UpdateStreamBadge streamBadge in TabContainer.Children.OfType<UpdateStreamBadge>()) - streamBadge.DisableDim(); - - base.OnHoverLost(e); - } - - protected override TabFillFlowContainer CreateTabFlow() - { - var flow = base.CreateTabFlow(); - - flow.RelativeSizeAxes = Axes.X; - flow.AutoSizeAxes = Axes.Y; - flow.AllowMultiline = true; - flow.Padding = new MarginPadding - { - Vertical = 20, - Horizontal = 85, - }; - - return flow; - } - - protected override Dropdown<APIUpdateStream> CreateDropdown() => null; - - protected override TabItem<APIUpdateStream> CreateTabItem(APIUpdateStream value) => - new UpdateStreamBadge(value) { SelectedTab = { BindTarget = Current } }; - } -} diff --git a/osu.Game/Overlays/ChangelogOverlay.cs b/osu.Game/Overlays/ChangelogOverlay.cs index 7755c8a6a6..c7e9a86fa4 100644 --- a/osu.Game/Overlays/ChangelogOverlay.cs +++ b/osu.Game/Overlays/ChangelogOverlay.cs @@ -13,7 +13,6 @@ 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.Input.Bindings; using osu.Game.Online.API.Requests; @@ -22,36 +21,34 @@ using osu.Game.Overlays.Changelog; namespace osu.Game.Overlays { - public class ChangelogOverlay : FullscreenOverlay + public class ChangelogOverlay : FullscreenOverlay<ChangelogHeader> { public readonly Bindable<APIChangelogBuild> Current = new Bindable<APIChangelogBuild>(); - private ChangelogHeader header; - private Container<ChangelogContent> content; private SampleChannel sampleBack; private List<APIChangelogBuild> builds; - private List<APIUpdateStream> streams; + protected List<APIUpdateStream> Streams; + + public ChangelogOverlay() + : base(OverlayColourScheme.Purple, new ChangelogHeader()) + { + } [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuColour colour) + private void load(AudioManager audio) { - Waves.FirstWaveColour = colour.GreyVioletLight; - Waves.SecondWaveColour = colour.GreyViolet; - Waves.ThirdWaveColour = colour.GreyVioletDark; - Waves.FourthWaveColour = colour.GreyVioletDarker; - Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = colour.PurpleDarkAlternative, + Colour = ColourProvider.Background4, }, - new OsuScrollContainer + new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, @@ -62,10 +59,11 @@ namespace osu.Game.Overlays Direction = FillDirection.Vertical, Children = new Drawable[] { - header = new ChangelogHeader + Header.With(h => { - ListingSelected = ShowListing, - }, + h.ListingSelected = ShowListing; + h.Build.BindTarget = Current; + }), content = new Container<ChangelogContent> { RelativeSizeAxes = Axes.X, @@ -78,8 +76,6 @@ namespace osu.Game.Overlays sampleBack = audio.Samples.Get(@"UI/generic-select-soft"); - header.Current.BindTo(Current); - Current.BindValueChanged(e => { if (e.NewValue != null) @@ -117,7 +113,7 @@ namespace osu.Game.Overlays performAfterFetch(() => { var build = builds.Find(b => b.Version == version && b.UpdateStream.Name == updateStream) - ?? streams.Find(s => s.Name == updateStream)?.LatestBuild; + ?? Streams.Find(s => s.Name == updateStream)?.LatestBuild; if (build != null) ShowBuild(build); @@ -158,7 +154,8 @@ namespace osu.Game.Overlays private Task initialFetchTask; - private void performAfterFetch(Action action) => fetchListing()?.ContinueWith(_ => Schedule(action)); + private void performAfterFetch(Action action) => fetchListing()?.ContinueWith(_ => + Schedule(action), TaskContinuationOptions.OnlyOnRanToCompletion); private Task fetchListing() { @@ -170,21 +167,28 @@ namespace osu.Game.Overlays var tcs = new TaskCompletionSource<bool>(); var req = new GetChangelogRequest(); - req.Success += res => + + req.Success += res => Schedule(() => { // remap streams to builds to ensure model equality res.Builds.ForEach(b => b.UpdateStream = res.Streams.Find(s => s.Id == b.UpdateStream.Id)); res.Streams.ForEach(s => s.LatestBuild.UpdateStream = res.Streams.Find(s2 => s2.Id == s.LatestBuild.UpdateStream.Id)); builds = res.Builds; - streams = res.Streams; + Streams = res.Streams; - header.Streams.Populate(res.Streams); + Header.Populate(res.Streams); tcs.SetResult(true); + }); + + req.Failure += e => + { + initialFetchTask = null; + tcs.SetException(e); }; - req.Failure += _ => initialFetchTask = null; - req.Perform(API); + + await API.PerformAsync(req); await tcs.Task; }); diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 2576b38ec8..4eb348ae33 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -10,6 +10,7 @@ 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.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -31,6 +32,10 @@ namespace osu.Game.Overlays.Chat protected virtual float MessagePadding => default_message_padding; + private const float default_timestamp_padding = 65; + + protected virtual float TimestampPadding => default_timestamp_padding; + private const float default_horizontal_padding = 15; protected virtual float HorizontalPadding => default_horizontal_padding; @@ -54,9 +59,8 @@ namespace osu.Game.Overlays.Chat private Message message; private OsuSpriteText username; - private LinkFlowContainer contentFlow; - public LinkFlowContainer ContentFlow => contentFlow; + public LinkFlowContainer ContentFlow { get; private set; } public Message Message { @@ -87,7 +91,12 @@ namespace osu.Game.Overlays.Chat { Shadow = false, Colour = hasBackground ? customUsernameColour : username_colours[message.Sender.Id % username_colours.Length], - Font = OsuFont.GetFont(size: TextSize, weight: FontWeight.Bold, italics: true) + Truncate = true, + EllipsisString = "… :", + Font = OsuFont.GetFont(size: TextSize, weight: FontWeight.Bold, italics: true), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + MaxWidth = MessagePadding - TimestampPadding }; if (hasBackground) @@ -106,21 +115,26 @@ namespace osu.Game.Overlays.Chat Colour = Color4.Black.Opacity(0.3f), Type = EdgeEffectType.Shadow, }, - // Drop shadow effect Child = new Container { AutoSizeAxes = Axes.Both, + Y = 3, Masking = true, CornerRadius = 4, - EdgeEffect = new EdgeEffectParameters + Children = new Drawable[] { - Radius = 1, - Colour = OsuColour.FromHex(message.Sender.Colour), - Type = EdgeEffectType.Shadow, - }, - Padding = new MarginPadding { Left = 3, Right = 3, Bottom = 1, Top = -3 }, - Y = 3, - Child = username, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(message.Sender.Colour), + }, + new Container + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 4, Right = 4, Bottom = 1, Top = -2 }, + Child = username + } + } } }; } @@ -155,7 +169,7 @@ namespace osu.Game.Overlays.Chat Padding = new MarginPadding { Left = MessagePadding + HorizontalPadding }, Children = new Drawable[] { - contentFlow = new LinkFlowContainer(t => + ContentFlow = new LinkFlowContainer(t => { t.Shadow = false; @@ -164,7 +178,7 @@ namespace osu.Game.Overlays.Chat t.Font = OsuFont.GetFont(italics: true); if (senderHasBackground) - t.Colour = OsuColour.FromHex(message.Sender.Colour); + t.Colour = Color4Extensions.FromHex(message.Sender.Colour); } t.Font = t.Font.With(size: TextSize); @@ -197,8 +211,8 @@ namespace osu.Game.Overlays.Chat // remove non-existent channels from the link list message.Links.RemoveAll(link => link.Action == LinkAction.OpenChannel && chatManager?.AvailableChannels.Any(c => c.Name == link.Argument) != true); - contentFlow.Clear(); - contentFlow.AddLinks(message.DisplayContent, message.Links); + ContentFlow.Clear(); + ContentFlow.AddLinks(message.DisplayContent, message.Links); } private class MessageSender : OsuClickableContainer, IHasContextMenu @@ -241,41 +255,41 @@ namespace osu.Game.Overlays.Chat private static readonly Color4[] username_colours = { - OsuColour.FromHex("588c7e"), - OsuColour.FromHex("b2a367"), - OsuColour.FromHex("c98f65"), - OsuColour.FromHex("bc5151"), - OsuColour.FromHex("5c8bd6"), - OsuColour.FromHex("7f6ab7"), - OsuColour.FromHex("a368ad"), - OsuColour.FromHex("aa6880"), + Color4Extensions.FromHex("588c7e"), + Color4Extensions.FromHex("b2a367"), + Color4Extensions.FromHex("c98f65"), + Color4Extensions.FromHex("bc5151"), + Color4Extensions.FromHex("5c8bd6"), + Color4Extensions.FromHex("7f6ab7"), + Color4Extensions.FromHex("a368ad"), + Color4Extensions.FromHex("aa6880"), - OsuColour.FromHex("6fad9b"), - OsuColour.FromHex("f2e394"), - OsuColour.FromHex("f2ae72"), - OsuColour.FromHex("f98f8a"), - OsuColour.FromHex("7daef4"), - OsuColour.FromHex("a691f2"), - OsuColour.FromHex("c894d3"), - OsuColour.FromHex("d895b0"), + Color4Extensions.FromHex("6fad9b"), + Color4Extensions.FromHex("f2e394"), + Color4Extensions.FromHex("f2ae72"), + Color4Extensions.FromHex("f98f8a"), + Color4Extensions.FromHex("7daef4"), + Color4Extensions.FromHex("a691f2"), + Color4Extensions.FromHex("c894d3"), + Color4Extensions.FromHex("d895b0"), - OsuColour.FromHex("53c4a1"), - OsuColour.FromHex("eace5c"), - OsuColour.FromHex("ea8c47"), - OsuColour.FromHex("fc4f4f"), - OsuColour.FromHex("3d94ea"), - OsuColour.FromHex("7760ea"), - OsuColour.FromHex("af52c6"), - OsuColour.FromHex("e25696"), + Color4Extensions.FromHex("53c4a1"), + Color4Extensions.FromHex("eace5c"), + Color4Extensions.FromHex("ea8c47"), + Color4Extensions.FromHex("fc4f4f"), + Color4Extensions.FromHex("3d94ea"), + Color4Extensions.FromHex("7760ea"), + Color4Extensions.FromHex("af52c6"), + Color4Extensions.FromHex("e25696"), - OsuColour.FromHex("677c66"), - OsuColour.FromHex("9b8732"), - OsuColour.FromHex("8c5129"), - OsuColour.FromHex("8c3030"), - OsuColour.FromHex("1f5d91"), - OsuColour.FromHex("4335a5"), - OsuColour.FromHex("812a96"), - OsuColour.FromHex("992861"), + Color4Extensions.FromHex("677c66"), + Color4Extensions.FromHex("9b8732"), + Color4Extensions.FromHex("8c5129"), + Color4Extensions.FromHex("8c3030"), + Color4Extensions.FromHex("1f5d91"), + Color4Extensions.FromHex("4335a5"), + Color4Extensions.FromHex("812a96"), + Color4Extensions.FromHex("992861"), }; } } diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 8d56e250fc..d63faebae4 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. using System; @@ -12,15 +12,37 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Online.Chat; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Sprites; namespace osu.Game.Overlays.Chat { public class DrawableChannel : Container { public readonly Channel Channel; - protected ChatLineContainer ChatLineFlow; + protected FillFlowContainer ChatLineFlow; private OsuScrollContainer scroll; + private bool scrollbarVisible = true; + + public bool ScrollbarVisible + { + set + { + if (scrollbarVisible == value) return; + + scrollbarVisible = value; + if (scroll != null) + scroll.ScrollbarVisible = value; + } + } + + [Resolved] + private OsuColour colours { get; set; } + public DrawableChannel(Channel channel) { Channel = channel; @@ -30,27 +52,25 @@ namespace osu.Game.Overlays.Chat [BackgroundDependencyLoader] private void load() { - Children = new Drawable[] + Child = new OsuContextMenuContainer { - scroll = new OsuScrollContainer + RelativeSizeAxes = Axes.Both, + Masking = true, + Child = scroll = new OsuScrollContainer { + ScrollbarVisible = scrollbarVisible, RelativeSizeAxes = Axes.Both, // Some chat lines have effects that slightly protrude to the bottom, // which we do not want to mask away, hence the padding. Padding = new MarginPadding { Bottom = 5 }, - Child = new OsuContextMenuContainer + Child = ChatLineFlow = new FillFlowContainer { + Padding = new MarginPadding { Left = 20, Right = 20 }, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Child = ChatLineFlow = new ChatLineContainer - { - Padding = new MarginPadding { Left = 20, Right = 20 }, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - } - }, - } + Direction = FillDirection.Vertical, + } + }, }; newMessagesArrived(Channel.Messages); @@ -77,31 +97,69 @@ namespace osu.Game.Overlays.Chat protected virtual ChatLine CreateChatLine(Message m) => new ChatLine(m); + protected virtual DaySeparator CreateDaySeparator(DateTimeOffset time) => new DaySeparator(time) + { + Margin = new MarginPadding { Vertical = 10 }, + Colour = colours.ChatBlue.Lighten(0.7f), + }; + private void newMessagesArrived(IEnumerable<Message> newMessages) { - // Add up to last Channel.MAX_HISTORY messages - var displayMessages = newMessages.Skip(Math.Max(0, newMessages.Count() - Channel.MaxHistory)); - - ChatLineFlow.AddRange(displayMessages.Select(CreateChatLine)); - - if (scroll.IsScrolledToEnd(10) || !ChatLineFlow.Children.Any() || newMessages.Any(m => m is LocalMessage)) - scrollToEnd(); - - var staleMessages = ChatLineFlow.Children.Where(c => c.LifetimeEnd == double.MaxValue).ToArray(); - int count = staleMessages.Length - Channel.MaxHistory; - - for (int i = 0; i < count; i++) + if (newMessages.Min(m => m.Id) < chatLines.Max(c => c.Message.Id)) { - var d = staleMessages[i]; - if (!scroll.IsScrolledToEnd(10)) - scroll.OffsetScrollPosition(-d.DrawHeight); - d.Expire(); + // there is a case (on initial population) that we may receive past messages and need to reorder. + // easiest way is to just combine messages and recreate drawables (less worrying about day separators etc.) + newMessages = newMessages.Concat(chatLines.Select(c => c.Message)).OrderBy(m => m.Id).ToList(); + ChatLineFlow.Clear(); } + + bool shouldScrollToEnd = scroll.IsScrolledToEnd(10) || !chatLines.Any() || newMessages.Any(m => m is LocalMessage); + + // Add up to last Channel.MAX_HISTORY messages + var displayMessages = newMessages.Skip(Math.Max(0, newMessages.Count() - Channel.MAX_HISTORY)); + + Message lastMessage = chatLines.LastOrDefault()?.Message; + + foreach (var message in displayMessages) + { + if (lastMessage == null || lastMessage.Timestamp.ToLocalTime().Date != message.Timestamp.ToLocalTime().Date) + ChatLineFlow.Add(CreateDaySeparator(message.Timestamp)); + + ChatLineFlow.Add(CreateChatLine(message)); + lastMessage = message; + } + + var staleMessages = chatLines.Where(c => c.LifetimeEnd == double.MaxValue).ToArray(); + int count = staleMessages.Length - Channel.MAX_HISTORY; + + if (count > 0) + { + void expireAndAdjustScroll(Drawable d) + { + scroll.OffsetScrollPosition(-d.DrawHeight); + d.Expire(); + } + + for (int i = 0; i < count; i++) + expireAndAdjustScroll(staleMessages[i]); + + // remove all adjacent day separators after stale message removal + for (int i = 0; i < ChatLineFlow.Count - 1; i++) + { + if (!(ChatLineFlow[i] is DaySeparator)) break; + if (!(ChatLineFlow[i + 1] is DaySeparator)) break; + + expireAndAdjustScroll(ChatLineFlow[i]); + } + } + + if (shouldScrollToEnd) + scrollToEnd(); } private void pendingMessageResolved(Message existing, Message updated) { - var found = ChatLineFlow.Children.LastOrDefault(c => c.Message == existing); + var found = chatLines.LastOrDefault(c => c.Message == existing); if (found != null) { @@ -115,19 +173,74 @@ namespace osu.Game.Overlays.Chat private void messageRemoved(Message removed) { - ChatLineFlow.Children.FirstOrDefault(c => c.Message == removed)?.FadeColour(Color4.Red, 400).FadeOut(600).Expire(); + chatLines.FirstOrDefault(c => c.Message == removed)?.FadeColour(Color4.Red, 400).FadeOut(600).Expire(); } + private IEnumerable<ChatLine> chatLines => ChatLineFlow.Children.OfType<ChatLine>(); + private void scrollToEnd() => ScheduleAfterChildren(() => scroll.ScrollToEnd()); - protected class ChatLineContainer : FillFlowContainer<ChatLine> + public class DaySeparator : Container { - protected override int Compare(Drawable x, Drawable y) + public float TextSize { - var xC = (ChatLine)x; - var yC = (ChatLine)y; + get => text.Font.Size; + set => text.Font = text.Font.With(size: value); + } - return xC.Message.CompareTo(yC.Message); + private float lineHeight = 2; + + public float LineHeight + { + get => lineHeight; + set => lineHeight = leftBox.Height = rightBox.Height = value; + } + + private readonly SpriteText text; + private readonly Box leftBox; + private readonly Box rightBox; + + public DaySeparator(DateTimeOffset time) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Child = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize), }, + Content = new[] + { + new Drawable[] + { + leftBox = new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = lineHeight, + }, + text = new OsuSpriteText + { + Margin = new MarginPadding { Horizontal = 10 }, + Text = time.ToLocalTime().ToString("dd MMM yyyy"), + }, + rightBox = new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = lineHeight, + }, + } + } + }; } } } diff --git a/osu.Game/Overlays/Chat/Selection/ChannelListItem.cs b/osu.Game/Overlays/Chat/Selection/ChannelListItem.cs index 4d77e5f93d..1e58e8b640 100644 --- a/osu.Game/Overlays/Chat/Selection/ChannelListItem.cs +++ b/osu.Game/Overlays/Chat/Selection/ChannelListItem.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Chat.Selection private const float text_size = 15; private const float transition_duration = 100; - private readonly Channel channel; + public readonly Channel Channel; private readonly Bindable<bool> joinedBind = new Bindable<bool>(); private readonly OsuSpriteText name; @@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Chat.Selection private Color4 topicColour; private Color4 hoverColour; - public IEnumerable<string> FilterTerms => new[] { channel.Name }; + public IEnumerable<string> FilterTerms => new[] { Channel.Name, Channel.Topic ?? string.Empty }; public bool MatchingFilter { @@ -50,7 +50,7 @@ namespace osu.Game.Overlays.Chat.Selection public ChannelListItem(Channel channel) { - this.channel = channel; + Channel = channel; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -121,10 +121,11 @@ namespace osu.Game.Overlays.Chat.Selection { new SpriteIcon { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, Icon = FontAwesome.Solid.User, Size = new Vector2(text_size - 2), Shadow = false, - Margin = new MarginPadding { Top = 1 }, }, new OsuSpriteText { @@ -147,7 +148,7 @@ namespace osu.Game.Overlays.Chat.Selection hoverColour = colours.Yellow; joinedBind.ValueChanged += joined => updateColour(joined.NewValue); - joinedBind.BindTo(channel.Joined); + joinedBind.BindTo(Channel.Joined); joinedBind.TriggerChange(); FinishTransforms(true); @@ -155,7 +156,7 @@ namespace osu.Game.Overlays.Chat.Selection protected override bool OnHover(HoverEvent e) { - if (!channel.Joined.Value) + if (!Channel.Joined.Value) name.FadeColour(hoverColour, 50, Easing.OutQuint); return base.OnHover(e); @@ -163,7 +164,7 @@ namespace osu.Game.Overlays.Chat.Selection protected override void OnHoverLost(HoverLostEvent e) { - if (!channel.Joined.Value) + if (!Channel.Joined.Value) name.FadeColour(Color4.White, transition_duration); } diff --git a/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs b/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs index e0ded11ec9..be9ecc6746 100644 --- a/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs +++ b/osu.Game/Overlays/Chat/Selection/ChannelSelectionOverlay.cs @@ -22,7 +22,7 @@ namespace osu.Game.Overlays.Chat.Selection { public class ChannelSelectionOverlay : WaveOverlayContainer { - public static readonly float WIDTH_PADDING = 170; + public new const float WIDTH_PADDING = 170; private const float transition_duration = 500; @@ -41,10 +41,10 @@ namespace osu.Game.Overlays.Chat.Selection { RelativeSizeAxes = Axes.X; - Waves.FirstWaveColour = OsuColour.FromHex("353535"); - Waves.SecondWaveColour = OsuColour.FromHex("434343"); - Waves.ThirdWaveColour = OsuColour.FromHex("515151"); - Waves.FourthWaveColour = OsuColour.FromHex("595959"); + Waves.FirstWaveColour = Color4Extensions.FromHex("353535"); + Waves.SecondWaveColour = Color4Extensions.FromHex("434343"); + Waves.ThirdWaveColour = Color4Extensions.FromHex("515151"); + Waves.FourthWaveColour = Color4Extensions.FromHex("595959"); Children = new Drawable[] { @@ -115,12 +115,7 @@ namespace osu.Game.Overlays.Chat.Selection Font = OsuFont.GetFont(size: 20), Shadow = false, }, - search = new HeaderSearchTextBox - { - RelativeSizeAxes = Axes.X, - PlaceholderText = @"Search", - Exit = Hide, - }, + search = new HeaderSearchTextBox { RelativeSizeAxes = Axes.X }, }, }, }, @@ -159,7 +154,7 @@ namespace osu.Game.Overlays.Chat.Selection { bg.Colour = colours.Gray3; triangles.ColourDark = colours.Gray3; - triangles.ColourLight = OsuColour.FromHex(@"353535"); + triangles.ColourLight = Color4Extensions.FromHex(@"353535"); headerBg.Colour = colours.Gray2.Opacity(0.75f); } diff --git a/osu.Game/Overlays/Chat/Tabs/ChannelSelectorTabItem.cs b/osu.Game/Overlays/Chat/Tabs/ChannelSelectorTabItem.cs index 7386bffb1a..e3ede04edd 100644 --- a/osu.Game/Overlays/Chat/Tabs/ChannelSelectorTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/ChannelSelectorTabItem.cs @@ -13,6 +13,8 @@ namespace osu.Game.Overlays.Chat.Tabs public override bool IsSwitchable => false; + protected override bool IsBoldWhenActive => false; + public ChannelSelectorTabItem() : base(new ChannelSelectorTabChannel()) { @@ -22,7 +24,7 @@ namespace osu.Game.Overlays.Chat.Tabs Icon.Alpha = 0; Text.Font = Text.Font.With(size: 45); - TextBold.Font = Text.Font.With(size: 45); + Text.Truncate = false; } [BackgroundDependencyLoader] @@ -37,6 +39,7 @@ namespace osu.Game.Overlays.Chat.Tabs public ChannelSelectorTabChannel() { Name = "+"; + Type = ChannelType.System; } } } diff --git a/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs b/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs index 612379d339..19c6f437b6 100644 --- a/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs +++ b/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs @@ -9,13 +9,13 @@ using osuTK; using System; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Containers; namespace osu.Game.Overlays.Chat.Tabs { public class ChannelTabControl : OsuTabControl<Channel> { - public static readonly float SHEAR_WIDTH = 10; + public const float SHEAR_WIDTH = 10; public Action<Channel> OnRequestLeave; @@ -25,19 +25,11 @@ namespace osu.Game.Overlays.Chat.Tabs public ChannelTabControl() { - TabContainer.Margin = new MarginPadding { Left = 50 }; + Padding = new MarginPadding { Left = 50 }; + TabContainer.Spacing = new Vector2(-SHEAR_WIDTH, 0); TabContainer.Masking = false; - AddInternal(new SpriteIcon - { - Icon = FontAwesome.Solid.Comments, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Size = new Vector2(20), - Margin = new MarginPadding(10), - }); - AddTabItem(selectorTab = new ChannelSelectorTabItem()); ChannelSelectorActive.BindTo(selectorTab.Active); @@ -49,7 +41,7 @@ namespace osu.Game.Overlays.Chat.Tabs // performTabSort might've made selectorTab's position wonky, fix it TabContainer.SetLayoutPosition(selectorTab, float.MaxValue); - ((ChannelTabItem)item).OnRequestClose += tabCloseRequested; + ((ChannelTabItem)item).OnRequestClose += channelItem => OnRequestLeave?.Invoke(channelItem.Value); base.AddTabItem(item, addToDropdown); } @@ -76,21 +68,20 @@ namespace osu.Game.Overlays.Chat.Tabs if (!Items.Contains(channel)) AddItem(channel); - if (Current.Value == null) - Current.Value = channel; + Current.Value ??= channel; } /// <summary> /// Removes a channel from the ChannelTabControl. - /// If the selected channel is the one that is beeing removed, the next available channel will be selected. + /// If the selected channel is the one that is being removed, the next available channel will be selected. /// </summary> /// <param name="channel">The channel that is going to be removed.</param> public void RemoveChannel(Channel channel) { RemoveItem(channel); - if (Current.Value == channel) - Current.Value = Items.FirstOrDefault(); + if (SelectedTab == null) + SelectTab(selectorTab); } protected override void SelectTab(TabItem<Channel> tab) @@ -105,19 +96,17 @@ namespace osu.Game.Overlays.Chat.Tabs selectorTab.Active.Value = false; } - private void tabCloseRequested(TabItem<Channel> tab) + protected override TabFillFlowContainer CreateTabFlow() => new ChannelTabFillFlowContainer { - int totalTabs = TabContainer.Count - 1; // account for selectorTab - int currentIndex = MathHelper.Clamp(TabContainer.IndexOf(tab), 1, totalTabs); + Direction = FillDirection.Full, + RelativeSizeAxes = Axes.Both, + Depth = -1, + Masking = true + }; - if (tab == SelectedTab && totalTabs > 1) - // Select the tab after tab-to-be-removed's index, or the tab before if current == last - SelectTab(TabContainer[currentIndex == totalTabs ? currentIndex - 1 : currentIndex + 1]); - else if (totalTabs == 1 && !selectorTab.Active.Value) - // Open channel selection overlay if all channel tabs will be closed after removing this tab - SelectTab(selectorTab); - - OnRequestLeave?.Invoke(tab.Value); + private class ChannelTabFillFlowContainer : TabFillFlowContainer + { + protected override int Compare(Drawable x, Drawable y) => CompareReverseChildID(x, y); } } } diff --git a/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs b/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs index 2a3dd55c71..cca4dc33e5 100644 --- a/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs @@ -29,7 +29,6 @@ namespace osu.Game.Overlays.Chat.Tabs public override bool IsRemovable => !Pinned; protected readonly SpriteText Text; - protected readonly SpriteText TextBold; protected readonly ClickableContainer CloseButton; private readonly Box box; private readonly Box highlightBox; @@ -88,20 +87,17 @@ namespace osu.Game.Overlays.Chat.Tabs }, Text = new OsuSpriteText { - Margin = new MarginPadding(5), Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, Text = value.ToString(), - Font = OsuFont.GetFont(size: 18) - }, - TextBold = new OsuSpriteText - { - Alpha = 0, - Margin = new MarginPadding(5), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Text = value.ToString(), - Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold) + Font = OsuFont.GetFont(size: 18), + Padding = new MarginPadding(5) + { + Left = LeftTextPadding, + Right = RightTextPadding, + }, + RelativeSizeAxes = Axes.X, + Truncate = true, }, CloseButton = new TabCloseButton { @@ -119,10 +115,16 @@ namespace osu.Game.Overlays.Chat.Tabs }; } + protected virtual float LeftTextPadding => 5; + + protected virtual float RightTextPadding => IsRemovable ? 40 : 5; + protected virtual IconUsage DisplayIcon => FontAwesome.Solid.Hashtag; protected virtual bool ShowCloseOnHover => true; + protected virtual bool IsBoldWhenActive => true; + protected override bool OnHover(HoverEvent e) { if (IsRemovable && ShowCloseOnHover) @@ -139,16 +141,13 @@ namespace osu.Game.Overlays.Chat.Tabs updateState(); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { switch (e.Button) { case MouseButton.Middle: CloseButton.Click(); - return true; - - default: - return false; + break; } } @@ -203,8 +202,7 @@ namespace osu.Game.Overlays.Chat.Tabs box.FadeColour(BackgroundActive, TRANSITION_LENGTH, Easing.OutQuint); highlightBox.FadeIn(TRANSITION_LENGTH, Easing.OutQuint); - Text.FadeOut(TRANSITION_LENGTH, Easing.OutQuint); - TextBold.FadeIn(TRANSITION_LENGTH, Easing.OutQuint); + if (IsBoldWhenActive) Text.Font = Text.Font.With(weight: FontWeight.Bold); } protected virtual void FadeInactive() @@ -213,11 +211,10 @@ namespace osu.Game.Overlays.Chat.Tabs TweenEdgeEffectTo(deactivateEdgeEffect, TRANSITION_LENGTH); - box.FadeColour(BackgroundInactive, TRANSITION_LENGTH, Easing.OutQuint); + box.FadeColour(IsHovered ? backgroundHover : BackgroundInactive, TRANSITION_LENGTH, Easing.OutQuint); highlightBox.FadeOut(TRANSITION_LENGTH, Easing.OutQuint); - Text.FadeIn(TRANSITION_LENGTH, Easing.OutQuint); - TextBold.FadeOut(TRANSITION_LENGTH, Easing.OutQuint); + Text.Font = Text.Font.With(weight: FontWeight.Medium); } protected override void OnActivated() => updateState(); diff --git a/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs b/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs index 9e87bae864..5b428a3825 100644 --- a/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs @@ -62,11 +62,10 @@ namespace osu.Game.Overlays.Chat.Tabs }); avatar.OnLoadComplete += d => d.FadeInFromZero(300, Easing.OutQuint); - - Text.X = ChatOverlay.TAB_AREA_HEIGHT; - TextBold.X = ChatOverlay.TAB_AREA_HEIGHT; } + protected override float LeftTextPadding => base.LeftTextPadding + ChatOverlay.TAB_AREA_HEIGHT; + protected override bool ShowCloseOnHover => false; protected override void FadeActive() @@ -90,7 +89,7 @@ namespace osu.Game.Overlays.Chat.Tabs { var user = Value.Users.First(); - BackgroundActive = user.Colour != null ? OsuColour.FromHex(user.Colour) : colours.BlueDark; + BackgroundActive = user.Colour != null ? Color4Extensions.FromHex(user.Colour) : colours.BlueDark; BackgroundInactive = BackgroundActive.Darken(0.5f); } } diff --git a/osu.Game/Overlays/Chat/Tabs/TabCloseButton.cs b/osu.Game/Overlays/Chat/Tabs/TabCloseButton.cs index bde930d4fb..178afda5ac 100644 --- a/osu.Game/Overlays/Chat/Tabs/TabCloseButton.cs +++ b/osu.Game/Overlays/Chat/Tabs/TabCloseButton.cs @@ -34,10 +34,10 @@ namespace osu.Game.Overlays.Chat.Tabs return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { icon.ScaleTo(0.75f, 1000, Easing.OutElastic); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index e223856b27..8bc7e21047 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using osuTK; using osuTK.Graphics; @@ -21,21 +22,28 @@ using osu.Game.Overlays.Chat; using osu.Game.Overlays.Chat.Selection; using osu.Game.Overlays.Chat.Tabs; using osuTK.Input; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; namespace osu.Game.Overlays { - public class ChatOverlay : OsuFocusedOverlayContainer + public class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent { + public string IconTexture => "Icons/Hexacons/messaging"; + public string Title => "chat"; + public string Description => "join the real-time discussion"; + private const float textbox_height = 60; private const float channel_selection_min_height = 0.3f; - private ChannelManager channelManager; + [Resolved] + private ChannelManager channelManager { get; set; } private Container<DrawableChannel> currentChannelContainer; private readonly List<DrawableChannel> loadedChannels = new List<DrawableChannel>(); - private LoadingAnimation loading; + private LoadingSpinner loading; private FocusedTextBox textbox; @@ -54,7 +62,7 @@ namespace osu.Game.Overlays private Box chatBackground; private Box tabBackground; - public Bindable<double> ChatHeight { get; set; } + public Bindable<float> ChatHeight { get; set; } private Container channelSelectionContainer; protected ChannelSelectionOverlay ChannelSelectionOverlay; @@ -71,7 +79,7 @@ namespace osu.Game.Overlays } [BackgroundDependencyLoader] - private void load(OsuConfigManager config, OsuColour colours, ChannelManager channelManager) + private void load(OsuConfigManager config, OsuColour colours, TextureStore textures) { const float padding = 5; @@ -138,14 +146,12 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both, Height = 1, PlaceholderText = "type your message", - Exit = Hide, - OnCommit = postMessage, ReleaseFocusOnCommit = false, HoldFocus = true, } } }, - loading = new LoadingAnimation(), + loading = new LoadingSpinner(), } }, tabsArea = new TabsArea @@ -157,12 +163,21 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both, Colour = Color4.Black, }, + new Sprite + { + Texture = textures.Get(IconTexture), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(OverlayTitle.ICON_SIZE), + Margin = new MarginPadding { Left = 10 }, + }, ChannelTabControl = CreateChannelTabControl().With(d => { d.Anchor = Anchor.BottomLeft; d.Origin = Anchor.BottomLeft; d.RelativeSizeAxes = Axes.Both; d.OnRequestLeave = channelManager.LeaveChannel; + d.IsSwitchable = true; }), } }, @@ -170,6 +185,8 @@ namespace osu.Game.Overlays }, }; + textbox.OnCommit += postMessage; + ChannelTabControl.Current.ValueChanged += current => channelManager.CurrentChannel.Value = current.NewValue; ChannelTabControl.ChannelSelectorActive.ValueChanged += active => ChannelSelectionOverlay.State.Value = active.NewValue ? Visibility.Visible : Visibility.Hidden; ChannelSelectionOverlay.State.ValueChanged += state => @@ -190,19 +207,16 @@ namespace osu.Game.Overlays ChannelSelectionOverlay.OnRequestJoin = channel => channelManager.JoinChannel(channel); ChannelSelectionOverlay.OnRequestLeave = channelManager.LeaveChannel; - ChatHeight = config.GetBindable<double>(OsuSetting.ChatDisplayHeight); - ChatHeight.ValueChanged += height => + ChatHeight = config.GetBindable<float>(OsuSetting.ChatDisplayHeight); + ChatHeight.BindValueChanged(height => { - chatContainer.Height = (float)height.NewValue; - channelSelectionContainer.Height = 1f - (float)height.NewValue; - tabBackground.FadeTo(height.NewValue == 1 ? 1 : 0.8f, 200); - }; - ChatHeight.TriggerChange(); + chatContainer.Height = height.NewValue; + channelSelectionContainer.Height = 1f - height.NewValue; + tabBackground.FadeTo(height.NewValue == 1f ? 1f : 0.8f, 200); + }, true); chatBackground.Colour = colours.ChatBlue; - this.channelManager = channelManager; - loading.Show(); // This is a relatively expensive (and blocking) operation. @@ -211,14 +225,13 @@ namespace osu.Game.Overlays Schedule(() => { // TODO: consider scheduling bindable callbacks to not perform when overlay is not present. - channelManager.JoinedChannels.ItemsAdded += onChannelAddedToJoinedChannels; - channelManager.JoinedChannels.ItemsRemoved += onChannelRemovedFromJoinedChannels; + channelManager.JoinedChannels.CollectionChanged += joinedChannelsChanged; + foreach (Channel channel in channelManager.JoinedChannels) ChannelTabControl.AddChannel(channel); - channelManager.AvailableChannels.ItemsAdded += availableChannelsChanged; - channelManager.AvailableChannels.ItemsRemoved += availableChannelsChanged; - ChannelSelectionOverlay.UpdateAvailableChannels(channelManager.AvailableChannels); + channelManager.AvailableChannels.CollectionChanged += availableChannelsChanged; + availableChannelsChanged(null, null); currentChannel = channelManager.CurrentChannel.GetBoundCopy(); currentChannel.BindValueChanged(currentChannelChanged, true); @@ -256,6 +269,9 @@ namespace osu.Game.Overlays loadedChannels.Add(loaded); LoadComponentAsync(loaded, l => { + if (currentChannel.Value != e.NewValue) + return; + loading.Hide(); currentChannelContainer.Clear(false); @@ -268,9 +284,13 @@ namespace osu.Game.Overlays currentChannelContainer.Clear(false); currentChannelContainer.Add(loaded); } + + // mark channel as read when channel switched + if (e.NewValue.Messages.Any()) + channelManager.MarkChannelAsRead(e.NewValue); } - private double startDragChatHeight; + private float startDragChatHeight; private bool isDragging; protected override bool OnDragStart(DragStartEvent e) @@ -284,11 +304,11 @@ namespace osu.Game.Overlays return true; } - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) { if (isDragging) { - double targetChatHeight = startDragChatHeight - (e.MousePosition.Y - e.MouseDownPosition.Y) / Parent.DrawSize.Y; + float targetChatHeight = startDragChatHeight - (e.MousePosition.Y - e.MouseDownPosition.Y) / Parent.DrawSize.Y; // If the channel selection screen is shown, mind its minimum height if (ChannelSelectionOverlay.State.Value == Visibility.Visible && targetChatHeight > 1f - channel_selection_min_height) @@ -296,20 +316,20 @@ namespace osu.Game.Overlays ChatHeight.Value = targetChatHeight; } - - return true; } - protected override bool OnDragEnd(DragEndEvent e) + protected override void OnDragEnd(DragEndEvent e) { isDragging = false; - return base.OnDragEnd(e); + base.OnDragEnd(e); } private void selectTab(int index) { - var channel = ChannelTabControl.Items.Skip(index).FirstOrDefault(); - if (channel != null && !(channel is ChannelSelectorTabItem.ChannelSelectorTabChannel)) + var channel = ChannelTabControl.Items + .Where(tab => !(tab is ChannelSelectorTabItem.ChannelSelectorTabChannel)) + .ElementAtOrDefault(index); + if (channel != null) ChannelTabControl.Current.Value = channel; } @@ -344,7 +364,7 @@ namespace osu.Game.Overlays protected override void OnFocus(FocusEvent e) { - //this is necessary as textbox is masked away and therefore can't get focus :( + // this is necessary as textbox is masked away and therefore can't get focus :( textbox.TakeFocus(); base.OnFocus(e); } @@ -370,23 +390,41 @@ namespace osu.Game.Overlays base.PopOut(); } - private void onChannelAddedToJoinedChannels(IEnumerable<Channel> channels) + private void joinedChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) { - foreach (Channel channel in channels) - ChannelTabControl.AddChannel(channel); - } - - private void onChannelRemovedFromJoinedChannels(IEnumerable<Channel> channels) - { - foreach (Channel channel in channels) + switch (args.Action) { - ChannelTabControl.RemoveChannel(channel); - loadedChannels.Remove(loadedChannels.Find(c => c.Channel == channel)); + case NotifyCollectionChangedAction.Add: + foreach (Channel channel in args.NewItems.Cast<Channel>()) + ChannelTabControl.AddChannel(channel); + break; + + case NotifyCollectionChangedAction.Remove: + foreach (Channel channel in args.OldItems.Cast<Channel>()) + { + ChannelTabControl.RemoveChannel(channel); + + var loaded = loadedChannels.Find(c => c.Channel == channel); + + if (loaded != null) + { + loadedChannels.Remove(loaded); + + // Because the container is only cleared in the async load callback of a new channel, it is forcefully cleared + // to ensure that the previous channel doesn't get updated after it's disposed + currentChannelContainer.Remove(loaded); + loaded.Dispose(); + } + } + + break; } } - private void availableChannelsChanged(IEnumerable<Channel> channels) - => ChannelSelectionOverlay.UpdateAvailableChannels(channelManager.AvailableChannels); + private void availableChannelsChanged(object sender, NotifyCollectionChangedEventArgs args) + { + ChannelSelectionOverlay.UpdateAvailableChannels(channelManager.AvailableChannels); + } protected override void Dispose(bool isDisposing) { @@ -395,10 +433,8 @@ namespace osu.Game.Overlays if (channelManager != null) { channelManager.CurrentChannel.ValueChanged -= currentChannelChanged; - channelManager.JoinedChannels.ItemsAdded -= onChannelAddedToJoinedChannels; - channelManager.JoinedChannels.ItemsRemoved -= onChannelRemovedFromJoinedChannels; - channelManager.AvailableChannels.ItemsAdded -= availableChannelsChanged; - channelManager.AvailableChannels.ItemsRemoved -= availableChannelsChanged; + channelManager.JoinedChannels.CollectionChanged -= joinedChannelsChanged; + channelManager.AvailableChannels.CollectionChanged -= availableChannelsChanged; } } diff --git a/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs b/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs new file mode 100644 index 0000000000..48f34e8f59 --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/ChevronButton.cs @@ -0,0 +1,48 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osu.Framework.Allocation; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public class ChevronButton : OsuHoverContainer + { + public readonly BindableBool Expanded = new BindableBool(true); + + private readonly SpriteIcon icon; + + public ChevronButton() + { + Size = new Vector2(40, 22); + Child = icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(12), + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = HoverColour = colourProvider.Foreground1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Action = Expanded.Toggle; + Expanded.BindValueChanged(onExpandedChanged, true); + } + + private void onExpandedChanged(ValueChangedEvent<bool> expanded) + { + icon.Icon = expanded.NewValue ? FontAwesome.Solid.ChevronUp : FontAwesome.Solid.ChevronDown; + } + } +} diff --git a/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs new file mode 100644 index 0000000000..57bf2af4d2 --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/CommentRepliesButton.cs @@ -0,0 +1,110 @@ +// 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. + +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.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; +using static osu.Game.Graphics.UserInterface.ShowMoreButton; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public abstract class CommentRepliesButton : CompositeDrawable + { + protected string Text + { + get => text.Text; + set => text.Text = value; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private readonly ChevronIcon icon; + private readonly Box background; + private readonly OsuSpriteText text; + + protected CommentRepliesButton() + { + AutoSizeAxes = Axes.Both; + InternalChildren = new Drawable[] + { + new CircularContainer + { + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new Container + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding + { + Vertical = 5, + Horizontal = 10, + }, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(15, 0), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AlwaysPresent = true, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }, + icon = new ChevronIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + } + } + } + } + } + }, + new HoverClickSounds(), + }; + } + + [BackgroundDependencyLoader] + private void load() + { + background.Colour = colourProvider.Background2; + } + + protected void SetIconDirection(bool upwards) => icon.ScaleTo(new Vector2(1, upwards ? -1 : 1)); + + public void ToggleTextVisibility(bool visible) => text.FadeTo(visible ? 1 : 0, 200, Easing.OutQuint); + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + background.FadeColour(colourProvider.Background1, 200, Easing.OutQuint); + icon.SetHoveredState(true); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + background.FadeColour(colourProvider.Background2, 200, Easing.OutQuint); + icon.SetHoveredState(false); + } + } +} diff --git a/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs new file mode 100644 index 0000000000..4998e5391e --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/LoadRepliesButton.cs @@ -0,0 +1,32 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public class LoadRepliesButton : LoadingButton + { + private ButtonContent content; + + public LoadRepliesButton() + { + AutoSizeAxes = Axes.Both; + } + + protected override Drawable CreateContent() => content = new ButtonContent(); + + protected override void OnLoadStarted() => content.ToggleTextVisibility(false); + + protected override void OnLoadFinished() => content.ToggleTextVisibility(true); + + private class ButtonContent : CommentRepliesButton + { + public ButtonContent() + { + Text = "load replies"; + } + } + } +} diff --git a/osu.Game/Overlays/Comments/Buttons/ShowMoreRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/ShowMoreRepliesButton.cs new file mode 100644 index 0000000000..c115a8bb8f --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/ShowMoreRepliesButton.cs @@ -0,0 +1,49 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using System.Collections.Generic; +using osuTK; +using osu.Framework.Allocation; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public class ShowMoreRepliesButton : LoadingButton + { + protected override IEnumerable<Drawable> EffectTargets => new[] { text }; + + private OsuSpriteText text; + + public ShowMoreRepliesButton() + { + AutoSizeAxes = Axes.Both; + LoadingAnimationSize = new Vector2(8); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Light2; + HoverColour = colourProvider.Light1; + } + + protected override Drawable CreateContent() => new Container + { + AutoSizeAxes = Axes.Both, + Child = text = new OsuSpriteText + { + AlwaysPresent = true, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = "show more" + } + }; + + protected override void OnLoadStarted() => text.FadeOut(200, Easing.OutQuint); + + protected override void OnLoadFinished() => text.FadeIn(200, Easing.OutQuint); + } +} diff --git a/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs b/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs new file mode 100644 index 0000000000..04e7e25cc5 --- /dev/null +++ b/osu.Game/Overlays/Comments/Buttons/ShowRepliesButton.cs @@ -0,0 +1,31 @@ +// 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. + +using Humanizer; +using osu.Framework.Bindables; +using osu.Framework.Input.Events; + +namespace osu.Game.Overlays.Comments.Buttons +{ + public class ShowRepliesButton : CommentRepliesButton + { + public readonly BindableBool Expanded = new BindableBool(true); + + public ShowRepliesButton(int count) + { + Text = "reply".ToQuantity(count); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Expanded.BindValueChanged(expanded => SetIconDirection(expanded.NewValue), true); + } + + protected override bool OnClick(ClickEvent e) + { + Expanded.Toggle(); + return true; + } + } +} diff --git a/osu.Game/Overlays/Comments/CancellableCommentEditor.cs b/osu.Game/Overlays/Comments/CancellableCommentEditor.cs new file mode 100644 index 0000000000..c226b7f07f --- /dev/null +++ b/osu.Game/Overlays/Comments/CancellableCommentEditor.cs @@ -0,0 +1,71 @@ +// 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. + +using System; +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; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Overlays.Comments +{ + public abstract class CancellableCommentEditor : CommentEditor + { + public Action OnCancel; + + [BackgroundDependencyLoader] + private void load() + { + ButtonsContainer.Add(new CancelButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Action = () => OnCancel?.Invoke() + }); + } + + private class CancelButton : OsuHoverContainer + { + protected override IEnumerable<Drawable> EffectTargets => new[] { background }; + + private readonly Box background; + + public CancelButton() + { + AutoSizeAxes = Axes.Both; + Child = new CircularContainer + { + Masking = true, + Height = 25, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Margin = new MarginPadding { Horizontal = 20 }, + Text = @"Cancel" + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Light4; + HoverColour = colourProvider.Light3; + } + } + } +} diff --git a/osu.Game/Overlays/Comments/CommentEditor.cs b/osu.Game/Overlays/Comments/CommentEditor.cs new file mode 100644 index 0000000000..7b4bf882dc --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentEditor.cs @@ -0,0 +1,245 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Graphics.Sprites; +using osuTK.Graphics; +using osu.Game.Graphics.UserInterface; +using System.Collections.Generic; +using System; +using osuTK; +using osu.Framework.Bindables; + +namespace osu.Game.Overlays.Comments +{ + public abstract class CommentEditor : CompositeDrawable + { + private const int side_padding = 8; + + public Action<string> OnCommit; + + public bool IsLoading + { + get => commitButton.IsLoading; + set => commitButton.IsLoading = value; + } + + protected abstract string FooterText { get; } + + protected abstract string CommitButtonText { get; } + + protected abstract string TextBoxPlaceholder { get; } + + protected FillFlowContainer ButtonsContainer { get; private set; } + + protected readonly Bindable<string> Current = new Bindable<string>(); + + private CommitButton commitButton; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + EditorTextBox textBox; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 6; + BorderThickness = 3; + BorderColour = colourProvider.Background3; + + AddRangeInternal(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3 + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + textBox = new EditorTextBox + { + Height = 40, + RelativeSizeAxes = Axes.X, + PlaceholderText = TextBoxPlaceholder, + Current = Current + }, + new Container + { + Name = "Footer", + RelativeSizeAxes = Axes.X, + Height = 35, + Padding = new MarginPadding { Horizontal = side_padding }, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = FooterText + }, + ButtonsContainer = new FillFlowContainer + { + Name = "Buttons", + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Child = commitButton = new CommitButton(CommitButtonText) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Action = () => + { + OnCommit?.Invoke(Current.Value); + Current.Value = string.Empty; + } + } + } + } + } + } + } + }); + + textBox.OnCommit += (u, v) => + { + if (commitButton.IsBlocked.Value) + return; + + commitButton.Click(); + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(text => commitButton.IsBlocked.Value = string.IsNullOrEmpty(text.NewValue), true); + } + + private class EditorTextBox : BasicTextBox + { + protected override float LeftRightPadding => side_padding; + + protected override Color4 SelectionColour => Color4.Gray; + + private OsuSpriteText placeholder; + + public EditorTextBox() + { + Masking = false; + TextContainer.Height = 0.4f; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + BackgroundUnfocused = BackgroundFocused = colourProvider.Background5; + placeholder.Colour = colourProvider.Background3; + BackgroundCommit = colourProvider.Background3; + } + + protected override SpriteText CreatePlaceholder() => placeholder = new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.Regular), + }; + + protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer + { + AutoSizeAxes = Axes.Both, + Child = new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: CalculatedTextSize) }, + }; + } + + private class CommitButton : LoadingButton + { + private const int duration = 200; + + public readonly BindableBool IsBlocked = new BindableBool(); + + public override bool PropagatePositionalInputSubTree => !IsBlocked.Value && base.PropagatePositionalInputSubTree; + + protected override IEnumerable<Drawable> EffectTargets => new[] { background }; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private OsuSpriteText drawableText; + private Box background; + private Box blockedBackground; + + public CommitButton(string text) + { + AutoSizeAxes = Axes.Both; + LoadingAnimationSize = new Vector2(10); + + drawableText.Text = text; + } + + [BackgroundDependencyLoader] + private void load() + { + IdleColour = colourProvider.Light4; + HoverColour = colourProvider.Light3; + blockedBackground.Colour = colourProvider.Background5; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + IsBlocked.BindValueChanged(onBlockedStateChanged, true); + } + + private void onBlockedStateChanged(ValueChangedEvent<bool> isBlocked) + { + drawableText.FadeColour(isBlocked.NewValue ? colourProvider.Foreground1 : Color4.White, duration, Easing.OutQuint); + background.FadeTo(isBlocked.NewValue ? 0 : 1, duration, Easing.OutQuint); + } + + protected override Drawable CreateContent() => new CircularContainer + { + Masking = true, + Height = 25, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + blockedBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + background = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0 + }, + drawableText = new OsuSpriteText + { + AlwaysPresent = true, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Margin = new MarginPadding { Horizontal = 20 } + } + } + }; + + protected override void OnLoadStarted() => drawableText.FadeOut(duration, Easing.OutQuint); + + protected override void OnLoadFinished() => drawableText.FadeIn(duration, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/Comments/CommentsContainer.cs b/osu.Game/Overlays/Comments/CommentsContainer.cs new file mode 100644 index 0000000000..2a78748be6 --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentsContainer.cs @@ -0,0 +1,216 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Shapes; +using osu.Game.Online.API.Requests.Responses; +using System.Threading; +using System.Linq; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Threading; +using osu.Game.Users; + +namespace osu.Game.Overlays.Comments +{ + public class CommentsContainer : CompositeDrawable + { + private readonly Bindable<CommentableType> type = new Bindable<CommentableType>(); + private readonly BindableLong id = new BindableLong(); + + public readonly Bindable<CommentsSortCriteria> Sort = new Bindable<CommentsSortCriteria>(); + public readonly BindableBool ShowDeleted = new BindableBool(); + + protected readonly Bindable<User> User = new Bindable<User>(); + + [Resolved] + private IAPIProvider api { get; set; } + + private GetCommentsRequest request; + private ScheduledDelegate scheduledCommentsLoad; + private CancellationTokenSource loadCancellation; + private int currentPage; + + private FillFlowContainer content; + private DeletedCommentsCounter deletedCommentsCounter; + private CommentsShowMoreButton moreButton; + private TotalCommentsCounter commentCounter; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + AddRangeInternal(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5 + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + commentCounter = new TotalCommentsCounter(), + new CommentsHeader + { + Sort = { BindTarget = Sort }, + ShowDeleted = { BindTarget = ShowDeleted } + }, + content = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }, + new Container + { + Name = @"Footer", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Margin = new MarginPadding { Bottom = 20 }, + Children = new Drawable[] + { + deletedCommentsCounter = new DeletedCommentsCounter + { + ShowDeleted = { BindTarget = ShowDeleted }, + Margin = new MarginPadding + { + Horizontal = 70, + Vertical = 10 + } + }, + new Container + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Child = moreButton = new CommentsShowMoreButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding + { + Vertical = 10 + }, + Action = getComments, + IsLoading = true, + } + } + } + } + } + } + } + } + }); + + User.BindTo(api.LocalUser); + } + + protected override void LoadComplete() + { + User.BindValueChanged(_ => refetchComments()); + Sort.BindValueChanged(_ => refetchComments(), true); + base.LoadComplete(); + } + + /// <param name="type">The type of resource to get comments for.</param> + /// <param name="id">The id of the resource to get comments for.</param> + public void ShowComments(CommentableType type, long id) + { + this.type.Value = type; + this.id.Value = id; + + if (!IsLoaded) + return; + + // only reset when changing ID/type. other refetch ops are generally just changing sort order. + commentCounter.Current.Value = 0; + + refetchComments(); + } + + private void refetchComments() + { + clearComments(); + getComments(); + } + + private void getComments() + { + if (id.Value <= 0) + return; + + request?.Cancel(); + loadCancellation?.Cancel(); + scheduledCommentsLoad?.Cancel(); + request = new GetCommentsRequest(id.Value, type.Value, Sort.Value, currentPage++, 0); + request.Success += res => scheduledCommentsLoad = Schedule(() => onSuccess(res)); + api.PerformAsync(request); + } + + private void clearComments() + { + currentPage = 1; + deletedCommentsCounter.Count.Value = 0; + moreButton.Show(); + moreButton.IsLoading = true; + content.Clear(); + } + + private void onSuccess(CommentBundle response) + { + loadCancellation = new CancellationTokenSource(); + + LoadComponentAsync(new CommentsPage(response) + { + ShowDeleted = { BindTarget = ShowDeleted }, + Sort = { BindTarget = Sort }, + Type = { BindTarget = type }, + CommentableId = { BindTarget = id } + }, loaded => + { + content.Add(loaded); + + deletedCommentsCounter.Count.Value += response.Comments.Count(c => c.IsDeleted && c.IsTopLevel); + + if (response.HasMore) + { + int loadedTopLevelComments = 0; + content.Children.OfType<FillFlowContainer>().ForEach(p => loadedTopLevelComments += p.Children.OfType<DrawableComment>().Count()); + + moreButton.Current.Value = response.TopLevelCount - loadedTopLevelComments; + moreButton.IsLoading = false; + } + else + { + moreButton.Hide(); + } + + commentCounter.Current.Value = response.Total; + }, loadCancellation.Token); + } + + protected override void Dispose(bool isDisposing) + { + request?.Cancel(); + loadCancellation?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Comments/CommentsHeader.cs b/osu.Game/Overlays/Comments/CommentsHeader.cs new file mode 100644 index 0000000000..0dd68bbd41 --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentsHeader.cs @@ -0,0 +1,117 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Overlays.Comments +{ + public class CommentsHeader : CompositeDrawable + { + public readonly Bindable<CommentsSortCriteria> Sort = new Bindable<CommentsSortCriteria>(); + public readonly BindableBool ShowDeleted = new BindableBool(); + + private readonly Box background; + + public CommentsHeader() + { + RelativeSizeAxes = Axes.X; + Height = 40; + + AddRangeInternal(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 50 }, + Children = new Drawable[] + { + new OverlaySortTabControl<CommentsSortCriteria> + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = Sort + }, + new ShowDeletedButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Checked = { BindTarget = ShowDeleted } + } + } + } + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background4; + } + + private class ShowDeletedButton : HeaderButton + { + public readonly BindableBool Checked = new BindableBool(); + + private readonly SpriteIcon checkboxIcon; + + public ShowDeletedButton() + { + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + checkboxIcon = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(10), + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = @"Show deleted" + } + }, + }); + } + + protected override void LoadComplete() + { + Checked.BindValueChanged(isChecked => checkboxIcon.Icon = isChecked.NewValue ? FontAwesome.Solid.CheckSquare : FontAwesome.Regular.Square, true); + base.LoadComplete(); + } + + protected override bool OnClick(ClickEvent e) + { + Checked.Value = !Checked.Value; + return true; + } + } + } + + public enum CommentsSortCriteria + { + [System.ComponentModel.Description(@"Recent")] + New, + Old, + Top + } +} diff --git a/osu.Game/Overlays/Comments/CommentsPage.cs b/osu.Game/Overlays/Comments/CommentsPage.cs new file mode 100644 index 0000000000..9b146b0a7d --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentsPage.cs @@ -0,0 +1,161 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using System.Linq; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API; +using System.Collections.Generic; +using JetBrains.Annotations; + +namespace osu.Game.Overlays.Comments +{ + public class CommentsPage : CompositeDrawable + { + public readonly BindableBool ShowDeleted = new BindableBool(); + public readonly Bindable<CommentsSortCriteria> Sort = new Bindable<CommentsSortCriteria>(); + public readonly Bindable<CommentableType> Type = new Bindable<CommentableType>(); + public readonly BindableLong CommentableId = new BindableLong(); + + [Resolved] + private IAPIProvider api { get; set; } + + private readonly CommentBundle commentBundle; + private FillFlowContainer flow; + + public CommentsPage(CommentBundle commentBundle) + { + this.commentBundle = commentBundle; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + AddRangeInternal(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5 + }, + flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + } + }); + + if (!commentBundle.Comments.Any()) + { + flow.Add(new NoCommentsPlaceholder()); + return; + } + + AppendComments(commentBundle); + } + + private DrawableComment getDrawableComment(Comment comment) + { + if (CommentDictionary.TryGetValue(comment.Id, out var existing)) + return existing; + + return CommentDictionary[comment.Id] = new DrawableComment(comment) + { + ShowDeleted = { BindTarget = ShowDeleted }, + Sort = { BindTarget = Sort }, + RepliesRequested = onCommentRepliesRequested + }; + } + + private void onCommentRepliesRequested(DrawableComment drawableComment, int page) + { + var request = new GetCommentsRequest(CommentableId.Value, Type.Value, Sort.Value, page, drawableComment.Comment.Id); + + request.Success += response => Schedule(() => AppendComments(response)); + + api.PerformAsync(request); + } + + protected readonly Dictionary<long, DrawableComment> CommentDictionary = new Dictionary<long, DrawableComment>(); + + /// <summary> + /// Appends retrieved comments to the subtree rooted of comments in this page. + /// </summary> + /// <param name="bundle">The bundle of comments to add.</param> + protected void AppendComments([NotNull] CommentBundle bundle) + { + var orphaned = new List<Comment>(); + + foreach (var comment in bundle.Comments.Concat(bundle.IncludedComments)) + { + // Exclude possible duplicated comments. + if (CommentDictionary.ContainsKey(comment.Id)) + continue; + + addNewComment(comment); + } + + // Comments whose parents were seen later than themselves can now be added. + foreach (var o in orphaned) + addNewComment(o); + + void addNewComment(Comment comment) + { + var drawableComment = getDrawableComment(comment); + + if (comment.ParentId == null) + { + // Comments that have no parent are added as top-level comments to the flow. + flow.Add(drawableComment); + } + else if (CommentDictionary.TryGetValue(comment.ParentId.Value, out var parentDrawable)) + { + // The comment's parent has already been seen, so the parent<-> child links can be added. + comment.ParentComment = parentDrawable.Comment; + parentDrawable.Replies.Add(drawableComment); + } + else + { + // The comment's parent has not been seen yet, so keep it orphaned for the time being. This can occur if the comments arrive out of order. + // Since this comment has now been seen, any further children can be added to it without being orphaned themselves. + orphaned.Add(comment); + } + } + } + + private class NoCommentsPlaceholder : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Height = 80; + RelativeSizeAxes = Axes.X; + AddRangeInternal(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4 + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Left = 50 }, + Text = @"No comments yet." + } + }); + } + } + } +} diff --git a/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs b/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs new file mode 100644 index 0000000000..adf64eabb1 --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentsShowMoreButton.cs @@ -0,0 +1,24 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Comments +{ + public class CommentsShowMoreButton : ShowMoreButton + { + public readonly BindableInt Current = new BindableInt(); + + protected override void LoadComplete() + { + Current.BindValueChanged(onCurrentChanged, true); + base.LoadComplete(); + } + + private void onCurrentChanged(ValueChangedEvent<int> count) + { + Text = $@"Show More ({count.NewValue})".ToUpper(); + } + } +} diff --git a/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs b/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs new file mode 100644 index 0000000000..56588ef0a8 --- /dev/null +++ b/osu.Game/Overlays/Comments/DeletedCommentsCounter.cs @@ -0,0 +1,65 @@ +// 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. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osu.Framework.Bindables; +using Humanizer; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Overlays.Comments +{ + public class DeletedCommentsCounter : CompositeDrawable + { + public readonly BindableBool ShowDeleted = new BindableBool(); + + public readonly BindableInt Count = new BindableInt(); + + private readonly SpriteText countText; + + public DeletedCommentsCounter() + { + AutoSizeAxes = Axes.Both; + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + Children = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.Trash, + Size = new Vector2(14), + }, + countText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Count.BindValueChanged(_ => updateDisplay(), true); + ShowDeleted.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateDisplay() + { + if (!ShowDeleted.Value && Count.Value != 0) + { + countText.Text = @"deleted comment".ToQuantity(Count.Value); + Show(); + } + else + Hide(); + } + } +} diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs new file mode 100644 index 0000000000..31aa41e967 --- /dev/null +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -0,0 +1,414 @@ +// 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. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Framework.Graphics.Sprites; +using osuTK; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Users.Drawables; +using osu.Game.Graphics.Containers; +using osu.Game.Utils; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Bindables; +using System.Linq; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Chat; +using osu.Framework.Allocation; +using System.Collections.Generic; +using System; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Extensions.IEnumerableExtensions; +using System.Collections.Specialized; +using osu.Game.Overlays.Comments.Buttons; + +namespace osu.Game.Overlays.Comments +{ + public class DrawableComment : CompositeDrawable + { + private const int avatar_size = 40; + + public Action<DrawableComment, int> RepliesRequested; + + public readonly Comment Comment; + + public readonly BindableBool ShowDeleted = new BindableBool(); + public readonly Bindable<CommentsSortCriteria> Sort = new Bindable<CommentsSortCriteria>(); + private readonly Dictionary<long, Comment> loadedReplies = new Dictionary<long, Comment>(); + + public readonly BindableList<DrawableComment> Replies = new BindableList<DrawableComment>(); + + private readonly BindableBool childrenExpanded = new BindableBool(true); + + private int currentPage; + + private FillFlowContainer childCommentsVisibilityContainer; + private FillFlowContainer childCommentsContainer; + private LoadRepliesButton loadRepliesButton; + private ShowMoreRepliesButton showMoreButton; + private ShowRepliesButton showRepliesButton; + private ChevronButton chevronButton; + private DeletedCommentsCounter deletedCommentsCounter; + + public DrawableComment(Comment comment) + { + Comment = comment; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + LinkFlowContainer username; + FillFlowContainer info; + LinkFlowContainer message; + GridContainer content; + VotePill votePill; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = getPadding(Comment.IsTopLevel), + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + content = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, size: avatar_size + 10), + new Dimension(), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new Container + { + Size = new Vector2(avatar_size), + Children = new Drawable[] + { + new UpdateableAvatar(Comment.User) + { + Size = new Vector2(avatar_size), + Masking = true, + CornerRadius = avatar_size / 2f, + CornerExponent = 2, + }, + votePill = new VotePill(Comment) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Margin = new MarginPadding + { + Right = 5 + } + } + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 4), + Margin = new MarginPadding + { + Vertical = 2 + }, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + username = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold)) + { + AutoSizeAxes = Axes.Both + }, + new ParentUsername(Comment), + new OsuSpriteText + { + Alpha = Comment.IsDeleted ? 1 : 0, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), + Text = "deleted" + } + } + }, + message = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, + info = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new DrawableDate(Comment.CreatedAt, 12, false) + { + Colour = colourProvider.Foreground1 + } + } + }, + new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + showRepliesButton = new ShowRepliesButton(Comment.RepliesCount) + { + Expanded = { BindTarget = childrenExpanded } + }, + loadRepliesButton = new LoadRepliesButton + { + Action = () => RepliesRequested(this, ++currentPage) + } + } + } + } + } + } + } + }, + childCommentsVisibilityContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Left = 20 }, + Children = new Drawable[] + { + childCommentsContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical + }, + deletedCommentsCounter = new DeletedCommentsCounter + { + ShowDeleted = { BindTarget = ShowDeleted }, + Margin = new MarginPadding + { + Top = 10 + } + }, + showMoreButton = new ShowMoreRepliesButton + { + Action = () => RepliesRequested(this, ++currentPage) + } + } + }, + } + } + }, + new Container + { + Size = new Vector2(70, 40), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Horizontal = 5 }, + Child = chevronButton = new ChevronButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Expanded = { BindTarget = childrenExpanded }, + Alpha = 0 + } + } + }; + + if (Comment.UserId.HasValue) + username.AddUserLink(Comment.User); + else + username.AddText(Comment.LegacyName); + + if (Comment.EditedAt.HasValue) + { + info.Add(new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), + Text = $@"edited {HumanizerUtils.Humanize(Comment.EditedAt.Value)} by {Comment.EditedUser.Username}", + Colour = colourProvider.Foreground1 + }); + } + + if (Comment.HasMessage) + { + var formattedSource = MessageFormatter.FormatText(Comment.Message); + message.AddLinks(formattedSource.Text, formattedSource.Links); + } + + if (Comment.IsDeleted) + { + content.FadeColour(OsuColour.Gray(0.5f)); + votePill.Hide(); + } + + if (Comment.IsTopLevel) + { + AddInternal(new Box + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.X, + Height = 1.5f, + Colour = OsuColour.Gray(0.1f) + }); + } + + if (Replies.Any()) + onRepliesAdded(Replies); + + Replies.CollectionChanged += (_, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + onRepliesAdded(args.NewItems.Cast<DrawableComment>()); + break; + + default: + throw new NotSupportedException(@"You can only add replies to this list. Other actions are not supported."); + } + }; + } + + protected override void LoadComplete() + { + ShowDeleted.BindValueChanged(show => + { + if (Comment.IsDeleted) + this.FadeTo(show.NewValue ? 1 : 0); + }, true); + childrenExpanded.BindValueChanged(expanded => childCommentsVisibilityContainer.FadeTo(expanded.NewValue ? 1 : 0), true); + + updateButtonsState(); + + base.LoadComplete(); + } + + public bool ContainsReply(long replyId) => loadedReplies.ContainsKey(replyId); + + private void onRepliesAdded(IEnumerable<DrawableComment> replies) + { + var page = createRepliesPage(replies); + + if (LoadState == LoadState.Loading) + { + addRepliesPage(page, replies); + return; + } + + LoadComponentAsync(page, loaded => addRepliesPage(loaded, replies)); + } + + private void addRepliesPage(FillFlowContainer<DrawableComment> page, IEnumerable<DrawableComment> replies) + { + childCommentsContainer.Add(page); + + var newReplies = replies.Select(reply => reply.Comment); + newReplies.ForEach(reply => loadedReplies.Add(reply.Id, reply)); + deletedCommentsCounter.Count.Value += newReplies.Count(reply => reply.IsDeleted); + updateButtonsState(); + } + + private FillFlowContainer<DrawableComment> createRepliesPage(IEnumerable<DrawableComment> replies) => new FillFlowContainer<DrawableComment> + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = replies.ToList() + }; + + private void updateButtonsState() + { + var loadedReplesCount = loadedReplies.Count; + var hasUnloadedReplies = loadedReplesCount != Comment.RepliesCount; + + loadRepliesButton.FadeTo(hasUnloadedReplies && loadedReplesCount == 0 ? 1 : 0); + showMoreButton.FadeTo(hasUnloadedReplies && loadedReplesCount > 0 ? 1 : 0); + showRepliesButton.FadeTo(loadedReplesCount != 0 ? 1 : 0); + + if (Comment.IsTopLevel) + chevronButton.FadeTo(loadedReplesCount != 0 ? 1 : 0); + + showMoreButton.IsLoading = loadRepliesButton.IsLoading = false; + } + + private MarginPadding getPadding(bool isTopLevel) + { + if (isTopLevel) + { + return new MarginPadding + { + Horizontal = 70, + Vertical = 15 + }; + } + + return new MarginPadding + { + Top = 10 + }; + } + + private class ParentUsername : FillFlowContainer, IHasTooltip + { + public string TooltipText => getParentMessage(); + + private readonly Comment parentComment; + + public ParentUsername(Comment comment) + { + parentComment = comment.ParentComment; + + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Horizontal; + Spacing = new Vector2(3, 0); + Alpha = comment.ParentId == null ? 0 : 1; + Children = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.Reply, + Size = new Vector2(14), + }, + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + Text = parentComment?.User?.Username ?? parentComment?.LegacyName + } + }; + } + + private string getParentMessage() + { + if (parentComment == null) + return string.Empty; + + return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? @"deleted" : string.Empty; + } + } + } +} diff --git a/osu.Game/Overlays/Comments/HeaderButton.cs b/osu.Game/Overlays/Comments/HeaderButton.cs new file mode 100644 index 0000000000..fdc8db35ab --- /dev/null +++ b/osu.Game/Overlays/Comments/HeaderButton.cs @@ -0,0 +1,68 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Comments +{ + public class HeaderButton : Container + { + private const int transition_duration = 200; + + protected override Container<Drawable> Content => content; + + private readonly Box background; + private readonly Container content; + + public HeaderButton() + { + AutoSizeAxes = Axes.X; + Height = 20; + Masking = true; + CornerRadius = 3; + AddRangeInternal(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + }, + content = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 10 } + }, + new HoverClickSounds(), + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background3; + } + + protected override bool OnHover(HoverEvent e) + { + ShowBackground(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + HideBackground(); + } + + protected void ShowBackground() => background.FadeIn(transition_duration, Easing.OutQuint); + + protected void HideBackground() => background.FadeOut(transition_duration, Easing.OutQuint); + } +} diff --git a/osu.Game/Overlays/Comments/TotalCommentsCounter.cs b/osu.Game/Overlays/Comments/TotalCommentsCounter.cs new file mode 100644 index 0000000000..1bb9b52689 --- /dev/null +++ b/osu.Game/Overlays/Comments/TotalCommentsCounter.cs @@ -0,0 +1,77 @@ +// 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. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Overlays.Comments +{ + public class TotalCommentsCounter : CompositeDrawable + { + public readonly BindableInt Current = new BindableInt(); + + private OsuSpriteText counter; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + Height = 50; + AddInternal(new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = 50 }, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 20, italics: true), + Colour = colourProvider.Light1, + Text = @"Comments" + }, + new CircularContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6 + }, + counter = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 10, Vertical = 5 }, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), + Colour = colourProvider.Foreground1 + } + }, + } + } + }); + } + + protected override void LoadComplete() + { + Current.BindValueChanged(value => counter.Text = value.NewValue.ToString("N0"), true); + base.LoadComplete(); + } + } +} diff --git a/osu.Game/Overlays/Comments/VotePill.cs b/osu.Game/Overlays/Comments/VotePill.cs new file mode 100644 index 0000000000..aa9723ea85 --- /dev/null +++ b/osu.Game/Overlays/Comments/VotePill.cs @@ -0,0 +1,190 @@ +// 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. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Allocation; +using osu.Game.Graphics.Sprites; +using osuTK.Graphics; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using System.Collections.Generic; +using osuTK; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Framework.Bindables; +using System.Linq; + +namespace osu.Game.Overlays.Comments +{ + public class VotePill : LoadingButton, IHasAccentColour + { + private const int duration = 200; + + public Color4 AccentColour { get; set; } + + protected override IEnumerable<Drawable> EffectTargets => null; + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private readonly Comment comment; + private Box background; + private Box hoverLayer; + private CircularContainer borderContainer; + private SpriteText sideNumber; + private OsuSpriteText votesCounter; + private CommentVoteRequest request; + + private readonly BindableBool isVoted = new BindableBool(); + private readonly BindableInt votesCount = new BindableInt(); + + public VotePill(Comment comment) + { + this.comment = comment; + + AutoSizeAxes = Axes.X; + Height = 20; + LoadingAnimationSize = new Vector2(10); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AccentColour = borderContainer.BorderColour = sideNumber.Colour = colours.GreenLight; + hoverLayer.Colour = Color4.Black.Opacity(0.5f); + + if (api.IsLoggedIn && api.LocalUser.Value.Id != comment.UserId) + Action = onAction; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + isVoted.Value = comment.IsVoted; + votesCount.Value = comment.VotesCount; + isVoted.BindValueChanged(voted => background.Colour = voted.NewValue ? AccentColour : colourProvider.Background6, true); + votesCount.BindValueChanged(count => votesCounter.Text = $"+{count.NewValue}", true); + } + + private void onAction() + { + request = new CommentVoteRequest(comment.Id, isVoted.Value ? CommentVoteAction.UnVote : CommentVoteAction.Vote); + request.Success += onSuccess; + api.Queue(request); + } + + private void onSuccess(CommentBundle response) + { + var receivedComment = response.Comments.Single(); + isVoted.Value = receivedComment.IsVoted; + votesCount.Value = receivedComment.VotesCount; + IsLoading = false; + } + + protected override Drawable CreateContent() => new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Children = new Drawable[] + { + borderContainer = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + hoverLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0 + } + } + }, + sideNumber = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Text = "+1", + Font = OsuFont.GetFont(size: 14), + Margin = new MarginPadding { Right = 3 }, + Alpha = 0, + }, + votesCounter = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 10 }, + Font = OsuFont.GetFont(size: 14), + AlwaysPresent = true, + } + }, + }; + + protected override void OnLoadStarted() + { + votesCounter.FadeOut(duration, Easing.OutQuint); + updateDisplay(); + } + + protected override void OnLoadFinished() + { + votesCounter.FadeIn(duration, Easing.OutQuint); + + if (IsHovered) + onHoverAction(); + } + + protected override bool OnHover(HoverEvent e) + { + onHoverAction(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateDisplay(); + base.OnHoverLost(e); + } + + private void updateDisplay() + { + if (Action == null) + return; + + if (isVoted.Value) + { + hoverLayer.FadeTo(IsHovered ? 1 : 0); + sideNumber.Hide(); + } + else + sideNumber.FadeTo(IsHovered ? 1 : 0); + + borderContainer.BorderThickness = IsHovered ? 3 : 0; + } + + private void onHoverAction() + { + if (!IsLoading) + updateDisplay(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + request?.Cancel(); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs new file mode 100644 index 0000000000..d39a81f5e8 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs @@ -0,0 +1,149 @@ +// 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. + +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.Spectator; +using osu.Game.Screens.Multi.Match.Components; +using osu.Game.Screens.Play; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Dashboard +{ + internal class CurrentlyPlayingDisplay : CompositeDrawable + { + private readonly IBindableList<int> playingUsers = new BindableList<int>(); + + private FillFlowContainer<PlayingUserPanel> userFlow; + + [Resolved] + private SpectatorStreamingClient spectatorStreaming { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = userFlow = new FillFlowContainer<PlayingUserPanel> + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Spacing = new Vector2(10), + }; + } + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private UserLookupCache users { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + playingUsers.BindTo(spectatorStreaming.PlayingUsers); + playingUsers.BindCollectionChanged(onUsersChanged, true); + } + + private void onUsersChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() => + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var id in e.NewItems.OfType<int>().ToArray()) + { + users.GetUserAsync(id).ContinueWith(u => + { + if (u.Result == null) return; + + Schedule(() => + { + // user may no longer be playing. + if (!playingUsers.Contains(u.Result.Id)) + return; + + userFlow.Add(createUserPanel(u.Result)); + }); + }); + } + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var u in e.OldItems.OfType<int>()) + userFlow.FirstOrDefault(card => card.User.Id == u)?.Expire(); + break; + + case NotifyCollectionChangedAction.Reset: + userFlow.Clear(); + break; + } + }); + + private PlayingUserPanel createUserPanel(User user) => + new PlayingUserPanel(user).With(panel => + { + panel.Anchor = Anchor.TopCentre; + panel.Origin = Anchor.TopCentre; + }); + + private class PlayingUserPanel : CompositeDrawable + { + public readonly User User; + + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } + + public PlayingUserPanel(User user) + { + User = user; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) + { + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Width = 290, + Children = new Drawable[] + { + new UserGridPanel(User) + { + RelativeSizeAxes = Axes.X, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + new PurpleTriangleButton + { + RelativeSizeAxes = Axes.X, + Text = "Watch", + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Action = () => game?.PerformFromScreen(s => s.Push(new Spectator(User))), + Enabled = { Value = User.Id != api.LocalUser.Value.Id } + } + } + }, + }; + } + } + } +} diff --git a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs new file mode 100644 index 0000000000..3314ed957a --- /dev/null +++ b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs @@ -0,0 +1,30 @@ +// 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. + +using System.ComponentModel; + +namespace osu.Game.Overlays.Dashboard +{ + public class DashboardOverlayHeader : TabControlOverlayHeader<DashboardOverlayTabs> + { + protected override OverlayTitle CreateTitle() => new DashboardTitle(); + + private class DashboardTitle : OverlayTitle + { + public DashboardTitle() + { + Title = "dashboard"; + Description = "view your friends and other information"; + IconTexture = "Icons/Hexacons/social"; + } + } + } + + public enum DashboardOverlayTabs + { + Friends, + + [Description("Currently Playing")] + CurrentlyPlaying + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs new file mode 100644 index 0000000000..41b25ee1a5 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -0,0 +1,256 @@ +// 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. + +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.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class FriendDisplay : OverlayView<List<User>> + { + private List<User> users = new List<User>(); + + public List<User> Users + { + get => users; + set + { + users = value; + onlineStreamControl.Populate(value); + } + } + + private CancellationTokenSource cancellationToken; + + private Drawable currentContent; + + private FriendOnlineStreamControl onlineStreamControl; + private Box background; + private Box controlBackground; + private UserListToolbar userListToolbar; + private Container itemsPlaceholder; + private LoadingLayer loading; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + controlBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Top = 20, + Horizontal = 45 + }, + Child = onlineStreamControl = new FriendOnlineStreamControl(), + } + } + }, + new Container + { + Name = "User List", + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Margin = new MarginPadding { Bottom = 20 }, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 40, + Vertical = 20 + }, + Child = userListToolbar = new UserListToolbar + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + itemsPlaceholder = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 50 } + }, + loading = new LoadingLayer(itemsPlaceholder) + } + } + } + } + } + } + } + }; + + background.Colour = colourProvider.Background4; + controlBackground.Colour = colourProvider.Background5; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + onlineStreamControl.Current.BindValueChanged(_ => recreatePanels()); + userListToolbar.DisplayStyle.BindValueChanged(_ => recreatePanels()); + userListToolbar.SortCriteria.BindValueChanged(_ => recreatePanels()); + } + + protected override APIRequest<List<User>> CreateRequest() => new GetFriendsRequest(); + + protected override void OnSuccess(List<User> response) + { + Users = response; + } + + private void recreatePanels() + { + if (!users.Any()) + return; + + cancellationToken?.Cancel(); + + if (itemsPlaceholder.Any()) + loading.Show(); + + var sortedUsers = sortUsers(getUsersInCurrentGroup()); + + LoadComponentAsync(createTable(sortedUsers), addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + } + + private List<User> getUsersInCurrentGroup() + { + switch (onlineStreamControl.Current.Value?.Status) + { + default: + case OnlineStatus.All: + return users; + + case OnlineStatus.Offline: + return users.Where(u => !u.IsOnline).ToList(); + + case OnlineStatus.Online: + return users.Where(u => u.IsOnline).ToList(); + } + } + + private void addContentToPlaceholder(Drawable content) + { + loading.Hide(); + + var lastContent = currentContent; + + if (lastContent != null) + { + lastContent.FadeOut(100, Easing.OutQuint).Expire(); + lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y); + } + + itemsPlaceholder.Add(currentContent = content); + currentContent.FadeIn(200, Easing.OutQuint); + } + + private FillFlowContainer createTable(List<User> users) + { + var style = userListToolbar.DisplayStyle.Value; + + return new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(style == OverlayPanelDisplayStyle.Card ? 10 : 2), + Children = users.Select(u => createUserPanel(u, style)).ToList() + }; + } + + private UserPanel createUserPanel(User user, OverlayPanelDisplayStyle style) + { + switch (style) + { + default: + case OverlayPanelDisplayStyle.Card: + return new UserGridPanel(user).With(panel => + { + panel.Anchor = Anchor.TopCentre; + panel.Origin = Anchor.TopCentre; + panel.Width = 290; + }); + + case OverlayPanelDisplayStyle.List: + return new UserListPanel(user); + + case OverlayPanelDisplayStyle.Brick: + return new UserBrickPanel(user); + } + } + + private List<User> sortUsers(List<User> unsorted) + { + switch (userListToolbar.SortCriteria.Value) + { + default: + case UserSortCriteria.LastVisit: + return unsorted.OrderByDescending(u => u.LastVisit).ToList(); + + case UserSortCriteria.Rank: + return unsorted.OrderByDescending(u => u.CurrentModeRank.HasValue).ThenBy(u => u.CurrentModeRank ?? 0).ToList(); + + case UserSortCriteria.Username: + return unsorted.OrderBy(u => u.Username).ToList(); + } + } + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs b/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs new file mode 100644 index 0000000000..28546ceab8 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/FriendOnlineStreamControl.cs @@ -0,0 +1,28 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Users; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class FriendOnlineStreamControl : OverlayStreamControl<FriendStream> + { + protected override OverlayStreamItem<FriendStream> CreateStreamItem(FriendStream value) => new FriendsOnlineStatusItem(value); + + public void Populate(List<User> users) + { + Clear(); + + var userCount = users.Count; + var onlineUsersCount = users.Count(user => user.IsOnline); + + AddItem(new FriendStream(OnlineStatus.All, userCount)); + AddItem(new FriendStream(OnlineStatus.Online, onlineUsersCount)); + AddItem(new FriendStream(OnlineStatus.Offline, userCount - onlineUsersCount)); + + Current.Value = Items.FirstOrDefault(); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs b/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs new file mode 100644 index 0000000000..4abece9a8d --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/FriendStream.cs @@ -0,0 +1,18 @@ +// 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. + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class FriendStream + { + public OnlineStatus Status { get; } + + public int Count { get; } + + public FriendStream(OnlineStatus status, int count) + { + Status = status; + Count = count; + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs new file mode 100644 index 0000000000..7e902203f8 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs @@ -0,0 +1,39 @@ +// 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. + +using System; +using osu.Game.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class FriendsOnlineStatusItem : OverlayStreamItem<FriendStream> + { + public FriendsOnlineStatusItem(FriendStream value) + : base(value) + { + } + + protected override string MainText => Value.Status.ToString(); + + protected override string AdditionalText => Value.Count.ToString(); + + protected override Color4 GetBarColour(OsuColour colours) + { + switch (Value.Status) + { + case OnlineStatus.All: + return Color4.White; + + case OnlineStatus.Online: + return colours.GreenLight; + + case OnlineStatus.Offline: + return Color4.Black; + + default: + throw new ArgumentException($@"{Value.Status} status does not provide a colour in {nameof(GetBarColour)}."); + } + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs new file mode 100644 index 0000000000..6f2f55a6ed --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs @@ -0,0 +1,12 @@ +// 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. + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public enum OnlineStatus + { + All, + Online, + Offline + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs b/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs new file mode 100644 index 0000000000..fb4b938183 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/UserListToolbar.cs @@ -0,0 +1,45 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Framework.Bindables; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class UserListToolbar : CompositeDrawable + { + public Bindable<UserSortCriteria> SortCriteria => sortControl.Current; + + public Bindable<OverlayPanelDisplayStyle> DisplayStyle => styleControl.Current; + + private readonly UserSortTabControl sortControl; + private readonly OverlayPanelDisplayStyleControl styleControl; + + public UserListToolbar() + { + AutoSizeAxes = Axes.Both; + + AddInternal(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + sortControl = new UserSortTabControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + styleControl = new OverlayPanelDisplayStyleControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs new file mode 100644 index 0000000000..3a5f65212d --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs @@ -0,0 +1,19 @@ +// 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. + +using System.ComponentModel; + +namespace osu.Game.Overlays.Dashboard.Friends +{ + public class UserSortTabControl : OverlaySortTabControl<UserSortCriteria> + { + } + + public enum UserSortCriteria + { + [Description(@"Recently Active")] + LastVisit, + Rank, + Username + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapListing.cs b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapListing.cs new file mode 100644 index 0000000000..4d96825353 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapListing.cs @@ -0,0 +1,43 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class DashboardBeatmapListing : CompositeDrawable + { + private readonly List<BeatmapSetInfo> newBeatmaps; + private readonly List<BeatmapSetInfo> popularBeatmaps; + + public DashboardBeatmapListing(List<BeatmapSetInfo> newBeatmaps, List<BeatmapSetInfo> popularBeatmaps) + { + this.newBeatmaps = newBeatmaps; + this.popularBeatmaps = popularBeatmaps; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = new FillFlowContainer<DrawableBeatmapList> + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new DrawableBeatmapList[] + { + new DrawableNewBeatmapList(newBeatmaps), + new DrawablePopularBeatmapList(popularBeatmaps) + } + }; + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs new file mode 100644 index 0000000000..3badea155d --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs @@ -0,0 +1,168 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public abstract class DashboardBeatmapPanel : OsuClickableContainer + { + [Resolved] + protected OverlayColourProvider ColourProvider { get; private set; } + + [Resolved(canBeNull: true)] + private BeatmapSetOverlay beatmapOverlay { get; set; } + + protected readonly BeatmapSetInfo SetInfo; + + private Box hoverBackground; + private SpriteIcon chevron; + + protected DashboardBeatmapPanel(BeatmapSetInfo setInfo) + { + SetInfo = setInfo; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + Height = 60; + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = -10 }, + Child = hoverBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background3, + Alpha = 0 + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 70), + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 6, + Child = new UpdateableBeatmapSetCover + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BeatmapSet = SetInfo + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 10 }, + Child = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Truncate = true, + Font = OsuFont.GetFont(weight: FontWeight.Regular), + Text = SetInfo.Metadata.Title + }, + new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Truncate = true, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), + Text = SetInfo.Metadata.Artist + }, + new LinkFlowContainer(f => f.Font = OsuFont.GetFont(size: 10, weight: FontWeight.Regular)) + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Spacing = new Vector2(3), + Margin = new MarginPadding { Top = 2 } + }.With(c => + { + c.AddText("by"); + c.AddUserLink(SetInfo.Metadata.Author); + c.AddArbitraryDrawable(CreateInfo()); + }) + } + } + }, + chevron = new SpriteIcon + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(16), + Icon = FontAwesome.Solid.ChevronRight, + Colour = ColourProvider.Foreground1 + } + } + } + } + } + }; + + Action = () => + { + if (SetInfo.OnlineBeatmapSetID.HasValue) + beatmapOverlay?.FetchAndShowBeatmapSet(SetInfo.OnlineBeatmapSetID.Value); + }; + } + + protected abstract Drawable CreateInfo(); + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + hoverBackground.FadeIn(200, Easing.OutQuint); + chevron.FadeColour(ColourProvider.Light1, 200, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + hoverBackground.FadeOut(200, Easing.OutQuint); + chevron.FadeColour(ColourProvider.Foreground1, 200, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardNewBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardNewBeatmapPanel.cs new file mode 100644 index 0000000000..b212eaf20a --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DashboardNewBeatmapPanel.cs @@ -0,0 +1,23 @@ +// 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. + +using System; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Graphics; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class DashboardNewBeatmapPanel : DashboardBeatmapPanel + { + public DashboardNewBeatmapPanel(BeatmapSetInfo setInfo) + : base(setInfo) + { + } + + protected override Drawable CreateInfo() => new DrawableDate(SetInfo.OnlineInfo.Ranked ?? DateTimeOffset.Now, 10, false) + { + Colour = ColourProvider.Foreground1 + }; + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardPopularBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardPopularBeatmapPanel.cs new file mode 100644 index 0000000000..e9066c0657 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DashboardPopularBeatmapPanel.cs @@ -0,0 +1,42 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class DashboardPopularBeatmapPanel : DashboardBeatmapPanel + { + public DashboardPopularBeatmapPanel(BeatmapSetInfo setInfo) + : base(setInfo) + { + } + + protected override Drawable CreateInfo() => new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + Colour = ColourProvider.Foreground1, + Children = new Drawable[] + { + new SpriteIcon + { + Size = new Vector2(10), + Icon = FontAwesome.Solid.Heart + }, + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Regular), + Text = SetInfo.OnlineInfo.FavouriteCount.ToString() + } + } + }; + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DrawableBeatmapList.cs b/osu.Game/Overlays/Dashboard/Home/DrawableBeatmapList.cs new file mode 100644 index 0000000000..f6535b7db3 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DrawableBeatmapList.cs @@ -0,0 +1,56 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public abstract class DrawableBeatmapList : CompositeDrawable + { + private readonly List<BeatmapSetInfo> beatmaps; + + protected DrawableBeatmapList(List<BeatmapSetInfo> beatmaps) + { + this.beatmaps = beatmaps; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + FillFlowContainer flow; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold), + Colour = colourProvider.Light1, + Text = Title + } + } + }; + + flow.AddRange(beatmaps.Select(CreateBeatmapPanel)); + } + + protected abstract string Title { get; } + + protected abstract DashboardBeatmapPanel CreateBeatmapPanel(BeatmapSetInfo setInfo); + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DrawableNewBeatmapList.cs b/osu.Game/Overlays/Dashboard/Home/DrawableNewBeatmapList.cs new file mode 100644 index 0000000000..75e8ca336d --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DrawableNewBeatmapList.cs @@ -0,0 +1,20 @@ +// 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. + +using System.Collections.Generic; +using osu.Game.Beatmaps; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class DrawableNewBeatmapList : DrawableBeatmapList + { + public DrawableNewBeatmapList(List<BeatmapSetInfo> beatmaps) + : base(beatmaps) + { + } + + protected override DashboardBeatmapPanel CreateBeatmapPanel(BeatmapSetInfo setInfo) => new DashboardNewBeatmapPanel(setInfo); + + protected override string Title => "New Ranked Beatmaps"; + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/DrawablePopularBeatmapList.cs b/osu.Game/Overlays/Dashboard/Home/DrawablePopularBeatmapList.cs new file mode 100644 index 0000000000..90bd00008c --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/DrawablePopularBeatmapList.cs @@ -0,0 +1,20 @@ +// 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. + +using System.Collections.Generic; +using osu.Game.Beatmaps; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class DrawablePopularBeatmapList : DrawableBeatmapList + { + public DrawablePopularBeatmapList(List<BeatmapSetInfo> beatmaps) + : base(beatmaps) + { + } + + protected override DashboardBeatmapPanel CreateBeatmapPanel(BeatmapSetInfo setInfo) => new DashboardPopularBeatmapPanel(setInfo); + + protected override string Title => "Popular Beatmaps"; + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/HomePanel.cs b/osu.Game/Overlays/Dashboard/Home/HomePanel.cs new file mode 100644 index 0000000000..ce053cd4ec --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/HomePanel.cs @@ -0,0 +1,55 @@ +// 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. + +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.Graphics.Shapes; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Dashboard.Home +{ + public class HomePanel : Container + { + protected override Container<Drawable> Content => content; + + private readonly Container content; + private readonly Box background; + + public HomePanel() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.25f), + Type = EdgeEffectType.Shadow, + Radius = 3, + Offset = new Vector2(0, 1) + }; + + AddRangeInternal(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background4; + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs b/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs new file mode 100644 index 0000000000..ee88469e2f --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/News/FeaturedNewsItemPanel.cs @@ -0,0 +1,195 @@ +// 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. + +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.Framework.Platform; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.News; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Dashboard.Home.News +{ + public class FeaturedNewsItemPanel : HomePanel + { + private readonly APINewsPost post; + + public FeaturedNewsItemPanel(APINewsPost post) + { + this.post = post; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new ClickableNewsBackground(post), + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, size: 60), + new Dimension(GridSizeMode.Absolute, size: 20), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new Date(post.PublishedAt), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = 10 }, + Child = new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight, + Width = 1, + RelativeSizeAxes = Axes.Y, + Colour = colourProvider.Light1 + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 5, Bottom = 10 }, + Padding = new MarginPadding { Right = 10 }, + Spacing = new Vector2(0, 10), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new NewsTitleLink(post), + new TextFlowContainer(f => + { + f.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular); + }) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = post.Preview + } + } + } + } + } + } + } + } + }; + } + + private class ClickableNewsBackground : OsuHoverContainer + { + private readonly APINewsPost post; + + public ClickableNewsBackground(APINewsPost post) + { + this.post = post; + + RelativeSizeAxes = Axes.X; + Height = 130; + } + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + NewsPostBackground bg; + + Child = new DelayedLoadWrapper(bg = new NewsPostBackground(post.FirstImage) + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0 + }) + { + RelativeSizeAxes = Axes.Both + }; + + bg.OnLoadComplete += d => d.FadeIn(250, Easing.In); + + TooltipText = "view in browser"; + Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); + + HoverColour = Color4.White; + } + } + + private class Date : CompositeDrawable, IHasCustomTooltip + { + public ITooltip GetCustomTooltip() => new DateTooltip(); + + public object TooltipContent => date; + + private readonly DateTimeOffset date; + + public Date(DateTimeOffset date) + { + this.date = date; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AutoSizeAxes = Axes.Both; + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + Margin = new MarginPadding { Top = 10 }; + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.GetFont(weight: FontWeight.Bold), // using Bold since there is no 800 weight alternative + Colour = colourProvider.Light1, + Text = $"{date:dd}" + }, + new TextFlowContainer(f => + { + f.Font = OsuFont.GetFont(size: 11, weight: FontWeight.Regular); + f.Colour = colourProvider.Light1; + }) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Text = $"{date:MMM yyyy}" + } + } + }; + } + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs b/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs new file mode 100644 index 0000000000..dc4f3f8c92 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/News/NewsGroupItem.cs @@ -0,0 +1,115 @@ +// 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. + +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.Graphics; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Overlays.Dashboard.Home.News +{ + public class NewsGroupItem : CompositeDrawable + { + private readonly APINewsPost post; + + public NewsGroupItem(APINewsPost post) + { + this.post = post; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, size: 60), + new Dimension(GridSizeMode.Absolute, size: 20), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new Date(post.PublishedAt), + new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight, + Width = 1, + RelativeSizeAxes = Axes.Y, + Colour = colourProvider.Light1 + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Padding = new MarginPadding { Right = 10 }, + Child = new NewsTitleLink(post) + } + } + } + }; + } + + private class Date : CompositeDrawable, IHasCustomTooltip + { + public ITooltip GetCustomTooltip() => new DateTooltip(); + + public object TooltipContent => date; + + private readonly DateTimeOffset date; + + public Date(DateTimeOffset date) + { + this.date = date; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + TextFlowContainer textFlow; + + AutoSizeAxes = Axes.Both; + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + InternalChild = textFlow = new TextFlowContainer(t => + { + t.Colour = colourProvider.Light1; + }) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Vertical = 5 } + }; + + textFlow.AddText($"{date:dd}", t => + { + t.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold); + }); + + textFlow.AddText($"{date: MMM}", t => + { + t.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Regular); + }); + } + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/News/NewsItemGroupPanel.cs b/osu.Game/Overlays/Dashboard/Home/News/NewsItemGroupPanel.cs new file mode 100644 index 0000000000..c1d5a87ef5 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/News/NewsItemGroupPanel.cs @@ -0,0 +1,36 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Overlays.Dashboard.Home.News +{ + public class NewsItemGroupPanel : HomePanel + { + private readonly List<APINewsPost> posts; + + public NewsItemGroupPanel(List<APINewsPost> posts) + { + this.posts = posts; + } + + [BackgroundDependencyLoader] + private void load() + { + Content.Padding = new MarginPadding { Vertical = 5 }; + + Child = new FillFlowContainer<NewsGroupItem> + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = posts.Select(p => new NewsGroupItem(p)).ToArray() + }; + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/News/NewsTitleLink.cs b/osu.Game/Overlays/Dashboard/Home/News/NewsTitleLink.cs new file mode 100644 index 0000000000..d6a3a69fe0 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/News/NewsTitleLink.cs @@ -0,0 +1,45 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Overlays.Dashboard.Home.News +{ + public class NewsTitleLink : OsuHoverContainer + { + private readonly APINewsPost post; + + public NewsTitleLink(APINewsPost post) + { + this.post = post; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(GameHost host, OverlayColourProvider colourProvider) + { + Child = new TextFlowContainer(t => + { + t.Font = OsuFont.GetFont(weight: FontWeight.Bold); + }) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = post.Title + }; + + HoverColour = colourProvider.Light1; + + TooltipText = "view in browser"; + Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); + } + } +} diff --git a/osu.Game/Overlays/Dashboard/Home/News/ShowMoreNewsPanel.cs b/osu.Game/Overlays/Dashboard/Home/News/ShowMoreNewsPanel.cs new file mode 100644 index 0000000000..d25df6f189 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/Home/News/ShowMoreNewsPanel.cs @@ -0,0 +1,51 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Dashboard.Home.News +{ + public class ShowMoreNewsPanel : OsuHoverContainer + { + protected override IEnumerable<Drawable> EffectTargets => new[] { text }; + + [Resolved(canBeNull: true)] + private NewsOverlay overlay { get; set; } + + private OsuSpriteText text; + + public ShowMoreNewsPanel() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Child = new HomePanel + { + Child = text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Vertical = 20 }, + Text = "see more" + } + }; + + IdleColour = colourProvider.Light1; + HoverColour = Color4.White; + + Action = () => + { + overlay?.ShowFrontPage(); + }; + } + } +} diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs new file mode 100644 index 0000000000..04defce636 --- /dev/null +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -0,0 +1,156 @@ +// 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. + +using System; +using System.Threading; +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.UserInterface; +using osu.Game.Online.API; +using osu.Game.Overlays.Dashboard; +using osu.Game.Overlays.Dashboard.Friends; + +namespace osu.Game.Overlays +{ + public class DashboardOverlay : FullscreenOverlay<DashboardOverlayHeader> + { + private CancellationTokenSource cancellationToken; + + private Container content; + private LoadingLayer loading; + private OverlayScrollContainer scrollFlow; + + public DashboardOverlay() + : base(OverlayColourScheme.Purple, new DashboardOverlayHeader + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Depth = -float.MaxValue + }) + { + } + + private readonly IBindable<APIState> apiState = new Bindable<APIState>(); + + [BackgroundDependencyLoader] + private void load(IAPIProvider api) + { + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background5 + }, + scrollFlow = new OverlayScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + Header, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }, + loading = new LoadingLayer(content), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Header.Current.BindValueChanged(onTabChanged); + } + + private bool displayUpdateRequired = true; + + protected override void PopIn() + { + base.PopIn(); + + // We don't want to create a new display on every call, only when exiting from fully closed state. + if (displayUpdateRequired) + { + Header.Current.TriggerChange(); + displayUpdateRequired = false; + } + } + + protected override void PopOutComplete() + { + base.PopOutComplete(); + loadDisplay(Empty()); + displayUpdateRequired = true; + } + + private void loadDisplay(Drawable display) + { + scrollFlow.ScrollToStart(); + + LoadComponentAsync(display, loaded => + { + if (API.IsLoggedIn) + loading.Hide(); + + content.Child = loaded; + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + private void onTabChanged(ValueChangedEvent<DashboardOverlayTabs> tab) + { + cancellationToken?.Cancel(); + loading.Show(); + + if (!API.IsLoggedIn) + { + loadDisplay(Empty()); + return; + } + + switch (tab.NewValue) + { + case DashboardOverlayTabs.Friends: + loadDisplay(new FriendDisplay()); + break; + + case DashboardOverlayTabs.CurrentlyPlaying: + loadDisplay(new CurrentlyPlayingDisplay()); + break; + + default: + throw new NotImplementedException($"Display for {tab.NewValue} tab is not implemented"); + } + } + + private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() => + { + if (State.Value == Visibility.Hidden) + return; + + Header.Current.TriggerChange(); + }); + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 1022edfe81..1bcbe4dd2f 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -10,22 +10,18 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; -using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Overlays.Dialog { - public class PopupDialog : OsuFocusedOverlayContainer + public abstract class PopupDialog : VisibilityContainer { - public static readonly float ENTER_DURATION = 500; - public static readonly float EXIT_DURATION = 200; - - protected override bool BlockPositionalInput => false; + public const float ENTER_DURATION = 500; + public const float EXIT_DURATION = 200; private readonly Vector2 ringSize = new Vector2(100f); private readonly Vector2 ringMinifiedSize = new Vector2(20f); @@ -46,25 +42,34 @@ namespace osu.Game.Overlays.Dialog set => icon.Icon = value; } - private string text; + private string headerText; public string HeaderText { - get => text; + get => headerText; set { - if (text == value) + if (headerText == value) return; - text = value; - + headerText = value; header.Text = value; } } + private string bodyText; + public string BodyText { - set => body.Text = value; + get => bodyText; + set + { + if (bodyText == value) + return; + + bodyText = value; + body.Text = value; + } } public IEnumerable<PopupDialogButton> Buttons @@ -90,7 +95,7 @@ namespace osu.Game.Overlays.Dialog } } - public PopupDialog() + protected PopupDialog() { RelativeSizeAxes = Axes.Both; @@ -117,13 +122,13 @@ namespace osu.Game.Overlays.Dialog new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"221a21"), + Colour = Color4Extensions.FromHex(@"221a21"), }, new Triangles { RelativeSizeAxes = Axes.Both, - ColourLight = OsuColour.FromHex(@"271e26"), - ColourDark = OsuColour.FromHex(@"1e171e"), + ColourLight = Color4Extensions.FromHex(@"271e26"), + ColourDark = Color4Extensions.FromHex(@"1e171e"), TriangleScale = 4, }, }, @@ -202,18 +207,6 @@ namespace osu.Game.Overlays.Dialog }; } - public override bool OnPressed(GlobalAction action) - { - switch (action) - { - case GlobalAction.Select: - Buttons.OfType<PopupDialogOkButton>().FirstOrDefault()?.Click(); - return true; - } - - return base.OnPressed(action); - } - protected override bool OnKeyDown(KeyDownEvent e) { if (e.Repeat) return false; @@ -238,8 +231,6 @@ namespace osu.Game.Overlays.Dialog protected override void PopIn() { - base.PopIn(); - actionInvoked = false; // Reset various animations but only if the dialog animation fully completed @@ -258,12 +249,11 @@ namespace osu.Game.Overlays.Dialog protected override void PopOut() { - if (!actionInvoked) + if (!actionInvoked && content.IsPresent) // In the case a user did not choose an action before a hide was triggered, press the last button. // This is presumed to always be a sane default "cancel" action. buttonsContainer.Last().Click(); - base.PopOut(); content.FadeOut(EXIT_DURATION, Easing.InSine); } diff --git a/osu.Game/Overlays/Dialog/PopupDialogButton.cs b/osu.Game/Overlays/Dialog/PopupDialogButton.cs index 75bae25b73..76ee438d6d 100644 --- a/osu.Game/Overlays/Dialog/PopupDialogButton.cs +++ b/osu.Game/Overlays/Dialog/PopupDialogButton.cs @@ -1,7 +1,7 @@ // 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. -using osu.Game.Graphics; +using osu.Framework.Extensions.Color4Extensions; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Dialog @@ -11,7 +11,7 @@ namespace osu.Game.Overlays.Dialog public PopupDialogButton() { Height = 50; - BackgroundColour = OsuColour.FromHex(@"150e14"); + BackgroundColour = Color4Extensions.FromHex(@"150e14"); TextSize = 18; } } diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index aaae7bcf5c..9f9dbdbaf1 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -5,13 +5,16 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Dialog; using osu.Game.Graphics.Containers; +using osu.Game.Input.Bindings; +using System.Linq; namespace osu.Game.Overlays { public class DialogOverlay : OsuFocusedOverlayContainer { private readonly Container dialogContainer; - private PopupDialog currentDialog; + + public PopupDialog CurrentDialog { get; private set; } public DialogOverlay() { @@ -29,31 +32,32 @@ namespace osu.Game.Overlays public void Push(PopupDialog dialog) { - if (dialog == currentDialog) return; + if (dialog == CurrentDialog) return; - currentDialog?.Hide(); - currentDialog = dialog; + CurrentDialog?.Hide(); + CurrentDialog = dialog; - dialogContainer.Add(currentDialog); + dialogContainer.Add(CurrentDialog); - currentDialog.Show(); - currentDialog.State.ValueChanged += state => onDialogOnStateChanged(dialog, state.NewValue); + CurrentDialog.Show(); + CurrentDialog.State.ValueChanged += state => onDialogOnStateChanged(dialog, state.NewValue); Show(); } - protected override bool PlaySamplesOnStateChange => false; - protected override bool BlockNonPositionalInput => true; private void onDialogOnStateChanged(VisibilityContainer dialog, Visibility v) { if (v != Visibility.Hidden) return; - //handle the dialog being dismissed. + // handle the dialog being dismissed. dialog.Delay(PopupDialog.EXIT_DURATION).Expire(); - if (dialog == currentDialog) + if (dialog == CurrentDialog) + { Hide(); + CurrentDialog = null; + } } protected override void PopIn() @@ -66,13 +70,25 @@ namespace osu.Game.Overlays { base.PopOut(); - if (currentDialog?.State.Value == Visibility.Visible) + if (CurrentDialog?.State.Value == Visibility.Visible) { - currentDialog.Hide(); + CurrentDialog.Hide(); return; } this.FadeOut(PopupDialog.EXIT_DURATION, Easing.InSine); } + + public override bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.Select: + CurrentDialog?.Buttons.OfType<PopupDialogOkButton>().FirstOrDefault()?.Click(); + return true; + } + + return base.OnPressed(action); + } } } diff --git a/osu.Game/Overlays/Direct/DirectRulesetSelector.cs b/osu.Game/Overlays/Direct/DirectRulesetSelector.cs deleted file mode 100644 index 106aaa616b..0000000000 --- a/osu.Game/Overlays/Direct/DirectRulesetSelector.cs +++ /dev/null @@ -1,93 +0,0 @@ -// 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. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Direct -{ - public class DirectRulesetSelector : RulesetSelector - { - public override bool HandleNonPositionalInput => !Current.Disabled && base.HandleNonPositionalInput; - - public override bool HandlePositionalInput => !Current.Disabled && base.HandlePositionalInput; - - public override bool PropagatePositionalInputSubTree => !Current.Disabled && base.PropagatePositionalInputSubTree; - - public DirectRulesetSelector() - { - TabContainer.Masking = false; - TabContainer.Spacing = new Vector2(10, 0); - AutoSizeAxes = Axes.Both; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Current.BindDisabledChanged(value => SelectedTab.FadeColour(value ? Color4.DarkGray : Color4.White, 200, Easing.OutQuint), true); - } - - protected override TabItem<RulesetInfo> CreateTabItem(RulesetInfo value) => new DirectRulesetTabItem(value); - - protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - }; - - private class DirectRulesetTabItem : TabItem<RulesetInfo> - { - private readonly ConstrainedIconContainer iconContainer; - - public DirectRulesetTabItem(RulesetInfo value) - : base(value) - { - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] - { - iconContainer = new ConstrainedIconContainer - { - Icon = value.CreateInstance().CreateIcon(), - Size = new Vector2(32), - }, - new HoverClickSounds() - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - updateState(); - } - - protected override bool OnHover(HoverEvent e) - { - base.OnHover(e); - updateState(); - return true; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - base.OnHoverLost(e); - updateState(); - } - - protected override void OnActivated() => updateState(); - - protected override void OnDeactivated() => updateState(); - - private void updateState() => iconContainer.FadeColour(IsHovered || Active.Value ? Color4.White : Color4.Gray, 120, Easing.InQuad); - } - } -} diff --git a/osu.Game/Overlays/Direct/FilterControl.cs b/osu.Game/Overlays/Direct/FilterControl.cs deleted file mode 100644 index 4b43542b43..0000000000 --- a/osu.Game/Overlays/Direct/FilterControl.cs +++ /dev/null @@ -1,46 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.SearchableList; -using osu.Game.Rulesets; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Direct -{ - public class FilterControl : SearchableListFilterControl<DirectSortCriteria, BeatmapSearchCategory> - { - private DirectRulesetSelector rulesetSelector; - - protected override Color4 BackgroundColour => OsuColour.FromHex(@"384552"); - protected override DirectSortCriteria DefaultTab => DirectSortCriteria.Ranked; - - protected override Drawable CreateSupplementaryControls() => rulesetSelector = new DirectRulesetSelector(); - - public Bindable<RulesetInfo> Ruleset => rulesetSelector.Current; - - [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, Bindable<RulesetInfo> ruleset) - { - DisplayStyleControl.Dropdown.AccentColour = colours.BlueDark; - rulesetSelector.Current.BindTo(ruleset); - } - } - - public enum DirectSortCriteria - { - Relevance, - Title, - Artist, - Creator, - Difficulty, - Ranked, - Rating, - Plays, - Favourites, - } -} diff --git a/osu.Game/Overlays/Direct/Header.cs b/osu.Game/Overlays/Direct/Header.cs deleted file mode 100644 index 80870dcb68..0000000000 --- a/osu.Game/Overlays/Direct/Header.cs +++ /dev/null @@ -1,42 +0,0 @@ -// 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. - -using System.ComponentModel; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Overlays.SearchableList; - -namespace osu.Game.Overlays.Direct -{ - public class Header : SearchableListHeader<DirectTab> - { - protected override Color4 BackgroundColour => OsuColour.FromHex(@"252f3a"); - - protected override DirectTab DefaultTab => DirectTab.Search; - protected override Drawable CreateHeaderText() => new OsuSpriteText { Text = @"osu!direct", Font = OsuFont.GetFont(size: 25) }; - protected override IconUsage Icon => OsuIcon.ChevronDownCircle; - - public Header() - { - Tabs.Current.Value = DirectTab.NewestMaps; - Tabs.Current.TriggerChange(); - } - } - - public enum DirectTab - { - Search, - - [Description("Newest Maps")] - NewestMaps = DirectSortCriteria.Ranked, - - [Description("Top Rated")] - TopRated = DirectSortCriteria.Rating, - - [Description("Most Played")] - MostPlayed = DirectSortCriteria.Plays, - } -} diff --git a/osu.Game/Overlays/Direct/PanelDownloadButton.cs b/osu.Game/Overlays/Direct/PanelDownloadButton.cs deleted file mode 100644 index 4037cd46f3..0000000000 --- a/osu.Game/Overlays/Direct/PanelDownloadButton.cs +++ /dev/null @@ -1,75 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online; - -namespace osu.Game.Overlays.Direct -{ - public class PanelDownloadButton : BeatmapDownloadTrackingComposite - { - protected bool DownloadEnabled => button.Enabled.Value; - - private readonly bool noVideo; - - private readonly ShakeContainer shakeContainer; - private readonly DownloadButton button; - - public PanelDownloadButton(BeatmapSetInfo beatmapSet, bool noVideo = false) - : base(beatmapSet) - { - this.noVideo = noVideo; - - InternalChild = shakeContainer = new ShakeContainer - { - RelativeSizeAxes = Axes.Both, - Child = button = new DownloadButton - { - RelativeSizeAxes = Axes.Both, - }, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - button.State.BindTo(State); - FinishTransforms(true); - } - - [BackgroundDependencyLoader(true)] - private void load(OsuGame game, BeatmapManager beatmaps) - { - if (BeatmapSet.Value.OnlineInfo.Availability?.DownloadDisabled ?? false) - { - button.Enabled.Value = false; - button.TooltipText = "This beatmap is currently not available for download."; - return; - } - - button.Action = () => - { - switch (State.Value) - { - case DownloadState.Downloading: - case DownloadState.Downloaded: - shakeContainer.Shake(); - break; - - case DownloadState.LocallyAvailable: - game.PresentBeatmap(BeatmapSet.Value); - break; - - default: - beatmaps.Download(BeatmapSet.Value, noVideo); - break; - } - }; - } - } -} diff --git a/osu.Game/Overlays/DirectOverlay.cs b/osu.Game/Overlays/DirectOverlay.cs deleted file mode 100644 index 7dcf76e41f..0000000000 --- a/osu.Game/Overlays/DirectOverlay.cs +++ /dev/null @@ -1,303 +0,0 @@ -// 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. - -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Humanizer; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Threading; -using osu.Game.Audio; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.Direct; -using osu.Game.Overlays.SearchableList; -using osu.Game.Rulesets; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays -{ - public class DirectOverlay : SearchableListOverlay<DirectTab, DirectSortCriteria, BeatmapSearchCategory> - { - private const float panel_padding = 10f; - - private RulesetStore rulesets; - - private readonly FillFlowContainer resultCountsContainer; - private readonly OsuSpriteText resultCountsText; - private FillFlowContainer<DirectPanel> panels; - - protected override Color4 BackgroundColour => OsuColour.FromHex(@"485e74"); - protected override Color4 TrianglesColourLight => OsuColour.FromHex(@"465b71"); - protected override Color4 TrianglesColourDark => OsuColour.FromHex(@"3f5265"); - - protected override SearchableListHeader<DirectTab> CreateHeader() => new Header(); - protected override SearchableListFilterControl<DirectSortCriteria, BeatmapSearchCategory> CreateFilterControl() => new FilterControl(); - - private IEnumerable<BeatmapSetInfo> beatmapSets; - - public IEnumerable<BeatmapSetInfo> BeatmapSets - { - get => beatmapSets; - set - { - if (beatmapSets?.Equals(value) ?? false) return; - - beatmapSets = value?.ToList(); - - if (beatmapSets == null) return; - - var artists = new List<string>(); - var songs = new List<string>(); - var tags = new List<string>(); - - foreach (var s in beatmapSets) - { - artists.Add(s.Metadata.Artist); - songs.Add(s.Metadata.Title); - tags.AddRange(s.Metadata.Tags.Split(' ')); - } - - ResultAmounts = new ResultCounts(distinctCount(artists), distinctCount(songs), distinctCount(tags)); - } - } - - private ResultCounts resultAmounts; - - public ResultCounts ResultAmounts - { - get => resultAmounts; - set - { - if (value == ResultAmounts) return; - - resultAmounts = value; - - updateResultCounts(); - } - } - - public DirectOverlay() - { - // osu!direct colours are not part of the standard palette - - Waves.FirstWaveColour = OsuColour.FromHex(@"19b0e2"); - Waves.SecondWaveColour = OsuColour.FromHex(@"2280a2"); - Waves.ThirdWaveColour = OsuColour.FromHex(@"005774"); - Waves.FourthWaveColour = OsuColour.FromHex(@"003a4e"); - - ScrollFlow.Children = new Drawable[] - { - resultCountsContainer = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Top = 5 }, - Children = new Drawable[] - { - new OsuSpriteText - { - Text = "Found ", - Font = OsuFont.GetFont(size: 15) - }, - resultCountsText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold) - }, - } - }, - }; - - Filter.Search.Current.ValueChanged += text => - { - if (text.NewValue != string.Empty) - { - Header.Tabs.Current.Value = DirectTab.Search; - - if (Filter.Tabs.Current.Value == DirectSortCriteria.Ranked) - Filter.Tabs.Current.Value = DirectSortCriteria.Relevance; - } - else - { - Header.Tabs.Current.Value = DirectTab.NewestMaps; - - if (Filter.Tabs.Current.Value == DirectSortCriteria.Relevance) - Filter.Tabs.Current.Value = DirectSortCriteria.Ranked; - } - }; - ((FilterControl)Filter).Ruleset.ValueChanged += _ => queueUpdateSearch(); - Filter.DisplayStyleControl.DisplayStyle.ValueChanged += style => recreatePanels(style.NewValue); - Filter.DisplayStyleControl.Dropdown.Current.ValueChanged += _ => queueUpdateSearch(); - - Header.Tabs.Current.ValueChanged += tab => - { - if (tab.NewValue != DirectTab.Search) - { - currentQuery.Value = string.Empty; - Filter.Tabs.Current.Value = (DirectSortCriteria)Header.Tabs.Current.Value; - queueUpdateSearch(); - } - }; - - currentQuery.ValueChanged += text => queueUpdateSearch(!string.IsNullOrEmpty(text.NewValue)); - - currentQuery.BindTo(Filter.Search.Current); - - Filter.Tabs.Current.ValueChanged += tab => - { - if (Header.Tabs.Current.Value != DirectTab.Search && tab.NewValue != (DirectSortCriteria)Header.Tabs.Current.Value) - Header.Tabs.Current.Value = DirectTab.Search; - - queueUpdateSearch(); - }; - - updateResultCounts(); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours, RulesetStore rulesets, PreviewTrackManager previewTrackManager) - { - this.rulesets = rulesets; - this.previewTrackManager = previewTrackManager; - - resultCountsContainer.Colour = colours.Yellow; - } - - private void updateResultCounts() - { - resultCountsContainer.FadeTo(ResultAmounts == null ? 0f : 1f, 200, Easing.OutQuint); - if (ResultAmounts == null) return; - - resultCountsText.Text = "Artist".ToQuantity(ResultAmounts.Artists) + ", " + - "Song".ToQuantity(ResultAmounts.Songs) + ", " + - "Tag".ToQuantity(ResultAmounts.Tags); - } - - private void recreatePanels(PanelDisplayStyle displayStyle) - { - if (panels != null) - { - panels.FadeOut(200); - panels.Expire(); - panels = null; - } - - if (BeatmapSets == null) return; - - var newPanels = new FillFlowContainer<DirectPanel> - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(panel_padding), - Margin = new MarginPadding { Top = 10 }, - ChildrenEnumerable = BeatmapSets.Select<BeatmapSetInfo, DirectPanel>(b => - { - switch (displayStyle) - { - case PanelDisplayStyle.Grid: - return new DirectGridPanel(b) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }; - - default: - return new DirectListPanel(b); - } - }) - }; - - LoadComponentAsync(newPanels, p => - { - if (panels != null) ScrollFlow.Remove(panels); - ScrollFlow.Add(panels = newPanels); - }); - } - - protected override void PopIn() - { - base.PopIn(); - - // Queries are allowed to be run only on the first pop-in - if (getSetsRequest == null) - queueUpdateSearch(); - } - - private SearchBeatmapSetsRequest getSetsRequest; - - private readonly Bindable<string> currentQuery = new Bindable<string>(string.Empty); - - private ScheduledDelegate queryChangedDebounce; - private PreviewTrackManager previewTrackManager; - - private void queueUpdateSearch(bool queryTextChanged = false) - { - BeatmapSets = null; - ResultAmounts = null; - - getSetsRequest?.Cancel(); - - queryChangedDebounce?.Cancel(); - queryChangedDebounce = Scheduler.AddDelayed(updateSearch, queryTextChanged ? 500 : 100); - } - - private void updateSearch() - { - if (!IsLoaded) - return; - - if (State.Value == Visibility.Hidden) - return; - - if (API == null) - return; - - previewTrackManager.StopAnyPlaying(this); - - getSetsRequest = new SearchBeatmapSetsRequest( - currentQuery.Value, - ((FilterControl)Filter).Ruleset.Value, - Filter.DisplayStyleControl.Dropdown.Current.Value, - Filter.Tabs.Current.Value); //todo: sort direction (?) - - getSetsRequest.Success += response => - { - Task.Run(() => - { - var sets = response.BeatmapSets.Select(r => r.ToBeatmapSet(rulesets)).ToList(); - - // may not need scheduling; loads async internally. - Schedule(() => - { - BeatmapSets = sets; - recreatePanels(Filter.DisplayStyleControl.DisplayStyle.Value); - }); - }); - }; - - API.Queue(getSetsRequest); - } - - private int distinctCount(List<string> list) => list.Distinct().ToArray().Length; - - public class ResultCounts - { - public readonly int Artists; - public readonly int Songs; - public readonly int Tags; - - public ResultCounts(int artists, int songs, int tags) - { - Artists = artists; - Songs = songs; - Tags = tags; - } - } - } -} diff --git a/osu.Game/Overlays/FullscreenOverlay.cs b/osu.Game/Overlays/FullscreenOverlay.cs index 0911ee84de..6f56d95929 100644 --- a/osu.Game/Overlays/FullscreenOverlay.cs +++ b/osu.Game/Overlays/FullscreenOverlay.cs @@ -6,24 +6,32 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osuTK.Graphics; namespace osu.Game.Overlays { - public abstract class FullscreenOverlay : WaveOverlayContainer, IOnlineComponent + public abstract class FullscreenOverlay<T> : WaveOverlayContainer, INamedOverlayComponent + 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 T Header { get; } + [Resolved] protected IAPIProvider API { get; private set; } - protected FullscreenOverlay() + [Cached] + protected readonly OverlayColourProvider ColourProvider; + + protected FullscreenOverlay(OverlayColourScheme colourScheme, T header) { - Waves.FirstWaveColour = OsuColour.Gray(0.4f); - Waves.SecondWaveColour = OsuColour.Gray(0.3f); - Waves.ThirdWaveColour = OsuColour.Gray(0.2f); - Waves.FourthWaveColour = OsuColour.Gray(0.1f); + Header = header; + + ColourProvider = new OverlayColourProvider(colourScheme); RelativeSizeAxes = Axes.Both; RelativePositionAxes = Axes.Both; @@ -41,6 +49,15 @@ namespace osu.Game.Overlays }; } + [BackgroundDependencyLoader] + private void load() + { + Waves.FirstWaveColour = ColourProvider.Light4; + Waves.SecondWaveColour = ColourProvider.Light3; + Waves.ThirdWaveColour = ColourProvider.Dark4; + Waves.FourthWaveColour = ColourProvider.Dark3; + } + public override void Show() { if (State.Value == Visibility.Visible) @@ -69,21 +86,5 @@ namespace osu.Game.Overlays protected virtual void PopOutComplete() { } - - protected override void LoadComplete() - { - base.LoadComplete(); - API.Register(this); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - API?.Unregister(this); - } - - public virtual void APIStateChanged(IAPIProvider api, APIState state) - { - } } } diff --git a/osu.Game/Overlays/HoldToConfirmOverlay.cs b/osu.Game/Overlays/HoldToConfirmOverlay.cs index fdc6f096bc..eb325d8dd3 100644 --- a/osu.Game/Overlays/HoldToConfirmOverlay.cs +++ b/osu.Game/Overlays/HoldToConfirmOverlay.cs @@ -51,7 +51,7 @@ namespace osu.Game.Overlays protected override void Dispose(bool isDisposing) { - audio.Tracks.RemoveAdjustment(AdjustableProperty.Volume, audioVolume); + audio?.Tracks.RemoveAdjustment(AdjustableProperty.Volume, audioVolume); base.Dispose(isDisposing); } } diff --git a/osu.Game/Overlays/INamedOverlayComponent.cs b/osu.Game/Overlays/INamedOverlayComponent.cs new file mode 100644 index 0000000000..38fb8679a0 --- /dev/null +++ b/osu.Game/Overlays/INamedOverlayComponent.cs @@ -0,0 +1,14 @@ +// 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. + +namespace osu.Game.Overlays +{ + public interface INamedOverlayComponent + { + string IconTexture { get; } + + string Title { get; } + + string Description { get; } + } +} diff --git a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs index 7e33d7ba27..9a27c55c53 100644 --- a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs @@ -1,6 +1,7 @@ // 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. +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Input.Bindings; using osu.Game.Overlays.Settings; @@ -9,13 +10,19 @@ namespace osu.Game.Overlays.KeyBinding { public class GlobalKeyBindingsSection : SettingsSection { - public override IconUsage Icon => FontAwesome.Solid.Globe; + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Globe + }; + public override string Header => "Global"; public GlobalKeyBindingsSection(GlobalActionContainer manager) { Add(new DefaultBindingsSubsection(manager)); + Add(new AudioControlKeyBindingsSubsection(manager)); Add(new InGameKeyBindingsSubsection(manager)); + Add(new EditorKeyBindingsSubsection(manager)); } private class DefaultBindingsSubsection : KeyBindingsSubsection @@ -39,5 +46,27 @@ namespace osu.Game.Overlays.KeyBinding Defaults = manager.InGameKeyBindings; } } + + private class AudioControlKeyBindingsSubsection : KeyBindingsSubsection + { + protected override string Header => "Audio"; + + public AudioControlKeyBindingsSubsection(GlobalActionContainer manager) + : base(null) + { + Defaults = manager.AudioControlKeyBindings; + } + } + + private class EditorKeyBindingsSubsection : KeyBindingsSubsection + { + protected override string Header => "Editor"; + + public EditorKeyBindingsSubsection(GlobalActionContainer manager) + : base(null) + { + Defaults = manager.EditorKeyBindings; + } + } } } diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs index 8317951c8a..b808d49fa2 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs @@ -48,8 +48,7 @@ namespace osu.Game.Overlays.KeyBinding public bool FilteringActive { get; set; } private OsuSpriteText text; - private Drawable pressAKey; - + private FillFlowContainer cancelAndClearButtons; private FillFlowContainer<KeyButton> buttons; public IEnumerable<string> FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend((string)text.Text); @@ -66,13 +65,12 @@ namespace osu.Game.Overlays.KeyBinding CornerRadius = padding; } - private KeyBindingStore store; + [Resolved] + private KeyBindingStore store { get; set; } [BackgroundDependencyLoader] - private void load(OsuColour colours, KeyBindingStore store) + private void load(OsuColour colours) { - this.store = store; - EdgeEffect = new EdgeEffectParameters { Radius = 2, @@ -81,7 +79,7 @@ namespace osu.Game.Overlays.KeyBinding Hollow = true, }; - Children = new[] + Children = new Drawable[] { new Box { @@ -100,7 +98,7 @@ namespace osu.Game.Overlays.KeyBinding Anchor = Anchor.TopRight, Origin = Anchor.TopRight }, - pressAKey = new FillFlowContainer + cancelAndClearButtons = new FillFlowContainer { AutoSizeAxes = Axes.Both, Padding = new MarginPadding(padding) { Top = height + padding * 2 }, @@ -177,17 +175,20 @@ namespace osu.Game.Overlays.KeyBinding return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { // don't do anything until the last button is released. if (!HasFocus || e.HasAnyButtonPressed) - return base.OnMouseUp(e); + { + base.OnMouseUp(e); + return; + } if (bindTarget.IsHovered) finalise(); - else + // prevent updating bind target before clear button's action + else if (!cancelAndClearButtons.Any(b => b.IsHovered)) updateBindTarget(); - return true; } protected override bool OnScroll(ScrollEvent e) @@ -216,12 +217,15 @@ namespace osu.Game.Overlays.KeyBinding return true; } - protected override bool OnKeyUp(KeyUpEvent e) + protected override void OnKeyUp(KeyUpEvent e) { - if (!HasFocus) return base.OnKeyUp(e); + if (!HasFocus) + { + base.OnKeyUp(e); + return; + } finalise(); - return true; } protected override bool OnJoystickPress(JoystickPressEvent e) @@ -235,17 +239,44 @@ namespace osu.Game.Overlays.KeyBinding return true; } - protected override bool OnJoystickRelease(JoystickReleaseEvent e) + protected override void OnJoystickRelease(JoystickReleaseEvent e) { if (!HasFocus) - return base.OnJoystickRelease(e); + { + base.OnJoystickRelease(e); + return; + } finalise(); + } + + protected override bool OnMidiDown(MidiDownEvent e) + { + if (!HasFocus) + return false; + + bindTarget.UpdateKeyCombination(KeyCombination.FromInputState(e.CurrentState)); + finalise(); + return true; } + protected override void OnMidiUp(MidiUpEvent e) + { + if (!HasFocus) + { + base.OnMidiUp(e); + return; + } + + finalise(); + } + private void clear() { + if (bindTarget == null) + return; + bindTarget.UpdateKeyCombination(InputKey.None); finalise(); } @@ -267,8 +298,8 @@ namespace osu.Game.Overlays.KeyBinding if (HasFocus) GetContainingInputManager().ChangeFocus(null); - pressAKey.FadeOut(300, Easing.OutQuint); - pressAKey.BypassAutoSizeAxes |= Axes.Y; + cancelAndClearButtons.FadeOut(300, Easing.OutQuint); + cancelAndClearButtons.BypassAutoSizeAxes |= Axes.Y; } protected override void OnFocus(FocusEvent e) @@ -276,8 +307,8 @@ namespace osu.Game.Overlays.KeyBinding AutoSizeDuration = 500; AutoSizeEasing = Easing.OutQuint; - pressAKey.FadeIn(300, Easing.OutQuint); - pressAKey.BypassAutoSizeAxes &= ~Axes.Y; + cancelAndClearButtons.FadeIn(300, Easing.OutQuint); + cancelAndClearButtons.BypassAutoSizeAxes &= ~Axes.Y; updateBindTarget(); base.OnFocus(e); @@ -289,6 +320,9 @@ namespace osu.Game.Overlays.KeyBinding base.OnFocusLost(e); } + /// <summary> + /// Updates the bind target to the currently hovered key button or the first if clicked anywhere else. + /// </summary> private void updateBindTarget() { if (bindTarget != null) bindTarget.IsBinding = false; @@ -305,7 +339,7 @@ namespace osu.Game.Overlays.KeyBinding } } - private class ClearButton : TriangleButton + public class ClearButton : TriangleButton { public ClearButton() { @@ -313,14 +347,6 @@ namespace osu.Game.Overlays.KeyBinding Size = new Vector2(80, 20); } - protected override bool OnMouseUp(MouseUpEvent e) - { - base.OnMouseUp(e); - - // without this, the mouse up triggers a finalise (and deselection) of the current binding target. - return true; - } - [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -331,7 +357,7 @@ namespace osu.Game.Overlays.KeyBinding } } - private class KeyButton : Container + public class KeyButton : Container { public readonly Framework.Input.Bindings.KeyBinding KeyBinding; diff --git a/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs b/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs index 1f4042c57c..332fb6c8fc 100644 --- a/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs +++ b/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs @@ -1,6 +1,7 @@ // 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. +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Overlays.Settings; @@ -10,7 +11,11 @@ namespace osu.Game.Overlays.KeyBinding { public class RulesetBindingsSection : SettingsSection { - public override IconUsage Icon => (ruleset.CreateInstance().CreateIcon() as SpriteIcon)?.Icon ?? OsuIcon.Hot; + public override Drawable CreateIcon() => ruleset?.CreateInstance()?.CreateIcon() ?? new SpriteIcon + { + Icon = OsuIcon.Hot + }; + public override string Header => ruleset.Name; private readonly RulesetInfo ruleset; diff --git a/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs b/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs index 07af657686..861d59c8f4 100644 --- a/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs +++ b/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs @@ -7,8 +7,7 @@ namespace osu.Game.Overlays.KeyBinding { public class VariantBindingsSubsection : KeyBindingsSubsection { - protected override string Header => variantName; - private readonly string variantName; + protected override string Header { get; } public VariantBindingsSubsection(RulesetInfo ruleset, int variant) : base(variant) @@ -17,7 +16,7 @@ namespace osu.Game.Overlays.KeyBinding var rulesetInstance = ruleset.CreateInstance(); - variantName = rulesetInstance.GetVariantName(variant); + Header = rulesetInstance.GetVariantName(variant); Defaults = rulesetInstance.GetDefaultKeyBindings(variant); } } diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index 1f15c773f4..4425c2f168 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -21,7 +21,7 @@ using osu.Framework.Graphics.Shapes; using System; using osu.Framework.Graphics.Effects; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; namespace osu.Game.Overlays { @@ -41,105 +41,114 @@ namespace osu.Game.Overlays private SampleChannel getSample; + private readonly Container content; + public MedalOverlay(Medal medal) { this.medal = medal; RelativeSizeAxes = Axes.Both; - Children = new Drawable[] + Child = content = new Container { - background = new Box + Alpha = 0, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(60), - }, - outerSpin = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(DISC_SIZE + 500), - Alpha = 0f, - }, - backgroundStrip = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = border_width, - Alpha = 0f, - Children = new[] + background = new Box { - new Container + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(60), + }, + outerSpin = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(DISC_SIZE + 500), + Alpha = 0f, + }, + backgroundStrip = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = border_width, + Alpha = 0f, + Children = new[] { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.CentreRight, - Width = 0.5f, - Padding = new MarginPadding { Right = DISC_SIZE / 2 }, - Children = new[] + new Container { - leftStrip = new BackgroundStrip(0f, 1f) + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.CentreRight, + Width = 0.5f, + Padding = new MarginPadding { Right = DISC_SIZE / 2 }, + Children = new[] { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, + leftStrip = new BackgroundStrip(0f, 1f) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + Width = 0.5f, + Padding = new MarginPadding { Left = DISC_SIZE / 2 }, + Children = new[] + { + rightStrip = new BackgroundStrip(1f, 0f), }, }, }, - new Container + }, + particleContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0f, + }, + disc = new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0f, + Masking = true, + AlwaysPresent = true, + BorderColour = Color4.White, + BorderThickness = border_width, + Size = new Vector2(DISC_SIZE), + Scale = new Vector2(0.8f), + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.CentreLeft, - Width = 0.5f, - Padding = new MarginPadding { Left = DISC_SIZE / 2 }, - Children = new[] + new Box { - rightStrip = new BackgroundStrip(1f, 0f), + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"05262f"), + }, + new Triangles + { + RelativeSizeAxes = Axes.Both, + TriangleScale = 2, + ColourDark = Color4Extensions.FromHex(@"04222b"), + ColourLight = Color4Extensions.FromHex(@"052933"), + }, + innerSpin = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1.05f), + Alpha = 0.25f, }, }, }, - }, - particleContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Alpha = 0f, - }, - disc = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Alpha = 0f, - Masking = true, - AlwaysPresent = true, - BorderColour = Color4.White, - BorderThickness = border_width, - Size = new Vector2(DISC_SIZE), - Scale = new Vector2(0.8f), - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"05262f"), - }, - new Triangles - { - RelativeSizeAxes = Axes.Both, - TriangleScale = 2, - ColourDark = OsuColour.FromHex(@"04222b"), - ColourLight = OsuColour.FromHex(@"052933"), - }, - innerSpin = new Sprite - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(1.05f), - Alpha = 0.25f, - }, - }, - }, + } }; + + Show(); } [BackgroundDependencyLoader] @@ -154,19 +163,22 @@ namespace osu.Game.Overlays Colour = colours.Blue.Opacity(0.5f), Radius = 50, }; - - disc.Add(drawableMedal = new DrawableMedal(medal) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.Both, - }); } protected override void LoadComplete() { base.LoadComplete(); - Show(); + + LoadComponentAsync(drawableMedal = new DrawableMedal(medal) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + }, loaded => + { + disc.Add(loaded); + startAnimation(); + }); } protected override void Update() @@ -190,11 +202,10 @@ namespace osu.Game.Overlays private const double initial_duration = 400; private const double step_duration = 900; - protected override void PopIn() + private void startAnimation() { - base.PopIn(); + content.Show(); - this.FadeIn(200); background.FlashColour(Color4.White.Opacity(0.25f), 400); getSample.Play(); diff --git a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs index f1ae5d64f5..3cf7befb45 100644 --- a/osu.Game/Overlays/MedalSplash/DrawableMedal.cs +++ b/osu.Game/Overlays/MedalSplash/DrawableMedal.cs @@ -16,6 +16,7 @@ using osu.Game.Users; namespace osu.Game.Overlays.MedalSplash { + [LongRunningLoad] public class DrawableMedal : Container, IStateful<DisplayState> { private const float scale_when_unlocked = 0.76f; @@ -50,7 +51,7 @@ namespace osu.Game.Overlays.MedalSplash { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Scale = new Vector2(0.81f), + Scale = new Vector2(0.41f), }, medalGlow = new Sprite { diff --git a/osu.Game/Overlays/Mods/ModButton.cs b/osu.Game/Overlays/Mods/ModButton.cs index fa1ee500a8..e574828cd2 100644 --- a/osu.Game/Overlays/Mods/ModButton.cs +++ b/osu.Game/Overlays/Mods/ModButton.cs @@ -80,7 +80,7 @@ namespace osu.Game.Overlays.Mods foregroundIcon.RotateTo(rotate_angle * direction, mod_switch_duration, mod_switch_easing); backgroundIcon.RotateTo(-rotate_angle * direction, mod_switch_duration, mod_switch_easing); - backgroundIcon.Icon = modAfter.Icon; + backgroundIcon.Mod = modAfter; using (BeginDelayedSequence(mod_switch_duration, true)) { @@ -96,7 +96,7 @@ namespace osu.Game.Overlays.Mods } } - foregroundIcon.Highlighted = Selected; + foregroundIcon.Selected.Value = Selected; SelectionChanged?.Invoke(SelectedMod); return true; @@ -158,7 +158,7 @@ namespace osu.Game.Overlays.Mods return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { scaleContainer.ScaleTo(1, 500, Easing.OutElastic); @@ -167,15 +167,16 @@ namespace osu.Game.Overlays.Mods { switch (e.Button) { - case MouseButton.Left: - SelectNext(1); - break; - case MouseButton.Right: SelectNext(-1); break; } } + } + + protected override bool OnClick(ClickEvent e) + { + SelectNext(1); return true; } @@ -194,8 +195,10 @@ namespace osu.Game.Overlays.Mods start = Mods.Length - 1; for (int i = start; i < Mods.Length && i >= 0; i += direction) + { if (SelectAt(i)) return; + } Deselect(); } @@ -213,8 +216,8 @@ namespace osu.Game.Overlays.Mods private void displayMod(Mod mod) { if (backgroundIcon != null) - backgroundIcon.Icon = foregroundIcon.Icon; - foregroundIcon.Icon = mod.Icon; + backgroundIcon.Mod = foregroundIcon.Mod; + foregroundIcon.Mod = mod; text.Text = mod.Name; Colour = mod.HasImplementation ? Color4.White : Color4.Gray; } @@ -283,7 +286,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.TopCentre, Font = OsuFont.GetFont(size: 18) }, - new HoverClickSounds() + new HoverClickSounds(buttons: new[] { MouseButton.Left, MouseButton.Right }) }; Mod = mod; diff --git a/osu.Game/Overlays/Mods/ModControlSection.cs b/osu.Game/Overlays/Mods/ModControlSection.cs new file mode 100644 index 0000000000..10b3bc7c2b --- /dev/null +++ b/osu.Game/Overlays/Mods/ModControlSection.cs @@ -0,0 +1,54 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public class ModControlSection : CompositeDrawable + { + protected FillFlowContainer FlowContent; + + public readonly Mod Mod; + + public ModControlSection(Mod mod, IEnumerable<Drawable> modControls) + { + Mod = mod; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + FlowContent = new FillFlowContainer + { + Margin = new MarginPadding { Top = 30 }, + Spacing = new Vector2(0, 5), + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + ChildrenEnumerable = modControls + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AddRangeInternal(new Drawable[] + { + new OsuSpriteText + { + Text = Mod.Name, + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Colour = colours.Yellow, + }, + FlowContent + }); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs index dedd397fa5..0107f94dcf 100644 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ b/osu.Game/Overlays/Mods/ModSection.cs @@ -57,6 +57,15 @@ namespace osu.Game.Overlays.Mods }).ToArray(); modsLoadCts?.Cancel(); + + if (modContainers.Length == 0) + { + ModIconsLoaded = true; + headerLabel.Hide(); + Hide(); + return; + } + ModIconsLoaded = false; LoadComponentsAsync(modContainers, c => @@ -67,24 +76,17 @@ namespace osu.Game.Overlays.Mods buttons = modContainers.OfType<ModButton>().ToArray(); - if (value.Any()) - { - headerLabel.FadeIn(200); - this.FadeIn(200); - } - else - { - // transition here looks weird as mods instantly disappear. - headerLabel.Hide(); - Hide(); - } + headerLabel.FadeIn(200); + this.FadeIn(200); } } - private ModButton[] buttons = { }; + private ModButton[] buttons = Array.Empty<ModButton>(); protected override bool OnKeyDown(KeyDownEvent e) { + if (e.ControlPressed) return false; + if (ToggleKeys != null) { var index = Array.IndexOf(ToggleKeys, e.Key); @@ -112,6 +114,7 @@ namespace osu.Game.Overlays.Mods if (selected == null) continue; foreach (var type in modTypes) + { if (type.IsInstanceOfType(selected)) { if (immediate) @@ -119,6 +122,7 @@ namespace osu.Game.Overlays.Mods else Scheduler.AddDelayed(button.Deselect, delay += 50); } + } } } @@ -130,7 +134,7 @@ namespace osu.Game.Overlays.Mods { foreach (var button in buttons) { - int i = Array.FindIndex(button.Mods, m => modTypes.Any(t => t.IsInstanceOfType(m))); + int i = Array.FindIndex(button.Mods, m => modTypes.Any(t => t == m.GetType())); if (i >= 0) button.SelectAt(i); @@ -158,13 +162,14 @@ namespace osu.Game.Overlays.Mods }, ButtonsContainer = new FillFlowContainer<ModButtonEmpty> { - AutoSizeAxes = Axes.Both, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, Spacing = new Vector2(50f, 0f), Margin = new MarginPadding { - Top = 6, + Top = 20, }, AlwaysPresent = true }, diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 9ff320841a..31adf47456 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -18,8 +18,8 @@ using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Overlays.Mods.Sections; -using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens; using osuTK; @@ -30,11 +30,13 @@ namespace osu.Game.Overlays.Mods { public class ModSelectOverlay : WaveOverlayContainer { + public const float HEIGHT = 510; + protected readonly TriangleButton DeselectAllButton; + protected readonly TriangleButton CustomiseButton; protected readonly TriangleButton CloseButton; protected readonly OsuSpriteText MultiplierLabel; - protected readonly OsuSpriteText UnrankedLabel; protected override bool BlockNonPositionalInput => false; @@ -42,26 +44,31 @@ namespace osu.Game.Overlays.Mods protected readonly FillFlowContainer<ModSection> ModSectionsContainer; - protected readonly Bindable<IReadOnlyList<Mod>> SelectedMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); + protected readonly ModSettingsContainer ModSettingsContainer; - protected readonly IBindable<RulesetInfo> Ruleset = new Bindable<RulesetInfo>(); + public readonly Bindable<IReadOnlyList<Mod>> SelectedMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); + + private Bindable<Dictionary<ModType, IReadOnlyList<Mod>>> availableMods; protected Color4 LowMultiplierColour; protected Color4 HighMultiplierColour; private const float content_width = 0.8f; + private const float footer_button_spacing = 20; + private readonly FillFlowContainer footerContainer; private SampleChannel sampleOn, sampleOff; public ModSelectOverlay() { - Waves.FirstWaveColour = OsuColour.FromHex(@"19b0e2"); - Waves.SecondWaveColour = OsuColour.FromHex(@"2280a2"); - Waves.ThirdWaveColour = OsuColour.FromHex(@"005774"); - Waves.FourthWaveColour = OsuColour.FromHex(@"003a4e"); + Waves.FirstWaveColour = Color4Extensions.FromHex(@"19b0e2"); + Waves.SecondWaveColour = Color4Extensions.FromHex(@"2280a2"); + Waves.ThirdWaveColour = Color4Extensions.FromHex(@"005774"); + Waves.FourthWaveColour = Color4Extensions.FromHex(@"003a4e"); + + RelativeSizeAxes = Axes.Both; - Height = 510; Padding = new MarginPadding { Horizontal = -OsuScreen.HORIZONTAL_OVERFLOW_PADDING }; Children = new Drawable[] @@ -80,8 +87,7 @@ namespace osu.Game.Overlays.Mods new Triangles { TriangleScale = 5, - RelativeSizeAxes = Axes.X, - Height = Height, //set the height from the start to ensure correct triangle density. + RelativeSizeAxes = Axes.Both, ColourLight = new Color4(53, 66, 82, 255), ColourDark = new Color4(41, 54, 70, 255), }, @@ -96,7 +102,7 @@ namespace osu.Game.Overlays.Mods { new Dimension(GridSizeMode.Absolute, 90), new Dimension(GridSizeMode.Distributed), - new Dimension(GridSizeMode.Absolute, 70), + new Dimension(GridSizeMode.AutoSize), }, Content = new[] { @@ -190,7 +196,8 @@ namespace osu.Game.Overlays.Mods // Footer new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, Children = new Drawable[] @@ -208,7 +215,9 @@ namespace osu.Game.Overlays.Mods AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, Width = content_width, - Direction = FillDirection.Horizontal, + Spacing = new Vector2(footer_button_spacing, footer_button_spacing / 2), + LayoutDuration = 100, + LayoutEasing = Easing.OutQuint, Padding = new MarginPadding { Vertical = 15, @@ -221,49 +230,50 @@ namespace osu.Game.Overlays.Mods Width = 180, Text = "Deselect All", Action = DeselectAll, - Margin = new MarginPadding - { - Right = 20 - } + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + }, + CustomiseButton = new TriangleButton + { + Width = 180, + Text = "Customisation", + Action = () => ModSettingsContainer.Alpha = ModSettingsContainer.Alpha == 1 ? 0 : 1, + Enabled = { Value = false }, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, }, CloseButton = new TriangleButton { Width = 180, Text = "Close", Action = Hide, - Margin = new MarginPadding - { - Right = 20 - } + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, }, - new OsuSpriteText + new FillFlowContainer { - Text = @"Score Multiplier:", - Font = OsuFont.GetFont(size: 30), - Margin = new MarginPadding + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(footer_button_spacing / 2, 0), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Children = new Drawable[] { - Top = 5, - Right = 10 - } + new OsuSpriteText + { + Text = @"Score Multiplier:", + Font = OsuFont.GetFont(size: 30), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + }, + MultiplierLabel = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 30, weight: FontWeight.Bold), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Width = 70, // make width fixed so reflow doesn't occur when multiplier number changes. + }, + }, }, - MultiplierLabel = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Bold), - Margin = new MarginPadding - { - Top = 5 - } - }, - UnrankedLabel = new OsuSpriteText - { - Text = @"(Unranked)", - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Bold), - Margin = new MarginPadding - { - Top = 5, - Left = 10 - } - } } } }, @@ -271,18 +281,28 @@ namespace osu.Game.Overlays.Mods }, }, }, + ModSettingsContainer = new ModSettingsContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Width = 0.25f, + Alpha = 0, + X = -100, + SelectedMods = { BindTarget = SelectedMods }, + } }; + + ((IBindable<bool>)CustomiseButton.Enabled).BindTo(ModSettingsContainer.HasSettingsForSelection); } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, IBindable<RulesetInfo> ruleset, AudioManager audio, Bindable<IReadOnlyList<Mod>> mods) + private void load(OsuColour colours, AudioManager audio, OsuGameBase osu) { LowMultiplierColour = colours.Red; HighMultiplierColour = colours.Green; - UnrankedLabel.Colour = colours.Blue; - Ruleset.BindTo(ruleset); - if (mods != null) SelectedMods.BindTo(mods); + availableMods = osu.AvailableMods.GetBoundCopy(); sampleOn = audio.Samples.Get(@"UI/check-on"); sampleOff = audio.Samples.Get(@"UI/check-off"); @@ -313,7 +333,7 @@ namespace osu.Game.Overlays.Mods { base.LoadComplete(); - Ruleset.BindValueChanged(rulesetChanged, true); + availableMods.BindValueChanged(availableModsChanged, true); SelectedMods.BindValueChanged(selectedModsChanged, true); } @@ -349,6 +369,9 @@ namespace osu.Game.Overlays.Mods protected override bool OnKeyDown(KeyDownEvent e) { + // don't absorb control as ToolbarRulesetSelector uses control + number to navigate + if (e.ControlPressed) return false; + switch (e.Key) { case Key.Number1: @@ -363,28 +386,20 @@ namespace osu.Game.Overlays.Mods return base.OnKeyDown(e); } - private void rulesetChanged(ValueChangedEvent<RulesetInfo> e) - { - if (e.NewValue == null) return; + public override bool OnPressed(GlobalAction action) => false; // handled by back button - var instance = e.NewValue.CreateInstance(); + private void availableModsChanged(ValueChangedEvent<Dictionary<ModType, IReadOnlyList<Mod>>> mods) + { + if (mods.NewValue == null) return; foreach (var section in ModSectionsContainer.Children) - section.Mods = instance.GetModsFor(section.ModType); - - // attempt to re-select any already selected mods. - // this may be the first time we are receiving the ruleset, in which case they will still match. - selectedModsChanged(new ValueChangedEvent<IReadOnlyList<Mod>>(SelectedMods.Value, SelectedMods.Value)); - - // write the mods back to the SelectedMods bindable in the case a change was not applicable. - // this generally isn't required as the previous line will perform deselection; just here for safety. - refreshSelectedMods(); + section.Mods = mods.NewValue[section.ModType]; } - private void selectedModsChanged(ValueChangedEvent<IReadOnlyList<Mod>> e) + private void selectedModsChanged(ValueChangedEvent<IReadOnlyList<Mod>> mods) { foreach (var section in ModSectionsContainer.Children) - section.SelectTypes(e.NewValue.Select(m => m.GetType()).ToList()); + section.SelectTypes(mods.NewValue.Select(m => m.GetType()).ToList()); updateMods(); } @@ -392,12 +407,10 @@ namespace osu.Game.Overlays.Mods private void updateMods() { var multiplier = 1.0; - var ranked = true; foreach (var mod in SelectedMods.Value) { multiplier *= mod.ScoreMultiplier; - ranked &= mod.Ranked; } MultiplierLabel.Text = $"{multiplier:N2}x"; @@ -407,8 +420,6 @@ namespace osu.Game.Overlays.Mods MultiplierLabel.FadeColour(LowMultiplierColour, 200); else MultiplierLabel.FadeColour(Color4.White, 200); - - UnrankedLabel.FadeTo(ranked ? 0 : 1, 200); } private void modButtonPressed(Mod selectedMod) @@ -416,7 +427,10 @@ namespace osu.Game.Overlays.Mods if (selectedMod != null) { if (State.Value == Visibility.Visible) sampleOn?.Play(); + DeselectTypes(selectedMod.IncompatibleMods, true); + + if (selectedMod.RequiresConfiguration) ModSettingsContainer.Alpha = 1; } else { @@ -434,8 +448,8 @@ namespace osu.Game.Overlays.Mods { base.Dispose(isDisposing); - Ruleset.UnbindAll(); - SelectedMods.UnbindAll(); + availableMods?.UnbindAll(); + SelectedMods?.UnbindAll(); } #endregion diff --git a/osu.Game/Overlays/Mods/ModSettingsContainer.cs b/osu.Game/Overlays/Mods/ModSettingsContainer.cs new file mode 100644 index 0000000000..b185b56ecd --- /dev/null +++ b/osu.Game/Overlays/Mods/ModSettingsContainer.cs @@ -0,0 +1,84 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Mods; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Mods +{ + public class ModSettingsContainer : Container + { + public readonly IBindable<IReadOnlyList<Mod>> SelectedMods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); + + public IBindable<bool> HasSettingsForSelection => hasSettingsForSelection; + + private readonly Bindable<bool> hasSettingsForSelection = new Bindable<bool>(); + + private readonly FillFlowContainer<ModControlSection> modSettingsContent; + + public ModSettingsContainer() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = new Color4(0, 0, 0, 192) + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = modSettingsContent = new FillFlowContainer<ModControlSection> + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 10f), + Padding = new MarginPadding(20), + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedMods.BindValueChanged(modsChanged, true); + } + + private void modsChanged(ValueChangedEvent<IReadOnlyList<Mod>> mods) + { + modSettingsContent.Clear(); + + foreach (var mod in mods.NewValue) + { + var settings = mod.CreateSettingsControls().ToList(); + if (settings.Count > 0) + modSettingsContent.Add(new ModControlSection(mod, settings)); + } + + bool hasSettings = modSettingsContent.Count > 0; + + if (!hasSettings) + Hide(); + + hasSettingsForSelection.Value = hasSettings; + } + + protected override bool OnMouseDown(MouseDownEvent e) => true; + protected override bool OnHover(HoverEvent e) => true; + } +} diff --git a/osu.Game/Overlays/Music/CollectionsDropdown.cs b/osu.Game/Overlays/Music/CollectionDropdown.cs similarity index 70% rename from osu.Game/Overlays/Music/CollectionsDropdown.cs rename to osu.Game/Overlays/Music/CollectionDropdown.cs index 4f59b053b6..ed0ebf696b 100644 --- a/osu.Game/Overlays/Music/CollectionsDropdown.cs +++ b/osu.Game/Overlays/Music/CollectionDropdown.cs @@ -7,36 +7,34 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.UserInterface; +using osu.Game.Collections; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Music { - public class CollectionsDropdown<T> : OsuDropdown<T> + /// <summary> + /// A <see cref="CollectionFilterDropdown"/> for use in the <see cref="NowPlayingOverlay"/>. + /// </summary> + public class CollectionDropdown : CollectionFilterDropdown { + protected override bool ShowManageCollectionsItem => false; + [BackgroundDependencyLoader] private void load(OsuColour colours) { AccentColour = colours.Gray6; } - protected override DropdownHeader CreateHeader() => new CollectionsHeader(); + protected override CollectionDropdownHeader CreateCollectionHeader() => new CollectionsHeader(); - protected override DropdownMenu CreateMenu() => new CollectionsMenu(); + protected override CollectionDropdownMenu CreateCollectionMenu() => new CollectionsMenu(); - private class CollectionsMenu : OsuDropdownMenu + private class CollectionsMenu : CollectionDropdownMenu { public CollectionsMenu() { + Masking = true; CornerRadius = 5; - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.3f), - Radius = 3, - Offset = new Vector2(0f, 1f), - }; } [BackgroundDependencyLoader] @@ -46,7 +44,7 @@ namespace osu.Game.Overlays.Music } } - private class CollectionsHeader : OsuDropdownHeader + private class CollectionsHeader : CollectionDropdownHeader { [BackgroundDependencyLoader] private void load(OsuColour colours) diff --git a/osu.Game/Overlays/Music/FilterControl.cs b/osu.Game/Overlays/Music/FilterControl.cs index 99017579a2..66adbeebe8 100644 --- a/osu.Game/Overlays/Music/FilterControl.cs +++ b/osu.Game/Overlays/Music/FilterControl.cs @@ -8,13 +8,15 @@ using osu.Game.Graphics.UserInterface; using osuTK; using System; using osu.Framework.Allocation; -using osu.Framework.Bindables; namespace osu.Game.Overlays.Music { public class FilterControl : Container { + public Action<FilterCriteria> FilterChanged; + public readonly FilterTextBox Search; + private readonly CollectionDropdown collectionDropdown; public FilterControl() { @@ -31,25 +33,28 @@ namespace osu.Game.Overlays.Music { RelativeSizeAxes = Axes.X, Height = 40, - Exit = () => ExitRequested?.Invoke(), }, - new CollectionsDropdown<PlaylistCollection> - { - RelativeSizeAxes = Axes.X, - Items = new[] { PlaylistCollection.All }, - } + collectionDropdown = new CollectionDropdown { RelativeSizeAxes = Axes.X } }, }, }; - - Search.Current.ValueChanged += current_ValueChanged; } - private void current_ValueChanged(ValueChangedEvent<string> e) => FilterChanged?.Invoke(e.NewValue); + protected override void LoadComplete() + { + base.LoadComplete(); - public Action ExitRequested; + Search.Current.BindValueChanged(_ => updateCriteria()); + collectionDropdown.Current.BindValueChanged(_ => updateCriteria(), true); + } - public Action<string> FilterChanged; + private void updateCriteria() => FilterChanged?.Invoke(createCriteria()); + + private FilterCriteria createCriteria() => new FilterCriteria + { + SearchText = Search.Current.Value, + Collection = collectionDropdown.Current.Value?.Collection + }; public class FilterTextBox : SearchTextBox { diff --git a/osu.Game/Overlays/Music/FilterCriteria.cs b/osu.Game/Overlays/Music/FilterCriteria.cs new file mode 100644 index 0000000000..f15edff4d0 --- /dev/null +++ b/osu.Game/Overlays/Music/FilterCriteria.cs @@ -0,0 +1,22 @@ +// 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. + +using JetBrains.Annotations; +using osu.Game.Collections; + +namespace osu.Game.Overlays.Music +{ + public class FilterCriteria + { + /// <summary> + /// The search text. + /// </summary> + public string SearchText; + + /// <summary> + /// The collection to filter beatmaps from. + /// </summary> + [CanBeNull] + public BeatmapCollection Collection; + } +} diff --git a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs new file mode 100644 index 0000000000..e6edfb1e3e --- /dev/null +++ b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs @@ -0,0 +1,81 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Input.Bindings; +using osu.Game.Beatmaps; +using osu.Game.Input.Bindings; +using osu.Game.Overlays.OSD; + +namespace osu.Game.Overlays.Music +{ + /// <summary> + /// Handles <see cref="GlobalAction"/>s related to music playback, and displays <see cref="Toast"/>s via the global <see cref="OnScreenDisplay"/> accordingly. + /// </summary> + public class MusicKeyBindingHandler : Component, IKeyBindingHandler<GlobalAction> + { + [Resolved] + private IBindable<WorkingBeatmap> beatmap { get; set; } + + [Resolved] + private MusicController musicController { get; set; } + + [Resolved(canBeNull: true)] + private OnScreenDisplay onScreenDisplay { get; set; } + + public bool OnPressed(GlobalAction action) + { + if (beatmap.Disabled) + return false; + + switch (action) + { + case GlobalAction.MusicPlay: + // use previous state as TogglePause may not update the track's state immediately (state update is run on the audio thread see https://github.com/ppy/osu/issues/9880#issuecomment-674668842) + bool wasPlaying = musicController.IsPlaying; + + if (musicController.TogglePause()) + onScreenDisplay?.Display(new MusicActionToast(wasPlaying ? "Pause track" : "Play track")); + return true; + + case GlobalAction.MusicNext: + musicController.NextTrack(() => onScreenDisplay?.Display(new MusicActionToast("Next track"))); + + return true; + + case GlobalAction.MusicPrev: + musicController.PreviousTrack(res => + { + switch (res) + { + case PreviousTrackResult.Restart: + onScreenDisplay?.Display(new MusicActionToast("Restart track")); + break; + + case PreviousTrackResult.Previous: + onScreenDisplay?.Display(new MusicActionToast("Previous track")); + break; + } + }); + + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } + + private class MusicActionToast : Toast + { + public MusicActionToast(string action) + : base("Music Playback", action, string.Empty) + { + } + } + } +} diff --git a/osu.Game/Overlays/Music/Playlist.cs b/osu.Game/Overlays/Music/Playlist.cs new file mode 100644 index 0000000000..4fe338926f --- /dev/null +++ b/osu.Game/Overlays/Music/Playlist.cs @@ -0,0 +1,52 @@ +// 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. + +using System; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Overlays.Music +{ + public class Playlist : OsuRearrangeableListContainer<BeatmapSetInfo> + { + public Action<BeatmapSetInfo> RequestSelection; + + public readonly Bindable<BeatmapSetInfo> SelectedSet = new Bindable<BeatmapSetInfo>(); + + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + + public void Filter(FilterCriteria criteria) + { + var items = (SearchContainer<RearrangeableListItem<BeatmapSetInfo>>)ListContainer; + + foreach (var item in items.OfType<PlaylistItem>()) + item.InSelectedCollection = criteria.Collection?.Beatmaps.Any(b => b.BeatmapSet.Equals(item.Model)) ?? true; + + items.SearchTerm = criteria.SearchText; + } + + public BeatmapSetInfo FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter); + + protected override OsuRearrangeableListItem<BeatmapSetInfo> CreateOsuDrawable(BeatmapSetInfo item) => new PlaylistItem(item) + { + SelectedSet = { BindTarget = SelectedSet }, + RequestSelection = set => RequestSelection?.Invoke(set) + }; + + protected override FillFlowContainer<RearrangeableListItem<BeatmapSetInfo>> CreateListFillFlowContainer() => new SearchContainer<RearrangeableListItem<BeatmapSetInfo>> + { + Spacing = new Vector2(0, 3), + LayoutDuration = 200, + LayoutEasing = Easing.OutQuint, + }; + } +} diff --git a/osu.Game/Overlays/Music/PlaylistItem.cs b/osu.Game/Overlays/Music/PlaylistItem.cs index df37a1b2c7..96dff39fae 100644 --- a/osu.Game/Overlays/Music/PlaylistItem.cs +++ b/osu.Game/Overlays/Music/PlaylistItem.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; -using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -14,105 +14,74 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays.Music { - public class PlaylistItem : Container, IFilterable, IDraggable + public class PlaylistItem : OsuRearrangeableListItem<BeatmapSetInfo>, IFilterable { - private const float fade_duration = 100; + public readonly Bindable<BeatmapSetInfo> SelectedSet = new Bindable<BeatmapSetInfo>(); - private Color4 hoverColour; - private Color4 artistColour; + public Action<BeatmapSetInfo> RequestSelection; - private SpriteIcon handle; private TextFlowContainer text; private IEnumerable<Drawable> titleSprites; - private ILocalisedBindableString titleBind; - private ILocalisedBindableString artistBind; - public readonly BeatmapSetInfo BeatmapSetInfo; + private ILocalisedBindableString title; + private ILocalisedBindableString artist; - public Action<BeatmapSetInfo> OnSelect; + private Color4 selectedColour; + private Color4 artistColour; - public bool IsDraggable { get; private set; } - - protected override bool OnMouseDown(MouseDownEvent e) + public PlaylistItem(BeatmapSetInfo item) + : base(item) { - IsDraggable = handle.IsHovered; - return base.OnMouseDown(e); - } + Padding = new MarginPadding { Left = 5 }; - protected override bool OnMouseUp(MouseUpEvent e) - { - IsDraggable = false; - return base.OnMouseUp(e); - } - - private bool selected; - - public bool Selected - { - get => selected; - set - { - if (value == selected) return; - - selected = value; - - FinishTransforms(true); - foreach (Drawable s in titleSprites) - s.FadeColour(Selected ? hoverColour : Color4.White, fade_duration); - } - } - - public PlaylistItem(BeatmapSetInfo setInfo) - { - BeatmapSetInfo = setInfo; - - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Padding = new MarginPadding { Top = 3, Bottom = 3 }; + FilterTerms = item.Metadata.SearchableTerms; } [BackgroundDependencyLoader] private void load(OsuColour colours, LocalisationManager localisation) { - hoverColour = colours.Yellow; + selectedColour = colours.Yellow; artistColour = colours.Gray9; + HandleColour = colours.Gray5; - var metadata = BeatmapSetInfo.Metadata; - FilterTerms = metadata.SearchableTerms; - - Children = new Drawable[] - { - handle = new PlaylistItemHandle - { - Colour = colours.Gray5 - }, - text = new OsuTextFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = 20 }, - ContentIndent = 10f, - }, - }; - - titleBind = localisation.GetLocalisedString(new LocalisedString((metadata.TitleUnicode, metadata.Title))); - artistBind = localisation.GetLocalisedString(new LocalisedString((metadata.ArtistUnicode, metadata.Artist))); - - artistBind.BindValueChanged(_ => recreateText(), true); + title = localisation.GetLocalisedString(new LocalisedString((Model.Metadata.TitleUnicode, Model.Metadata.Title))); + artist = localisation.GetLocalisedString(new LocalisedString((Model.Metadata.ArtistUnicode, Model.Metadata.Artist))); } + protected override void LoadComplete() + { + base.LoadComplete(); + + artist.BindValueChanged(_ => recreateText(), true); + + SelectedSet.BindValueChanged(set => + { + if (set.OldValue?.Equals(Model) != true && set.NewValue?.Equals(Model) != true) + return; + + foreach (Drawable s in titleSprites) + s.FadeColour(set.NewValue.Equals(Model) ? selectedColour : Color4.White, FADE_DURATION); + }, true); + } + + protected override Drawable CreateContent() => text = new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }; + private void recreateText() { text.Clear(); - //space after the title to put a space between the title and artist - titleSprites = text.AddText(titleBind.Value + @" ", sprite => sprite.Font = OsuFont.GetFont(weight: FontWeight.Regular)).OfType<SpriteText>(); + // space after the title to put a space between the title and artist + titleSprites = text.AddText(title.Value + @" ", sprite => sprite.Font = OsuFont.GetFont(weight: FontWeight.Regular)).OfType<SpriteText>(); - text.AddText(artistBind.Value, sprite => + text.AddText(artist.Value, sprite => { sprite.Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold); sprite.Colour = artistColour; @@ -120,64 +89,46 @@ namespace osu.Game.Overlays.Music }); } - protected override bool OnHover(HoverEvent e) - { - handle.FadeIn(fade_duration); - - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - handle.FadeOut(fade_duration); - } - protected override bool OnClick(ClickEvent e) { - OnSelect?.Invoke(BeatmapSetInfo); + RequestSelection?.Invoke(Model); return true; } - public IEnumerable<string> FilterTerms { get; private set; } + private bool inSelectedCollection = true; - private bool matching = true; + public bool InSelectedCollection + { + get => inSelectedCollection; + set + { + if (inSelectedCollection == value) + return; + + inSelectedCollection = value; + updateFilter(); + } + } + + public IEnumerable<string> FilterTerms { get; } + + private bool matchingFilter = true; public bool MatchingFilter { - get => matching; + get => matchingFilter && inSelectedCollection; set { - if (matching == value) return; + if (matchingFilter == value) + return; - matching = value; - - this.FadeTo(matching ? 1 : 0, 200); + matchingFilter = value; + updateFilter(); } } + private void updateFilter() => this.FadeTo(MatchingFilter ? 1 : 0, 200); + public bool FilteringActive { get; set; } - - private class PlaylistItemHandle : SpriteIcon - { - public PlaylistItemHandle() - { - Anchor = Anchor.TopLeft; - Origin = Anchor.TopLeft; - Size = new Vector2(12); - Icon = FontAwesome.Solid.Bars; - Alpha = 0f; - Margin = new MarginPadding { Left = 5, Top = 2 }; - } - - public override bool HandlePositionalInput => IsPresent; - } - } - - public interface IDraggable : IDrawable - { - /// <summary> - /// Whether this <see cref="IDraggable"/> can be dragged in its current state. - /// </summary> - bool IsDraggable { get; } } } diff --git a/osu.Game/Overlays/Music/PlaylistList.cs b/osu.Game/Overlays/Music/PlaylistList.cs deleted file mode 100644 index 539601c359..0000000000 --- a/osu.Game/Overlays/Music/PlaylistList.cs +++ /dev/null @@ -1,244 +0,0 @@ -// 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. - -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.Game.Beatmaps; -using osu.Game.Graphics.Containers; -using osuTK; - -namespace osu.Game.Overlays.Music -{ - public class PlaylistList : CompositeDrawable - { - public Action<BeatmapSetInfo> Selected; - public Action<BeatmapSetInfo, int> OrderChanged; - - private readonly ItemsScrollContainer items; - - public PlaylistList() - { - InternalChild = items = new ItemsScrollContainer - { - RelativeSizeAxes = Axes.Both, - Selected = set => Selected?.Invoke(set), - OrderChanged = (s, i) => OrderChanged?.Invoke(s, i) - }; - } - - public new MarginPadding Padding - { - get => base.Padding; - set => base.Padding = value; - } - - public BeatmapSetInfo FirstVisibleSet => items.FirstVisibleSet; - - public void Filter(string searchTerm) => items.SearchTerm = searchTerm; - - private class ItemsScrollContainer : OsuScrollContainer - { - public Action<BeatmapSetInfo> Selected; - public Action<BeatmapSetInfo, int> OrderChanged; - - private readonly SearchContainer search; - private readonly FillFlowContainer<PlaylistItem> items; - - private readonly IBindable<WorkingBeatmap> beatmapBacking = new Bindable<WorkingBeatmap>(); - - public ItemsScrollContainer() - { - Children = new Drawable[] - { - search = new SearchContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - items = new ItemSearchContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - } - } - }; - } - - [BackgroundDependencyLoader] - private void load(BeatmapManager beatmaps, IBindable<WorkingBeatmap> beatmap) - { - beatmaps.GetAllUsableBeatmapSets().ForEach(addBeatmapSet); - beatmaps.ItemAdded += addBeatmapSet; - beatmaps.ItemRemoved += removeBeatmapSet; - - beatmapBacking.BindTo(beatmap); - beatmapBacking.ValueChanged += _ => updateSelectedSet(); - } - - private void addBeatmapSet(BeatmapSetInfo obj) => Schedule(() => - { - items.Insert(items.Count - 1, new PlaylistItem(obj) { OnSelect = set => Selected?.Invoke(set) }); - }); - - private void removeBeatmapSet(BeatmapSetInfo obj) => Schedule(() => - { - var itemToRemove = items.FirstOrDefault(i => i.BeatmapSetInfo.ID == obj.ID); - if (itemToRemove != null) - items.Remove(itemToRemove); - }); - - private void updateSelectedSet() - { - foreach (PlaylistItem s in items.Children) - s.Selected = s.BeatmapSetInfo.ID == beatmapBacking.Value.BeatmapSetInfo?.ID; - } - - public string SearchTerm - { - get => search.SearchTerm; - set => search.SearchTerm = value; - } - - public BeatmapSetInfo FirstVisibleSet => items.FirstOrDefault(i => i.MatchingFilter)?.BeatmapSetInfo; - - private Vector2 nativeDragPosition; - private PlaylistItem draggedItem; - - protected override bool OnDragStart(DragStartEvent e) - { - nativeDragPosition = e.ScreenSpaceMousePosition; - draggedItem = items.FirstOrDefault(d => d.IsDraggable); - return draggedItem != null || base.OnDragStart(e); - } - - protected override bool OnDrag(DragEvent e) - { - nativeDragPosition = e.ScreenSpaceMousePosition; - if (draggedItem == null) - return base.OnDrag(e); - - return true; - } - - protected override bool OnDragEnd(DragEndEvent e) - { - nativeDragPosition = e.ScreenSpaceMousePosition; - var handled = draggedItem != null || base.OnDragEnd(e); - draggedItem = null; - - return handled; - } - - protected override void Update() - { - base.Update(); - - if (draggedItem == null) - return; - - updateScrollPosition(); - updateDragPosition(); - } - - private void updateScrollPosition() - { - const float start_offset = 10; - const double max_power = 50; - const double exp_base = 1.05; - - var localPos = ToLocalSpace(nativeDragPosition); - - if (localPos.Y < start_offset) - { - if (Current <= 0) - return; - - var power = Math.Min(max_power, Math.Abs(start_offset - localPos.Y)); - ScrollBy(-(float)Math.Pow(exp_base, power)); - } - else if (localPos.Y > DrawHeight - start_offset) - { - if (IsScrolledToEnd()) - return; - - var power = Math.Min(max_power, Math.Abs(DrawHeight - start_offset - localPos.Y)); - ScrollBy((float)Math.Pow(exp_base, power)); - } - } - - private void updateDragPosition() - { - var itemsPos = items.ToLocalSpace(nativeDragPosition); - - int srcIndex = (int)items.GetLayoutPosition(draggedItem); - - // Find the last item with position < mouse position. Note we can't directly use - // the item positions as they are being transformed - float heightAccumulator = 0; - int dstIndex = 0; - - for (; dstIndex < items.Count; dstIndex++) - { - // Using BoundingBox here takes care of scale, paddings, etc... - heightAccumulator += items[dstIndex].BoundingBox.Height; - if (heightAccumulator > itemsPos.Y) - break; - } - - dstIndex = MathHelper.Clamp(dstIndex, 0, items.Count - 1); - - if (srcIndex == dstIndex) - return; - - if (srcIndex < dstIndex) - { - for (int i = srcIndex + 1; i <= dstIndex; i++) - items.SetLayoutPosition(items[i], i - 1); - } - else - { - for (int i = dstIndex; i < srcIndex; i++) - items.SetLayoutPosition(items[i], i + 1); - } - - items.SetLayoutPosition(draggedItem, dstIndex); - OrderChanged?.Invoke(draggedItem.BeatmapSetInfo, dstIndex); - } - - private class ItemSearchContainer : FillFlowContainer<PlaylistItem>, IHasFilterableChildren - { - public IEnumerable<string> FilterTerms => new string[] { }; - - public bool MatchingFilter - { - set - { - if (value) - InvalidateLayout(); - } - } - - public bool FilteringActive - { - set { } - } - - public IEnumerable<IFilterable> FilterableChildren => Children; - - public ItemSearchContainer() - { - LayoutDuration = 200; - LayoutEasing = Easing.OutQuint; - } - } - } - } -} diff --git a/osu.Game/Overlays/Music/PlaylistOverlay.cs b/osu.Game/Overlays/Music/PlaylistOverlay.cs index ec3d708645..b8d04eab4e 100644 --- a/osu.Game/Overlays/Music/PlaylistOverlay.cs +++ b/osu.Game/Overlays/Music/PlaylistOverlay.cs @@ -1,7 +1,6 @@ // 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. -using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,28 +16,27 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Music { - public class PlaylistOverlay : OverlayContainer + public class PlaylistOverlay : VisibilityContainer { private const float transition_duration = 600; private const float playlist_height = 510; - /// <summary> - /// Invoked when the order of an item in the list has changed. - /// The second parameter indicates the new index of the item. - /// </summary> - public Action<BeatmapSetInfo, int> OrderChanged; + public IBindableList<BeatmapSetInfo> BeatmapSets => beatmapSets; + + private readonly BindableList<BeatmapSetInfo> beatmapSets = new BindableList<BeatmapSetInfo>(); private readonly Bindable<WorkingBeatmap> beatmap = new Bindable<WorkingBeatmap>(); - private BeatmapManager beatmaps; + + [Resolved] + private BeatmapManager beatmaps { get; set; } private FilterControl filter; - private PlaylistList list; + private Playlist list; [BackgroundDependencyLoader] - private void load(OsuColour colours, Bindable<WorkingBeatmap> beatmap, BeatmapManager beatmaps) + private void load(OsuColour colours, Bindable<WorkingBeatmap> beatmap) { this.beatmap.BindTo(beatmap); - this.beatmaps = beatmaps; Children = new Drawable[] { @@ -60,26 +58,24 @@ namespace osu.Game.Overlays.Music Colour = colours.Gray3, RelativeSizeAxes = Axes.Both, }, - list = new PlaylistList + list = new Playlist { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = 95, Bottom = 10, Right = 10 }, - Selected = itemSelected, - OrderChanged = (s, i) => OrderChanged?.Invoke(s, i) + RequestSelection = itemSelected }, filter = new FilterControl { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - ExitRequested = Hide, - FilterChanged = search => list.Filter(search), + FilterChanged = criteria => list.Filter(criteria), Padding = new MarginPadding(10), }, }, }, }; - filter.Search.OnCommit = (sender, newText) => + filter.Search.OnCommit += (sender, newText) => { BeatmapInfo toSelect = list.FirstVisibleSet?.Beatmaps?.FirstOrDefault(); @@ -91,6 +87,14 @@ namespace osu.Game.Overlays.Music }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + list.Items.BindTo(beatmapSets); + beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo, true); + } + protected override void PopIn() { filter.Search.HoldFocus = true; @@ -112,7 +116,7 @@ namespace osu.Game.Overlays.Music { if (set.ID == (beatmap.Value?.BeatmapSetInfo?.ID ?? -1)) { - beatmap.Value?.Track?.Seek(0); + beatmap.Value?.Track.Seek(0); return; } @@ -120,10 +124,4 @@ namespace osu.Game.Overlays.Music beatmap.Value.Track.Restart(); } } - - //todo: placeholder - public enum PlaylistCollection - { - All - } } diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index abbcec5094..3a9a6261ba 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -1,346 +1,326 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. using System; using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Framework.Input.Events; -using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Framework.Threading; using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays.Music; using osu.Game.Rulesets.Mods; -using osuTK; -using osuTK.Graphics; namespace osu.Game.Overlays { - public class MusicController : OsuFocusedOverlayContainer + /// <summary> + /// Handles playback of the global music track. + /// </summary> + public class MusicController : CompositeDrawable { - private const float player_height = 130; - private const float transition_length = 800; - private const float progress_height = 10; - private const float bottom_black_area_height = 55; + [Resolved] + private BeatmapManager beatmaps { get; set; } - private Drawable background; - private ProgressBar progressBar; + public IBindableList<BeatmapSetInfo> BeatmapSets + { + get + { + if (LoadState < LoadState.Ready) + throw new InvalidOperationException($"{nameof(BeatmapSets)} should not be accessed before the music controller is loaded."); - private IconButton prevButton; - private IconButton playButton; - private IconButton nextButton; - private IconButton playlistButton; + return beatmapSets; + } + } - private SpriteText title, artist; + /// <summary> + /// Point in time after which the current track will be restarted on triggering a "previous track" action. + /// </summary> + private const double restart_cutoff_point = 5000; - private PlaylistOverlay playlist; + private readonly BindableList<BeatmapSetInfo> beatmapSets = new BindableList<BeatmapSetInfo>(); - private BeatmapManager beatmaps; + /// <summary> + /// Whether the user has requested the track to be paused. Use <see cref="IsPlaying"/> to determine whether the track is still playing. + /// </summary> + public bool UserPauseRequested { get; private set; } - private List<BeatmapSetInfo> beatmapSets; - - private Container dragContainer; - private Container playerContainer; - - public bool IsUserPaused { get; private set; } + /// <summary> + /// Fired when the global <see cref="WorkingBeatmap"/> has changed. + /// Includes direction information for display purposes. + /// </summary> + public event Action<WorkingBeatmap, TrackChangeDirection> TrackChanged; [Resolved] - private Bindable<WorkingBeatmap> beatmap { get; set; } + private IBindable<WorkingBeatmap> beatmap { get; set; } [Resolved] private IBindable<IReadOnlyList<Mod>> mods { get; set; } - /// <summary> - /// Provide a source for the toolbar height. - /// </summary> - public Func<float> GetToolbarHeight; + [NotNull] + public DrawableTrack CurrentTrack { get; private set; } = new DrawableTrack(new TrackVirtual(1000)); - public MusicController() - { - Width = 400; - Margin = new MarginPadding(10); - } + private IBindable<WeakReference<BeatmapSetInfo>> managerUpdated; + private IBindable<WeakReference<BeatmapSetInfo>> managerRemoved; [BackgroundDependencyLoader] - private void load(Bindable<WorkingBeatmap> beatmap, BeatmapManager beatmaps, OsuColour colours) + private void load() { - this.beatmaps = beatmaps; + managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(beatmapUpdated); + managerRemoved = beatmaps.ItemRemoved.GetBoundCopy(); + managerRemoved.BindValueChanged(beatmapRemoved); - Children = new Drawable[] - { - dragContainer = new DragContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - playlist = new PlaylistOverlay - { - RelativeSizeAxes = Axes.X, - Y = player_height + 10, - OrderChanged = playlistOrderChanged - }, - playerContainer = new Container - { - RelativeSizeAxes = Axes.X, - Height = player_height, - Masking = true, - CornerRadius = 5, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(40), - Radius = 5, - }, - Children = new[] - { - background = new Background(), - title = new OsuSpriteText - { - Origin = Anchor.BottomCentre, - Anchor = Anchor.TopCentre, - Position = new Vector2(0, 40), - Font = OsuFont.GetFont(size: 25, italics: true), - Colour = Color4.White, - Text = @"Nothing to play", - }, - artist = new OsuSpriteText - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Position = new Vector2(0, 45), - Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold, italics: true), - Colour = Color4.White, - Text = @"Nothing to play", - }, - new Container - { - Padding = new MarginPadding { Bottom = progress_height }, - Height = bottom_black_area_height, - RelativeSizeAxes = Axes.X, - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - Children = new Drawable[] - { - new FillFlowContainer<IconButton> - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Children = new[] - { - prevButton = new MusicIconButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Action = prev, - Icon = FontAwesome.Solid.StepBackward, - }, - playButton = new MusicIconButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(1.4f), - IconScale = new Vector2(1.4f), - Action = togglePause, - Icon = FontAwesome.Regular.PlayCircle, - }, - nextButton = new MusicIconButton - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Action = () => next(), - Icon = FontAwesome.Solid.StepForward, - }, - } - }, - playlistButton = new MusicIconButton - { - Origin = Anchor.Centre, - Anchor = Anchor.CentreRight, - Position = new Vector2(-bottom_black_area_height / 2, 0), - Icon = FontAwesome.Solid.Bars, - Action = () => playlist.ToggleVisibility(), - }, - } - }, - progressBar = new ProgressBar - { - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - Height = progress_height, - FillColour = colours.Yellow, - OnSeek = attemptSeek - } - }, - }, - } - } - }; + beatmapSets.AddRange(beatmaps.GetAllUsableBeatmapSets(IncludedDetails.Minimal, true).OrderBy(_ => RNG.Next())); - beatmapSets = beatmaps.GetAllUsableBeatmapSets(); - beatmaps.ItemAdded += handleBeatmapAdded; - beatmaps.ItemRemoved += handleBeatmapRemoved; - - playlist.State.ValueChanged += s => playlistButton.FadeColour(s.NewValue == Visibility.Visible ? colours.Yellow : Color4.White, 200, Easing.OutQuint); + // Todo: These binds really shouldn't be here, but are unlikely to cause any issues for now. + // They are placed here for now since some tests rely on setting the beatmap _and_ their hierarchies inside their load(), which runs before the MusicController's load(). + beatmap.BindValueChanged(beatmapChanged, true); + mods.BindValueChanged(_ => ResetTrackAdjustments(), true); } - private ScheduledDelegate seekDelegate; + /// <summary> + /// Forcefully reload the current <see cref="WorkingBeatmap"/>'s track from disk. + /// </summary> + public void ReloadCurrentTrack() => changeTrack(); - private void attemptSeek(double progress) - { - seekDelegate?.Cancel(); - seekDelegate = Schedule(() => - { - if (!beatmap.Disabled) - current?.Track.Seek(progress); - }); - } - - private void playlistOrderChanged(BeatmapSetInfo beatmapSetInfo, int index) + /// <summary> + /// Change the position of a <see cref="BeatmapSetInfo"/> in the current playlist. + /// </summary> + /// <param name="beatmapSetInfo">The beatmap to move.</param> + /// <param name="index">The new position.</param> + public void ChangeBeatmapSetPosition(BeatmapSetInfo beatmapSetInfo, int index) { beatmapSets.Remove(beatmapSetInfo); beatmapSets.Insert(index, beatmapSetInfo); } - private void handleBeatmapAdded(BeatmapSetInfo set) => Schedule(() => beatmapSets.Add(set)); + /// <summary> + /// Returns whether the beatmap track is playing. + /// </summary> + public bool IsPlaying => CurrentTrack.IsRunning; - private void handleBeatmapRemoved(BeatmapSetInfo set) => Schedule(() => beatmapSets.RemoveAll(s => s.ID == set.ID)); + /// <summary> + /// Returns whether the beatmap track is loaded. + /// </summary> + public bool TrackLoaded => CurrentTrack.TrackLoaded; - protected override void LoadComplete() + private void beatmapUpdated(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakSet) { - beatmap.BindValueChanged(beatmapChanged, true); - beatmap.BindDisabledChanged(beatmapDisabledChanged, true); - mods.BindValueChanged(_ => updateAudioAdjustments(), true); - base.LoadComplete(); - } - - private void beatmapDisabledChanged(bool disabled) - { - if (disabled) - playlist.Hide(); - - playButton.Enabled.Value = !disabled; - prevButton.Enabled.Value = !disabled; - nextButton.Enabled.Value = !disabled; - playlistButton.Enabled.Value = !disabled; - } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - Height = dragContainer.Height; - - dragContainer.Padding = new MarginPadding { Top = GetToolbarHeight?.Invoke() ?? 0 }; - } - - protected override void Update() - { - base.Update(); - - if (pendingBeatmapSwitch != null) + if (weakSet.NewValue.TryGetTarget(out var set)) { - pendingBeatmapSwitch(); - pendingBeatmapSwitch = null; - } - - var track = current?.TrackLoaded ?? false ? current.Track : null; - - if (track?.IsDummyDevice == false) - { - progressBar.EndTime = track.Length; - progressBar.CurrentTime = track.CurrentTime; - - playButton.Icon = track.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; - } - else - { - progressBar.CurrentTime = 0; - progressBar.EndTime = 1; - playButton.Icon = FontAwesome.Regular.PlayCircle; + Schedule(() => + { + beatmapSets.Remove(set); + beatmapSets.Add(set); + }); } } - private void togglePause() + private void beatmapRemoved(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakSet) { - var track = current?.Track; + if (weakSet.NewValue.TryGetTarget(out var set)) + { + Schedule(() => + { + beatmapSets.RemoveAll(s => s.ID == set.ID); + }); + } + } - if (track == null) + private ScheduledDelegate seekDelegate; + + public void SeekTo(double position) + { + seekDelegate?.Cancel(); + seekDelegate = Schedule(() => { if (!beatmap.Disabled) - next(true); - return; - } + CurrentTrack.Seek(position); + }); + } - if (track.IsRunning) + /// <summary> + /// Ensures music is playing, no matter what, unless the user has explicitly paused. + /// This means that if the current beatmap has a virtual track (see <see cref="TrackVirtual"/>) a new beatmap will be selected. + /// </summary> + public void EnsurePlayingSomething() + { + if (UserPauseRequested) return; + + if (CurrentTrack.IsDummyDevice || beatmap.Value.BeatmapSetInfo.DeletePending) { - IsUserPaused = true; - track.Stop(); + if (beatmap.Disabled) + return; + + NextTrack(); } + else if (!IsPlaying) + { + Play(); + } + } + + /// <summary> + /// Start playing the current track (if not already playing). + /// </summary> + /// <param name="restart">Whether to restart the track from the beginning.</param> + /// <param name="requestedByUser"> + /// Whether the request to play was issued by the user rather than internally. + /// Specifying <c>true</c> will ensure that other methods like <see cref="EnsurePlayingSomething"/> + /// will resume music playback going forward. + /// </param> + /// <returns>Whether the operation was successful.</returns> + public bool Play(bool restart = false, bool requestedByUser = false) + { + if (requestedByUser) + UserPauseRequested = false; + + if (restart) + CurrentTrack.Restart(); + else if (!IsPlaying) + CurrentTrack.Start(); + + return true; + } + + /// <summary> + /// Stop playing the current track and pause at the current position. + /// </summary> + /// <param name="requestedByUser"> + /// Whether the request to stop was issued by the user rather than internally. + /// Specifying <c>true</c> will ensure that other methods like <see cref="EnsurePlayingSomething"/> + /// will not resume music playback until the next explicit call to <see cref="Play"/>. + /// </param> + public void Stop(bool requestedByUser = false) + { + UserPauseRequested |= requestedByUser; + if (CurrentTrack.IsRunning) + CurrentTrack.Stop(); + } + + /// <summary> + /// Toggle pause / play. + /// </summary> + /// <returns>Whether the operation was successful.</returns> + public bool TogglePause() + { + if (CurrentTrack.IsRunning) + Stop(true); else - { - track.Start(); - IsUserPaused = false; - } + Play(requestedByUser: true); + + return true; } - private void prev() + /// <summary> + /// Play the previous track or restart the current track if it's current time below <see cref="restart_cutoff_point"/>. + /// </summary> + /// <param name="onSuccess">Invoked when the operation has been performed successfully.</param> + public void PreviousTrack(Action<PreviousTrackResult> onSuccess = null) => Schedule(() => { - queuedDirection = TransformDirection.Prev; + PreviousTrackResult res = prev(); + if (res != PreviousTrackResult.None) + onSuccess?.Invoke(res); + }); - var playable = beatmapSets.TakeWhile(i => i.ID != current.BeatmapSetInfo.ID).LastOrDefault() ?? beatmapSets.LastOrDefault(); + /// <summary> + /// Play the previous track or restart the current track if it's current time below <see cref="restart_cutoff_point"/>. + /// </summary> + /// <returns>The <see cref="PreviousTrackResult"/> that indicate the decided action.</returns> + private PreviousTrackResult prev() + { + if (beatmap.Disabled) + return PreviousTrackResult.None; + + var currentTrackPosition = CurrentTrack.CurrentTime; + + if (currentTrackPosition >= restart_cutoff_point) + { + SeekTo(0); + return PreviousTrackResult.Restart; + } + + queuedDirection = TrackChangeDirection.Prev; + + var playable = BeatmapSets.TakeWhile(i => i.ID != current.BeatmapSetInfo.ID).LastOrDefault() ?? BeatmapSets.LastOrDefault(); if (playable != null) { - beatmap.Value = beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value); - beatmap.Value.Track.Restart(); + changeBeatmap(beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value)); + restartTrack(); + return PreviousTrackResult.Previous; } + + return PreviousTrackResult.None; } - private void next(bool instant = false) + /// <summary> + /// Play the next random or playlist track. + /// </summary> + /// <param name="onSuccess">Invoked when the operation has been performed successfully.</param> + /// <returns>A <see cref="ScheduledDelegate"/> of the operation.</returns> + public void NextTrack(Action onSuccess = null) => Schedule(() => { - if (!instant) - queuedDirection = TransformDirection.Next; + bool res = next(); + if (res) + onSuccess?.Invoke(); + }); - var playable = beatmapSets.SkipWhile(i => i.ID != current.BeatmapSetInfo.ID).Skip(1).FirstOrDefault() ?? beatmapSets.FirstOrDefault(); + private bool next() + { + if (beatmap.Disabled) + return false; + + queuedDirection = TrackChangeDirection.Next; + + var playable = BeatmapSets.SkipWhile(i => i.ID != current.BeatmapSetInfo.ID).ElementAtOrDefault(1) ?? BeatmapSets.FirstOrDefault(); if (playable != null) { - beatmap.Value = beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value); - beatmap.Value.Track.Restart(); + changeBeatmap(beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value)); + restartTrack(); + return true; } + + return false; + } + + private void restartTrack() + { + // if not scheduled, the previously track will be stopped one frame later (see ScheduleAfterChildren logic in GameBase). + // we probably want to move this to a central method for switching to a new working beatmap in the future. + Schedule(() => CurrentTrack.Restart()); } private WorkingBeatmap current; - private TransformDirection? queuedDirection; - private void beatmapChanged(ValueChangedEvent<WorkingBeatmap> beatmap) + private TrackChangeDirection? queuedDirection; + + private void beatmapChanged(ValueChangedEvent<WorkingBeatmap> beatmap) => changeBeatmap(beatmap.NewValue); + + private void changeBeatmap(WorkingBeatmap newWorking) { - TransformDirection direction = TransformDirection.None; + // This method can potentially be triggered multiple times as it is eagerly fired in next() / prev() to ensure correct execution order + // (changeBeatmap must be called before consumers receive the bindable changed event, which is not the case when the local beatmap bindable is updated directly). + if (newWorking == current) + return; + + var lastWorking = current; + + TrackChangeDirection direction = TrackChangeDirection.None; + + bool audioEquals = newWorking?.BeatmapInfo?.AudioEquals(current?.BeatmapInfo) ?? false; if (current != null) { - bool audioEquals = beatmap.NewValue?.BeatmapInfo?.AudioEquals(current.BeatmapInfo) ?? false; - if (audioEquals) - direction = TransformDirection.None; + direction = TrackChangeDirection.None; else if (queuedDirection.HasValue) { direction = queuedDirection.Value; @@ -348,195 +328,125 @@ namespace osu.Game.Overlays } else { - //figure out the best direction based on order in playlist. - var last = beatmapSets.TakeWhile(b => b.ID != current.BeatmapSetInfo?.ID).Count(); - var next = beatmap.NewValue == null ? -1 : beatmapSets.TakeWhile(b => b.ID != beatmap.NewValue.BeatmapSetInfo?.ID).Count(); + // figure out the best direction based on order in playlist. + var last = BeatmapSets.TakeWhile(b => b.ID != current.BeatmapSetInfo?.ID).Count(); + var next = newWorking == null ? -1 : BeatmapSets.TakeWhile(b => b.ID != newWorking.BeatmapSetInfo?.ID).Count(); - direction = last > next ? TransformDirection.Prev : TransformDirection.Next; + direction = last > next ? TrackChangeDirection.Prev : TrackChangeDirection.Next; } } - progressBar.CurrentTime = 0; + current = newWorking; - updateDisplay(current = beatmap.NewValue, direction); - updateAudioAdjustments(); + if (!audioEquals || CurrentTrack.IsDummyDevice) + { + changeTrack(); + } + else + { + // transfer still valid track to new working beatmap + current.TransferTrack(lastWorking.Track); + } + + TrackChanged?.Invoke(current, direction); + + ResetTrackAdjustments(); queuedDirection = null; + + // this will be a noop if coming from the beatmapChanged event. + // the exception is local operations like next/prev, where we want to complete loading the track before sending out a change. + if (beatmap.Value != current && beatmap is Bindable<WorkingBeatmap> working) + working.Value = current; } - private void updateAudioAdjustments() + private void changeTrack() { - var track = current?.Track; - if (track == null) + var lastTrack = CurrentTrack; + + var queuedTrack = new DrawableTrack(current.LoadTrack()); + queuedTrack.Completed += () => onTrackCompleted(current); + + CurrentTrack = queuedTrack; + + // At this point we may potentially be in an async context from tests. This is extremely dangerous but we have to make do for now. + // CurrentTrack is immediately updated above for situations where a immediate knowledge about the new track is required, + // but the mutation of the hierarchy is scheduled to avoid exceptions. + Schedule(() => + { + lastTrack.VolumeTo(0, 500, Easing.Out).Expire(); + + if (queuedTrack == CurrentTrack) + { + AddInternal(queuedTrack); + queuedTrack.VolumeTo(0).Then().VolumeTo(1, 300, Easing.Out); + } + else + { + // If the track has changed since the call to changeTrack, it is safe to dispose the + // queued track rather than consume it. + queuedTrack.Dispose(); + } + }); + } + + private void onTrackCompleted(WorkingBeatmap workingBeatmap) + { + // the source of track completion is the audio thread, so the beatmap may have changed before firing. + if (current != workingBeatmap) return; - track.ResetSpeedAdjustments(); - - foreach (var mod in mods.Value.OfType<IApplicableToClock>()) - mod.ApplyToClock(track); + if (!CurrentTrack.Looping && !beatmap.Disabled) + NextTrack(); } - private Action pendingBeatmapSwitch; + private bool allowRateAdjustments; - private void updateDisplay(WorkingBeatmap beatmap, TransformDirection direction) + /// <summary> + /// Whether mod rate adjustments are allowed to be applied. + /// </summary> + public bool AllowRateAdjustments { - // avoid using scheduler as our scheduler may not be run for a long time, holding references to beatmaps. - pendingBeatmapSwitch = delegate + get => allowRateAdjustments; + set { - // todo: this can likely be replaced with WorkingBeatmap.GetBeatmapAsync() - Task.Run(() => - { - if (beatmap?.Beatmap == null) //this is not needed if a placeholder exists - { - title.Text = @"Nothing to play"; - artist.Text = @"Nothing to play"; - } - else - { - BeatmapMetadata metadata = beatmap.Metadata; - title.Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)); - artist.Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)); - } - }); + if (allowRateAdjustments == value) + return; - LoadComponentAsync(new Background(beatmap) { Depth = float.MaxValue }, newBackground => - { - switch (direction) - { - case TransformDirection.Next: - newBackground.Position = new Vector2(400, 0); - newBackground.MoveToX(0, 500, Easing.OutCubic); - background.MoveToX(-400, 500, Easing.OutCubic); - break; - - case TransformDirection.Prev: - newBackground.Position = new Vector2(-400, 0); - newBackground.MoveToX(0, 500, Easing.OutCubic); - background.MoveToX(400, 500, Easing.OutCubic); - break; - } - - background.Expire(); - background = newBackground; - - playerContainer.Add(newBackground); - }); - }; - } - - protected override void PopIn() - { - base.PopIn(); - - this.FadeIn(transition_length, Easing.OutQuint); - dragContainer.ScaleTo(1, transition_length, Easing.OutElastic); - } - - protected override void PopOut() - { - base.PopOut(); - - this.FadeOut(transition_length, Easing.OutQuint); - dragContainer.ScaleTo(0.9f, transition_length, Easing.OutQuint); - } - - private enum TransformDirection - { - None, - Next, - Prev - } - - private class MusicIconButton : IconButton - { - public MusicIconButton() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - HoverColour = colours.YellowDark.Opacity(0.6f); - FlashColour = colours.Yellow; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - // works with AutoSizeAxes above to make buttons autosize with the scale animation. - Content.AutoSizeAxes = Axes.None; - Content.Size = new Vector2(DEFAULT_BUTTON_SIZE); - } - } - - private class Background : BufferedContainer - { - private readonly Sprite sprite; - private readonly WorkingBeatmap beatmap; - - public Background(WorkingBeatmap beatmap = null) - { - this.beatmap = beatmap; - CacheDrawnFrameBuffer = true; - Depth = float.MaxValue; - RelativeSizeAxes = Axes.Both; - - Children = new Drawable[] - { - sprite = new Sprite - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(150), - FillMode = FillMode.Fill, - }, - new Box - { - RelativeSizeAxes = Axes.X, - Height = bottom_black_area_height, - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - Colour = Color4.Black.Opacity(0.5f) - } - }; - } - - [BackgroundDependencyLoader] - private void load(TextureStore textures) - { - sprite.Texture = beatmap?.Background ?? textures.Get(@"Backgrounds/bg4"); - } - } - - private class DragContainer : Container - { - protected override bool OnDragStart(DragStartEvent e) - { - return true; - } - - protected override bool OnDrag(DragEvent e) - { - Vector2 change = e.MousePosition - e.MouseDownPosition; - - // Diminish the drag distance as we go further to simulate "rubber band" feeling. - change *= change.Length <= 0 ? 0 : (float)Math.Pow(change.Length, 0.7f) / change.Length; - - this.MoveTo(change); - return true; - } - - protected override bool OnDragEnd(DragEndEvent e) - { - this.MoveTo(Vector2.Zero, 800, Easing.OutElastic); - return base.OnDragEnd(e); + allowRateAdjustments = value; + ResetTrackAdjustments(); } } /// <summary> - /// Play the next random or playlist track. + /// Resets the speed adjustments currently applied on <see cref="CurrentTrack"/> and applies the mod adjustments if <see cref="AllowRateAdjustments"/> is <c>true</c>. /// </summary> - public void NextTrack() => next(); + /// <remarks> + /// Does not reset speed adjustments applied directly to the beatmap track. + /// </remarks> + public void ResetTrackAdjustments() + { + CurrentTrack.ResetSpeedAdjustments(); + + if (allowRateAdjustments) + { + foreach (var mod in mods.Value.OfType<IApplicableToTrack>()) + mod.ApplyToTrack(CurrentTrack); + } + } + } + + public enum TrackChangeDirection + { + None, + Next, + Prev + } + + public enum PreviousTrackResult + { + None, + Restart, + Previous } } diff --git a/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs b/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs new file mode 100644 index 0000000000..0f177f151a --- /dev/null +++ b/osu.Game/Overlays/News/Displays/FrontPageDisplay.cs @@ -0,0 +1,114 @@ +// 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. + +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 osuTK; + +namespace osu.Game.Overlays.News.Displays +{ + public class FrontPageDisplay : CompositeDrawable + { + [Resolved] + private IAPIProvider api { get; set; } + + private FillFlowContainer content; + private ShowMoreButton showMore; + + private GetNewsRequest request; + private Cursor lastCursor; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding + { + Vertical = 20, + Left = 30, + Right = 50 + }; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + content = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10) + }, + showMore = new ShowMoreButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding + { + Top = 15 + }, + Action = performFetch, + 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<NewsCard> + { + 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); + showMore.IsLoading = false; + showMore.Show(); + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + protected override void Dispose(bool isDisposing) + { + request?.Cancel(); + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs new file mode 100644 index 0000000000..599b45fa78 --- /dev/null +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -0,0 +1,165 @@ +// 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. + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Platform; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Overlays.News +{ + public class NewsCard : OsuHoverContainer + { + protected override IEnumerable<Drawable> EffectTargets => new[] { background }; + + private readonly APINewsPost post; + + private Box background; + private TextFlowContainer main; + + public NewsCard(APINewsPost post) + { + this.post = post; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 6; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, GameHost host) + { + if (post.Slug != null) + { + TooltipText = "view in browser"; + Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); + } + + NewsPostBackground bg; + AddRange(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = 160, + Masking = true, + CornerRadius = 6, + Children = new Drawable[] + { + new DelayedLoadWrapper(bg = new NewsPostBackground(post.FirstImage) + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0 + }) + { + RelativeSizeAxes = Axes.Both + }, + new DateContainer(post.PublishedAt) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding + { + Top = 10, + Right = 15 + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Horizontal = 15, + Vertical = 10 + }, + Child = main = new TextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + } + }); + + IdleColour = colourProvider.Background4; + HoverColour = colourProvider.Background3; + + bg.OnLoadComplete += d => d.FadeIn(250, Easing.In); + + main.AddParagraph(post.Title, t => t.Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold)); + main.AddParagraph(post.Preview, t => t.Font = OsuFont.GetFont(size: 12)); // Should use sans-serif font + main.AddParagraph("by ", t => t.Font = OsuFont.GetFont(size: 12)); + main.AddText(post.Author, t => t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)); + } + + private class DateContainer : CircularContainer, IHasCustomTooltip + { + public ITooltip GetCustomTooltip() => new DateTooltip(); + + public object TooltipContent => date; + + private readonly DateTimeOffset date; + + public DateContainer(DateTimeOffset date) + { + this.date = date; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AutoSizeAxes = Axes.Both; + Masking = true; + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6.Opacity(0.5f) + }, + new OsuSpriteText + { + Text = date.ToString("d MMM yyyy").ToUpper(), + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), + Margin = new MarginPadding + { + Horizontal = 20, + Vertical = 5 + } + } + }; + } + + protected override bool OnClick(ClickEvent e) => true; // Protects the NewsCard from clicks while hovering DateContainer + } + } +} diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs new file mode 100644 index 0000000000..63174128e7 --- /dev/null +++ b/osu.Game/Overlays/News/NewsHeader.cs @@ -0,0 +1,65 @@ +// 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. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics; + +namespace osu.Game.Overlays.News +{ + public class NewsHeader : BreadcrumbControlOverlayHeader + { + private const string front_page_string = "frontpage"; + + public Action ShowFrontPage; + + private readonly Bindable<string> article = new Bindable<string>(null); + + public NewsHeader() + { + TabControl.AddItem(front_page_string); + + Current.BindValueChanged(e => + { + if (e.NewValue == front_page_string) + ShowFrontPage?.Invoke(); + }); + + article.BindValueChanged(onArticleChanged, true); + } + + public void SetFrontPage() => article.Value = null; + + public void SetArticle(string slug) => article.Value = slug; + + private void onArticleChanged(ValueChangedEvent<string> e) + { + if (e.OldValue != null) + TabControl.RemoveItem(e.OldValue); + + if (e.NewValue != null) + { + TabControl.AddItem(e.NewValue); + Current.Value = e.NewValue; + } + else + { + Current.Value = front_page_string; + } + } + + protected override Drawable CreateBackground() => new OverlayHeaderBackground(@"Headers/news"); + + protected override OverlayTitle CreateTitle() => new NewsHeaderTitle(); + + private class NewsHeaderTitle : OverlayTitle + { + public NewsHeaderTitle() + { + Title = "news"; + Description = "get up-to-date on community happenings"; + IconTexture = "Icons/Hexacons/news"; + } + } + } +} diff --git a/osu.Game/Overlays/News/NewsPostBackground.cs b/osu.Game/Overlays/News/NewsPostBackground.cs new file mode 100644 index 0000000000..386ef7f669 --- /dev/null +++ b/osu.Game/Overlays/News/NewsPostBackground.cs @@ -0,0 +1,37 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Overlays.News +{ + [LongRunningLoad] + public class NewsPostBackground : Sprite + { + private readonly string sourceUrl; + + public NewsPostBackground(string sourceUrl) + { + this.sourceUrl = sourceUrl; + } + + [BackgroundDependencyLoader] + private void load(LargeTextureStore store) + { + Texture = store.Get(createUrl(sourceUrl)); + } + + private string createUrl(string source) + { + if (string.IsNullOrEmpty(source)) + return "Headers/news"; + + if (source.StartsWith('/')) + return "https://osu.ppy.sh" + source; + + return source; + } + } +} diff --git a/osu.Game/Overlays/NewsOverlay.cs b/osu.Game/Overlays/NewsOverlay.cs new file mode 100644 index 0000000000..c8c1db012f --- /dev/null +++ b/osu.Game/Overlays/NewsOverlay.cs @@ -0,0 +1,139 @@ +// 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. + +using System.Threading; +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.UserInterface; +using osu.Game.Overlays.News; +using osu.Game.Overlays.News.Displays; + +namespace osu.Game.Overlays +{ + public class NewsOverlay : FullscreenOverlay<NewsHeader> + { + private readonly Bindable<string> article = new Bindable<string>(null); + + private Container content; + private LoadingLayer loading; + private OverlayScrollContainer scrollFlow; + + public NewsOverlay() + : base(OverlayColourScheme.Purple, new NewsHeader()) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider.Background5, + }, + scrollFlow = new OverlayScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + Header.With(h => + { + h.ShowFrontPage = ShowFrontPage; + }), + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + } + }, + }, + }, + loading = new LoadingLayer(content), + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // should not be run until first pop-in to avoid requesting data before user views. + article.BindValueChanged(onArticleChanged); + } + + private bool displayUpdateRequired = true; + + protected override void PopIn() + { + base.PopIn(); + + if (displayUpdateRequired) + { + article.TriggerChange(); + displayUpdateRequired = false; + } + } + + protected override void PopOutComplete() + { + base.PopOutComplete(); + displayUpdateRequired = true; + } + + public void ShowFrontPage() + { + article.Value = null; + Show(); + } + + public void ShowArticle(string slug) + { + article.Value = slug; + Show(); + } + + private CancellationTokenSource cancellationToken; + + private void onArticleChanged(ValueChangedEvent<string> 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 => + { + content.Child = loaded; + loading.Hide(); + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index 41160d10ec..b5714fbcae 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -16,8 +16,12 @@ using osu.Framework.Threading; namespace osu.Game.Overlays { - public class NotificationOverlay : OsuFocusedOverlayContainer + public class NotificationOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent { + public string IconTexture => "Icons/Hexacons/notification"; + public string Title => "notifications"; + public string Description => "waiting for 'ya"; + private const float width = 320; public const float TRANSITION_LENGTH = 600; diff --git a/osu.Game/Overlays/Notifications/NotificationSection.cs b/osu.Game/Overlays/Notifications/NotificationSection.cs index 17a2d4cf9f..c2a958b65e 100644 --- a/osu.Game/Overlays/Notifications/NotificationSection.cs +++ b/osu.Game/Overlays/Notifications/NotificationSection.cs @@ -84,13 +84,13 @@ namespace osu.Game.Overlays.Notifications new OsuSpriteText { Text = titleText.ToUpperInvariant(), - Font = OsuFont.GetFont(weight: FontWeight.Black) + Font = OsuFont.GetFont(weight: FontWeight.Bold) }, countDrawable = new OsuSpriteText { Text = "3", Colour = colours.Yellow, - Font = OsuFont.GetFont(weight: FontWeight.Black) + Font = OsuFont.GetFont(weight: FontWeight.Bold) }, } }, diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 99836705c4..3105ecd742 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -39,7 +39,7 @@ namespace osu.Game.Overlays.Notifications { base.LoadComplete(); - //we may have received changes before we were displayed. + // we may have received changes before we were displayed. updateState(); } diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs new file mode 100644 index 0000000000..9beb859f28 --- /dev/null +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -0,0 +1,427 @@ +// 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. + +using System; +using System.Threading.Tasks; +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.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.Music; +using osuTK; +using osuTK.Graphics; + +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"; + + private const float player_height = 130; + private const float transition_length = 800; + private const float progress_height = 10; + private const float bottom_black_area_height = 55; + + private Drawable background; + private ProgressBar progressBar; + + private IconButton prevButton; + private IconButton playButton; + private IconButton nextButton; + private IconButton playlistButton; + + private SpriteText title, artist; + + private PlaylistOverlay playlist; + + private Container dragContainer; + private Container playerContainer; + + /// <summary> + /// Provide a source for the toolbar height. + /// </summary> + public Func<float> GetToolbarHeight; + + [Resolved] + private MusicController musicController { get; set; } + + [Resolved] + private Bindable<WorkingBeatmap> beatmap { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + public NowPlayingOverlay() + { + Width = 400; + Margin = new MarginPadding(10); + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + dragContainer = new DragContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + playlist = new PlaylistOverlay + { + RelativeSizeAxes = Axes.X, + Y = player_height + 10, + }, + playerContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = player_height, + Masking = true, + CornerRadius = 5, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(40), + Radius = 5, + }, + Children = new[] + { + background = new Background(), + title = new OsuSpriteText + { + Origin = Anchor.BottomCentre, + Anchor = Anchor.TopCentre, + Position = new Vector2(0, 40), + Font = OsuFont.GetFont(size: 25, italics: true), + Colour = Color4.White, + Text = @"Nothing to play", + }, + artist = new OsuSpriteText + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Position = new Vector2(0, 45), + Font = OsuFont.GetFont(size: 15, weight: FontWeight.Bold, italics: true), + Colour = Color4.White, + Text = @"Nothing to play", + }, + new Container + { + Padding = new MarginPadding { Bottom = progress_height }, + Height = bottom_black_area_height, + RelativeSizeAxes = Axes.X, + Origin = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + Children = new Drawable[] + { + new FillFlowContainer<IconButton> + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Children = new[] + { + prevButton = new MusicIconButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => musicController.PreviousTrack(), + Icon = FontAwesome.Solid.StepBackward, + }, + playButton = new MusicIconButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.4f), + IconScale = new Vector2(1.4f), + Action = () => musicController.TogglePause(), + Icon = FontAwesome.Regular.PlayCircle, + }, + nextButton = new MusicIconButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => musicController.NextTrack(), + Icon = FontAwesome.Solid.StepForward, + }, + } + }, + playlistButton = new MusicIconButton + { + Origin = Anchor.Centre, + Anchor = Anchor.CentreRight, + Position = new Vector2(-bottom_black_area_height / 2, 0), + Icon = FontAwesome.Solid.Bars, + Action = () => playlist.ToggleVisibility(), + }, + } + }, + progressBar = new HoverableProgressBar + { + Origin = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + Height = progress_height / 2, + FillColour = colours.Yellow, + BackgroundColour = colours.YellowDarker.Opacity(0.5f), + OnSeek = musicController.SeekTo + } + }, + }, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + playlist.BeatmapSets.BindTo(musicController.BeatmapSets); + playlist.State.BindValueChanged(s => playlistButton.FadeColour(s.NewValue == Visibility.Visible ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); + + beatmap.BindDisabledChanged(beatmapDisabledChanged, true); + + musicController.TrackChanged += trackChanged; + trackChanged(beatmap.Value); + } + + protected override void PopIn() + { + base.PopIn(); + + this.FadeIn(transition_length, Easing.OutQuint); + dragContainer.ScaleTo(1, transition_length, Easing.OutElastic); + } + + protected override void PopOut() + { + base.PopOut(); + + this.FadeOut(transition_length, Easing.OutQuint); + dragContainer.ScaleTo(0.9f, transition_length, Easing.OutQuint); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + Height = dragContainer.Height; + dragContainer.Padding = new MarginPadding { Top = GetToolbarHeight?.Invoke() ?? 0 }; + } + + protected override void Update() + { + base.Update(); + + if (pendingBeatmapSwitch != null) + { + pendingBeatmapSwitch(); + pendingBeatmapSwitch = null; + } + + var track = musicController.CurrentTrack; + + if (!track.IsDummyDevice) + { + progressBar.EndTime = track.Length; + progressBar.CurrentTime = track.CurrentTime; + + playButton.Icon = track.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; + } + else + { + progressBar.CurrentTime = 0; + progressBar.EndTime = 1; + playButton.Icon = FontAwesome.Regular.PlayCircle; + } + } + + private Action pendingBeatmapSwitch; + + private void trackChanged(WorkingBeatmap beatmap, TrackChangeDirection direction = TrackChangeDirection.None) + { + // avoid using scheduler as our scheduler may not be run for a long time, holding references to beatmaps. + pendingBeatmapSwitch = delegate + { + // todo: this can likely be replaced with WorkingBeatmap.GetBeatmapAsync() + Task.Run(() => + { + if (beatmap?.Beatmap == null) // this is not needed if a placeholder exists + { + title.Text = @"Nothing to play"; + artist.Text = @"Nothing to play"; + } + else + { + BeatmapMetadata metadata = beatmap.Metadata; + title.Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)); + artist.Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)); + } + }); + + LoadComponentAsync(new Background(beatmap) { Depth = float.MaxValue }, newBackground => + { + switch (direction) + { + case TrackChangeDirection.Next: + newBackground.Position = new Vector2(400, 0); + newBackground.MoveToX(0, 500, Easing.OutCubic); + background.MoveToX(-400, 500, Easing.OutCubic); + break; + + case TrackChangeDirection.Prev: + newBackground.Position = new Vector2(-400, 0); + newBackground.MoveToX(0, 500, Easing.OutCubic); + background.MoveToX(400, 500, Easing.OutCubic); + break; + } + + background.Expire(); + background = newBackground; + + playerContainer.Add(newBackground); + }); + }; + } + + private void beatmapDisabledChanged(bool disabled) + { + if (disabled) + playlist.Hide(); + + prevButton.Enabled.Value = !disabled; + nextButton.Enabled.Value = !disabled; + playlistButton.Enabled.Value = !disabled; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (musicController != null) + musicController.TrackChanged -= trackChanged; + } + + private class MusicIconButton : IconButton + { + public MusicIconButton() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + HoverColour = colours.YellowDark.Opacity(0.6f); + FlashColour = colours.Yellow; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // works with AutoSizeAxes above to make buttons autosize with the scale animation. + Content.AutoSizeAxes = Axes.None; + Content.Size = new Vector2(DEFAULT_BUTTON_SIZE); + } + } + + private class Background : BufferedContainer + { + private readonly Sprite sprite; + private readonly WorkingBeatmap beatmap; + + public Background(WorkingBeatmap beatmap = null) + { + this.beatmap = beatmap; + + Depth = float.MaxValue; + RelativeSizeAxes = Axes.Both; + + CacheDrawnFrameBuffer = true; + + Children = new Drawable[] + { + sprite = new Sprite + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(150), + FillMode = FillMode.Fill, + }, + new Box + { + RelativeSizeAxes = Axes.X, + Height = bottom_black_area_height, + Origin = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + Colour = Color4.Black.Opacity(0.5f) + } + }; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + sprite.Texture = beatmap?.Background ?? textures.Get(@"Backgrounds/bg4"); + } + } + + private class DragContainer : Container + { + protected override bool OnDragStart(DragStartEvent e) + { + return true; + } + + protected override void OnDrag(DragEvent e) + { + Vector2 change = e.MousePosition - e.MouseDownPosition; + + // Diminish the drag distance as we go further to simulate "rubber band" feeling. + change *= change.Length <= 0 ? 0 : MathF.Pow(change.Length, 0.7f) / change.Length; + + this.MoveTo(change); + } + + protected override void OnDragEnd(DragEndEvent e) + { + this.MoveTo(Vector2.Zero, 800, Easing.OutElastic); + base.OnDragEnd(e); + } + } + + private class HoverableProgressBar : ProgressBar + { + protected override bool OnHover(HoverEvent e) + { + this.ResizeHeightTo(progress_height, 500, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + this.ResizeHeightTo(progress_height / 2, 500, Easing.OutQuint); + base.OnHoverLost(e); + } + } + } +} diff --git a/osu.Game/Overlays/OSD/Toast.cs b/osu.Game/Overlays/OSD/Toast.cs new file mode 100644 index 0000000000..1497ca8fa8 --- /dev/null +++ b/osu.Game/Overlays/OSD/Toast.cs @@ -0,0 +1,84 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.OSD +{ + public abstract class Toast : Container + { + private const int toast_minimum_width = 240; + + private readonly Container content; + protected override Container<Drawable> Content => content; + + protected readonly OsuSpriteText ValueText; + + protected Toast(string description, string value, string shortcut) + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + // A toast's height is decided (and transformed) by the containing OnScreenDisplay. + RelativeSizeAxes = Axes.Y; + AutoSizeAxes = Axes.X; + + InternalChildren = new Drawable[] + { + new Container // this container exists just to set a minimum width for the toast + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = toast_minimum_width + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.7f + }, + content = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Padding = new MarginPadding(10), + Name = "Description", + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), + Spacing = new Vector2(1, 0), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = description.ToUpperInvariant() + }, + ValueText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light), + Padding = new MarginPadding { Left = 10, Right = 10 }, + Name = "Value", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = value + }, + new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Name = "Shortcut", + Alpha = 0.3f, + Margin = new MarginPadding { Bottom = 15 }, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Text = string.IsNullOrEmpty(shortcut) ? "NO KEY BOUND" : shortcut.ToUpperInvariant() + }, + }; + } + } +} diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs new file mode 100644 index 0000000000..8e8a99a0a7 --- /dev/null +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -0,0 +1,147 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Configuration.Tracking; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.OSD +{ + public class TrackedSettingToast : Toast + { + private const int lights_bottom_margin = 40; + + public TrackedSettingToast(SettingDescription description) + : base(description.Name, description.Value, description.Shortcut) + { + FillFlowContainer<OptionLight> optionLights; + + Children = new Drawable[] + { + new Container + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding { Bottom = lights_bottom_margin }, + Children = new Drawable[] + { + optionLights = new FillFlowContainer<OptionLight> + { + Margin = new MarginPadding { Bottom = 5 }, + Spacing = new Vector2(5, 0), + Direction = FillDirection.Horizontal, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both + }, + } + } + }; + + int optionCount = 0; + int selectedOption = -1; + + switch (description.RawValue) + { + case bool val: + optionCount = 1; + if (val) selectedOption = 0; + break; + + case Enum _: + var values = Enum.GetValues(description.RawValue.GetType()); + optionCount = values.Length; + selectedOption = Convert.ToInt32(description.RawValue); + break; + } + + ValueText.Origin = optionCount > 0 ? Anchor.BottomCentre : Anchor.Centre; + + for (int i = 0; i < optionCount; i++) + optionLights.Add(new OptionLight { Glowing = i == selectedOption }); + } + + private class OptionLight : Container + { + private Color4 glowingColour, idleColour; + + private const float transition_speed = 300; + + private const float glow_strength = 0.4f; + + private readonly Box fill; + + public OptionLight() + { + Children = new[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 1, + }, + }; + } + + private bool glowing; + + public bool Glowing + { + set + { + glowing = value; + if (!IsLoaded) return; + + updateGlow(); + } + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + fill.Colour = idleColour = Color4.White.Opacity(0.4f); + glowingColour = Color4.White; + + Size = new Vector2(25, 5); + + Masking = true; + CornerRadius = 3; + + EdgeEffect = new EdgeEffectParameters + { + Colour = colours.BlueDark.Opacity(glow_strength), + Type = EdgeEffectType.Glow, + Radius = 8, + }; + } + + protected override void LoadComplete() + { + updateGlow(); + FinishTransforms(true); + } + + private void updateGlow() + { + if (glowing) + { + fill.FadeColour(glowingColour, transition_speed, Easing.OutQuint); + FadeEdgeEffectTo(glow_strength, transition_speed, Easing.OutQuint); + } + else + { + FadeEdgeEffectTo(0, transition_speed, Easing.OutQuint); + fill.FadeColour(idleColour, transition_speed, Easing.OutQuint); + } + } + } + } +} diff --git a/osu.Game/Overlays/OnScreenDisplay.cs b/osu.Game/Overlays/OnScreenDisplay.cs index 88a1edddc5..e6708093c4 100644 --- a/osu.Game/Overlays/OnScreenDisplay.cs +++ b/osu.Game/Overlays/OnScreenDisplay.cs @@ -8,34 +8,25 @@ using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; using osuTK; -using osuTK.Graphics; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Transforms; using osu.Framework.Threading; using osu.Game.Configuration; -using osu.Game.Graphics.Sprites; +using osu.Game.Overlays.OSD; namespace osu.Game.Overlays { + /// <summary> + /// An on-screen display which automatically tracks and displays toast notifications for <seealso cref="TrackedSettings"/>. + /// Can also display custom content via <see cref="Display(Toast)"/> + /// </summary> public class OnScreenDisplay : Container { private readonly Container box; - private readonly SpriteText textLine1; - private readonly SpriteText textLine2; - private readonly SpriteText textLine3; - private const float height = 110; - private const float height_notext = 98; private const float height_contracted = height * 0.9f; - private readonly FillFlowContainer<OptionLight> optionLights; - public OnScreenDisplay() { RelativeSizeAxes = Axes.Both; @@ -52,64 +43,6 @@ namespace osu.Game.Overlays Height = height_contracted, Alpha = 0, CornerRadius = 20, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.7f, - }, - new Container // purely to add a minimum width - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 240, - RelativeSizeAxes = Axes.Y, - }, - textLine1 = new OsuSpriteText - { - Padding = new MarginPadding(10), - Font = OsuFont.GetFont(size: 14, weight: FontWeight.Black), - Spacing = new Vector2(1, 0), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - textLine2 = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light), - Padding = new MarginPadding { Left = 10, Right = 10 }, - Anchor = Anchor.Centre, - Origin = Anchor.BottomCentre, - }, - new FillFlowContainer - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - optionLights = new FillFlowContainer<OptionLight> - { - Padding = new MarginPadding { Top = 20, Bottom = 5 }, - Spacing = new Vector2(5, 0), - Direction = FillDirection.Horizontal, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both - }, - textLine3 = new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding { Bottom = 15 }, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Alpha = 0.3f, - }, - } - } - } }, }; } @@ -142,7 +75,7 @@ namespace osu.Game.Overlays return; configManager.LoadInto(trackedSettings); - trackedSettings.SettingChanged += display; + trackedSettings.SettingChanged += displayTrackedSettingChange; trackedConfigManagers.Add((source, configManager), trackedSettings); } @@ -153,7 +86,7 @@ namespace osu.Game.Overlays /// <param name="source">The object that registered the <see cref="ConfigManager{T}"/> to be tracked.</param> /// <param name="configManager">The <see cref="ConfigManager{T}"/> that is being tracked.</param> /// <exception cref="ArgumentNullException">If <paramref name="configManager"/> is null.</exception> - /// <exception cref="InvalidOperationException">If <paramref name="configManager"/> is not being tracked from the same <see cref="source"/>.</exception> + /// <exception cref="InvalidOperationException">If <paramref name="configManager"/> is not being tracked from the same <paramref name="source"/>.</exception> public void StopTracking(object source, ITrackableConfigManager configManager) { if (configManager == null) throw new ArgumentNullException(nameof(configManager)); @@ -162,56 +95,23 @@ namespace osu.Game.Overlays return; existing.Unload(); - existing.SettingChanged -= display; + existing.SettingChanged -= displayTrackedSettingChange; trackedConfigManagers.Remove((source, configManager)); } - private void display(SettingDescription description) + /// <summary> + /// Displays the provided <see cref="Toast"/> temporarily. + /// </summary> + /// <param name="toast"></param> + public void Display(Toast toast) { - Schedule(() => - { - textLine1.Text = description.Name.ToUpperInvariant(); - textLine2.Text = description.Value; - textLine3.Text = description.Shortcut.ToUpperInvariant(); - - if (string.IsNullOrEmpty(textLine3.Text)) - textLine3.Text = "NO KEY BOUND"; - - DisplayTemporarily(box); - - int optionCount = 0; - int selectedOption = -1; - - switch (description.RawValue) - { - case bool val: - optionCount = 1; - if (val) selectedOption = 0; - break; - - case Enum _: - var values = Enum.GetValues(description.RawValue.GetType()); - optionCount = values.Length; - selectedOption = Convert.ToInt32(description.RawValue); - break; - } - - textLine2.Origin = optionCount > 0 ? Anchor.BottomCentre : Anchor.Centre; - textLine2.Y = optionCount > 0 ? 0 : 5; - - if (optionLights.Children.Count != optionCount) - { - optionLights.Clear(); - for (int i = 0; i < optionCount; i++) - optionLights.Add(new OptionLight()); - } - - for (int i = 0; i < optionCount; i++) - optionLights.Children[i].Glowing = i == selectedOption; - }); + box.Child = toast; + DisplayTemporarily(box); } + private void displayTrackedSettingChange(SettingDescription description) => Schedule(() => Display(new TrackedSettingToast(description))); + private TransformSequence<Drawable> fadeIn; private ScheduledDelegate fadeOut; @@ -236,80 +136,5 @@ namespace osu.Game.Overlays b => b.ResizeHeightTo(height_contracted, 1500, Easing.InQuint)); }, 500); } - - private class OptionLight : Container - { - private Color4 glowingColour, idleColour; - - private const float transition_speed = 300; - - private const float glow_strength = 0.4f; - - private readonly Box fill; - - public OptionLight() - { - Children = new[] - { - fill = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 1, - }, - }; - } - - private bool glowing; - - public bool Glowing - { - set - { - glowing = value; - if (!IsLoaded) return; - - updateGlow(); - } - } - - private void updateGlow() - { - if (glowing) - { - fill.FadeColour(glowingColour, transition_speed, Easing.OutQuint); - FadeEdgeEffectTo(glow_strength, transition_speed, Easing.OutQuint); - } - else - { - FadeEdgeEffectTo(0, transition_speed, Easing.OutQuint); - fill.FadeColour(idleColour, transition_speed, Easing.OutQuint); - } - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - fill.Colour = idleColour = Color4.White.Opacity(0.4f); - glowingColour = Color4.White; - - Size = new Vector2(25, 5); - - Masking = true; - CornerRadius = 3; - - EdgeEffect = new EdgeEffectParameters - { - Colour = colours.BlueDark.Opacity(glow_strength), - Type = EdgeEffectType.Glow, - Radius = 8, - }; - } - - protected override void LoadComplete() - { - updateGlow(); - FinishTransforms(true); - } - } } } diff --git a/osu.Game/Overlays/OverlayColourProvider.cs b/osu.Game/Overlays/OverlayColourProvider.cs new file mode 100644 index 0000000000..9816f313ad --- /dev/null +++ b/osu.Game/Overlays/OverlayColourProvider.cs @@ -0,0 +1,80 @@ +// 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. + +using System; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays +{ + public class OverlayColourProvider + { + private readonly OverlayColourScheme colourScheme; + + public OverlayColourProvider(OverlayColourScheme colourScheme) + { + this.colourScheme = colourScheme; + } + + public Color4 Highlight1 => getColour(1, 0.7f); + public Color4 Content1 => getColour(0.4f, 1); + public Color4 Content2 => getColour(0.4f, 0.9f); + public Color4 Light1 => getColour(0.4f, 0.8f); + public Color4 Light2 => getColour(0.4f, 0.75f); + public Color4 Light3 => getColour(0.4f, 0.7f); + public Color4 Light4 => getColour(0.4f, 0.5f); + public Color4 Dark1 => getColour(0.2f, 0.35f); + public Color4 Dark2 => getColour(0.2f, 0.3f); + public Color4 Dark3 => getColour(0.2f, 0.25f); + public Color4 Dark4 => getColour(0.2f, 0.2f); + public Color4 Dark5 => getColour(0.2f, 0.15f); + public Color4 Dark6 => getColour(0.2f, 0.1f); + public Color4 Foreground1 => getColour(0.1f, 0.6f); + public Color4 Background1 => getColour(0.1f, 0.4f); + public Color4 Background2 => getColour(0.1f, 0.3f); + public Color4 Background3 => getColour(0.1f, 0.25f); + public Color4 Background4 => getColour(0.1f, 0.2f); + public Color4 Background5 => getColour(0.1f, 0.15f); + public Color4 Background6 => getColour(0.1f, 0.1f); + + private Color4 getColour(float saturation, float lightness) => Color4.FromHsl(new Vector4(getBaseHue(colourScheme), saturation, lightness, 1)); + + // See https://github.com/ppy/osu-web/blob/4218c288292d7c810b619075471eaea8bbb8f9d8/app/helpers.php#L1463 + private static float getBaseHue(OverlayColourScheme colourScheme) + { + switch (colourScheme) + { + default: + throw new ArgumentException($@"{colourScheme} colour scheme does not provide a hue value in {nameof(getBaseHue)}."); + + case OverlayColourScheme.Red: + return 0; + + case OverlayColourScheme.Pink: + return 333 / 360f; + + case OverlayColourScheme.Orange: + return 46 / 360f; + + case OverlayColourScheme.Green: + return 115 / 360f; + + case OverlayColourScheme.Purple: + return 255 / 360f; + + case OverlayColourScheme.Blue: + return 200 / 360f; + } + } + } + + public enum OverlayColourScheme + { + Red, + Pink, + Orange, + Green, + Purple, + Blue + } +} diff --git a/osu.Game/Overlays/OverlayHeader.cs b/osu.Game/Overlays/OverlayHeader.cs index 2e032db2ba..fed1e57686 100644 --- a/osu.Game/Overlays/OverlayHeader.cs +++ b/osu.Game/Overlays/OverlayHeader.cs @@ -1,70 +1,121 @@ // 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. +using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.UserInterface; +using osu.Framework.Graphics.Shapes; +using osuTK.Graphics; namespace osu.Game.Overlays { public abstract class OverlayHeader : Container { - protected readonly OverlayHeaderTabControl TabControl; + public OverlayTitle Title { get; } - private const float cover_height = 150; - private const float cover_info_height = 75; + private float contentSidePadding; + + /// <summary> + /// Horizontal padding of the header content. + /// </summary> + protected float ContentSidePadding + { + get => contentSidePadding; + set + { + contentSidePadding = value; + content.Padding = new MarginPadding + { + Horizontal = value + }; + } + } + + private readonly Box titleBackground; + private readonly Container content; + + protected readonly FillFlowContainer HeaderInfo; protected OverlayHeader() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Children = new Drawable[] + Add(new FillFlowContainer { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new[] { - RelativeSizeAxes = Axes.X, - Height = cover_height, - Masking = true, - Child = CreateBackground() - }, - new Container - { - Margin = new MarginPadding { Left = UserProfileOverlay.CONTENT_X_MARGIN }, - Y = cover_height, - Height = cover_info_height, - RelativeSizeAxes = Axes.X, - Anchor = Anchor.TopLeft, - Origin = Anchor.BottomLeft, - Depth = -float.MaxValue, - Children = new Drawable[] + HeaderInfo = new FillFlowContainer { - CreateTitle().With(t => t.X = -ScreenTitle.ICON_WIDTH), - TabControl = new OverlayHeaderTabControl + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Depth = -float.MaxValue, + Children = new[] { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = cover_info_height - 30, - Margin = new MarginPadding { Left = -UserProfileOverlay.CONTENT_X_MARGIN }, - Padding = new MarginPadding { Left = UserProfileOverlay.CONTENT_X_MARGIN } + CreateBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + titleBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Gray, + }, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + Title = CreateTitle().With(title => + { + title.Anchor = Anchor.CentreLeft; + title.Origin = Anchor.CentreLeft; + }), + CreateTitleContent().With(content => + { + content.Anchor = Anchor.CentreRight; + content.Origin = Anchor.CentreRight; + }) + } + } + } + }, } - } - }, - new Container - { - Margin = new MarginPadding { Top = cover_height }, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = CreateContent() + }, + CreateContent() } - }; + }); + + ContentSidePadding = 50; } - protected abstract Drawable CreateBackground(); + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + titleBackground.Colour = colourProvider.Dark5; + } - protected abstract Drawable CreateContent(); + [NotNull] + protected virtual Drawable CreateContent() => Empty(); - protected abstract ScreenTitle CreateTitle(); + [NotNull] + protected virtual Drawable CreateBackground() => Empty(); + + /// <summary> + /// Creates a <see cref="Drawable"/> on the opposite side of the <see cref="OverlayTitle"/>. Used mostly to create <see cref="OverlayRulesetSelector"/>. + /// </summary> + [NotNull] + protected virtual Drawable CreateTitleContent() => Empty(); + + protected abstract OverlayTitle CreateTitle(); } } diff --git a/osu.Game/Overlays/OverlayHeaderBackground.cs b/osu.Game/Overlays/OverlayHeaderBackground.cs new file mode 100644 index 0000000000..2fef593285 --- /dev/null +++ b/osu.Game/Overlays/OverlayHeaderBackground.cs @@ -0,0 +1,43 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; + +namespace osu.Game.Overlays +{ + public class OverlayHeaderBackground : CompositeDrawable + { + public OverlayHeaderBackground(string textureName) + { + Height = 80; + RelativeSizeAxes = Axes.X; + Masking = true; + InternalChild = new Background(textureName); + } + + private class Background : Sprite + { + private readonly string textureName; + + public Background(string textureName) + { + this.textureName = textureName; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + RelativeSizeAxes = Axes.Both; + FillMode = FillMode.Fill; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Texture = textures.Get(textureName); + } + } + } +} diff --git a/osu.Game/Overlays/OverlayHeaderTabControl.cs b/osu.Game/Overlays/OverlayHeaderTabControl.cs deleted file mode 100644 index 5b56771dc1..0000000000 --- a/osu.Game/Overlays/OverlayHeaderTabControl.cs +++ /dev/null @@ -1,24 +0,0 @@ -// 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. - -using osu.Framework.Graphics.UserInterface; - -namespace osu.Game.Overlays -{ - public class OverlayHeaderTabControl : OverlayTabControl<string> - { - protected override TabItem<string> CreateTabItem(string value) => new OverlayHeaderTabItem(value) - { - AccentColour = AccentColour, - }; - - private class OverlayHeaderTabItem : OverlayTabItem<string> - { - public OverlayHeaderTabItem(string value) - : base(value) - { - Text.Text = value; - } - } - } -} diff --git a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs new file mode 100644 index 0000000000..87b9d89d4d --- /dev/null +++ b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs @@ -0,0 +1,106 @@ +// 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. + +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Allocation; +using osuTK.Graphics; +using osu.Framework.Graphics.Cursor; + +namespace osu.Game.Overlays +{ + public class OverlayPanelDisplayStyleControl : OsuTabControl<OverlayPanelDisplayStyle> + { + protected override Dropdown<OverlayPanelDisplayStyle> CreateDropdown() => null; + + protected override TabItem<OverlayPanelDisplayStyle> CreateTabItem(OverlayPanelDisplayStyle value) => new PanelDisplayTabItem(value); + + protected override bool AddEnumEntriesAutomatically => false; + + public OverlayPanelDisplayStyleControl() + { + AutoSizeAxes = Axes.Both; + + AddTabItem(new PanelDisplayTabItem(OverlayPanelDisplayStyle.Card) + { + Icon = FontAwesome.Solid.Square + }); + AddTabItem(new PanelDisplayTabItem(OverlayPanelDisplayStyle.List) + { + Icon = FontAwesome.Solid.Bars + }); + AddTabItem(new PanelDisplayTabItem(OverlayPanelDisplayStyle.Brick) + { + Icon = FontAwesome.Solid.Th + }); + } + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal + }; + + private class PanelDisplayTabItem : TabItem<OverlayPanelDisplayStyle>, IHasTooltip + { + public IconUsage Icon + { + set => icon.Icon = value; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + public string TooltipText => $@"{Value} view"; + + private readonly SpriteIcon icon; + + public PanelDisplayTabItem(OverlayPanelDisplayStyle value) + : base(value) + { + Size = new Vector2(11); + AddRange(new Drawable[] + { + icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit + }, + new HoverClickSounds() + }); + } + + protected override void OnActivated() => updateState(); + + protected override void OnDeactivated() => 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() => icon.Colour = Active.Value || IsHovered ? colourProvider.Light1 : Color4.White; + } + } + + public enum OverlayPanelDisplayStyle + { + Card, + List, + Brick + } +} diff --git a/osu.Game/Overlays/OverlayRulesetSelector.cs b/osu.Game/Overlays/OverlayRulesetSelector.cs new file mode 100644 index 0000000000..8c44157f78 --- /dev/null +++ b/osu.Game/Overlays/OverlayRulesetSelector.cs @@ -0,0 +1,28 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Rulesets; +using osuTK; + +namespace osu.Game.Overlays +{ + public class OverlayRulesetSelector : RulesetSelector + { + public OverlayRulesetSelector() + { + AutoSizeAxes = Axes.Both; + } + + protected override TabItem<RulesetInfo> CreateTabItem(RulesetInfo value) => new OverlayRulesetTabItem(value); + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(20, 0), + }; + } +} diff --git a/osu.Game/Overlays/OverlayRulesetTabItem.cs b/osu.Game/Overlays/OverlayRulesetTabItem.cs new file mode 100644 index 0000000000..9d4afc94d1 --- /dev/null +++ b/osu.Game/Overlays/OverlayRulesetTabItem.cs @@ -0,0 +1,100 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osuTK.Graphics; +using osuTK; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; + +namespace osu.Game.Overlays +{ + public class OverlayRulesetTabItem : TabItem<RulesetInfo> + { + private Color4 accentColour; + + protected virtual Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + text.FadeColour(value, 120, Easing.OutQuint); + } + } + + protected override Container<Drawable> Content { get; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private readonly OsuSpriteText text; + + public OverlayRulesetTabItem(RulesetInfo value) + : base(value) + { + AutoSizeAxes = Axes.Both; + + AddRangeInternal(new Drawable[] + { + Content = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + Child = text = new OsuSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Text = value.Name, + Font = OsuFont.GetFont(size: 14), + ShadowColour = Color4.Black.Opacity(0.75f) + } + }, + new HoverClickSounds() + }); + + Enabled.Value = true; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Enabled.BindValueChanged(_ => updateState(), true); + } + + public override bool PropagatePositionalInputSubTree => Enabled.Value && !Active.Value && base.PropagatePositionalInputSubTree; + + protected override bool OnHover(HoverEvent e) + { + base.OnHover(e); + updateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + updateState(); + } + + protected override void OnActivated() => updateState(); + + protected override void OnDeactivated() => updateState(); + + private void updateState() + { + text.Font = text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.Medium); + AccentColour = Enabled.Value ? getActiveColour() : colourProvider.Foreground1; + } + + private Color4 getActiveColour() => IsHovered || Active.Value ? Color4.White : colourProvider.Highlight1; + } +} diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs new file mode 100644 index 0000000000..b67d5db1a4 --- /dev/null +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -0,0 +1,149 @@ +// 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. + +using System.Collections.Generic; +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.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays +{ + /// <summary> + /// <see cref="OsuScrollContainer"/> which provides <see cref="ScrollToTopButton"/>. Mostly used in <see cref="FullscreenOverlay{T}"/>. + /// </summary> + public class OverlayScrollContainer : OsuScrollContainer + { + /// <summary> + /// Scroll position at which the <see cref="ScrollToTopButton"/> will be shown. + /// </summary> + private const int button_scroll_position = 200; + + protected readonly ScrollToTopButton Button; + + public OverlayScrollContainer() + { + AddInternal(Button = new ScrollToTopButton + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(20), + Action = () => + { + ScrollToStart(); + Button.State = Visibility.Hidden; + } + }); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (ScrollContent.DrawHeight + button_scroll_position < DrawHeight) + { + Button.State = Visibility.Hidden; + return; + } + + Button.State = Target > button_scroll_position ? Visibility.Visible : Visibility.Hidden; + } + + public class ScrollToTopButton : OsuHoverContainer + { + private const int fade_duration = 500; + + private Visibility state; + + public Visibility State + { + get => state; + set + { + if (value == state) + return; + + state = value; + Enabled.Value = state == Visibility.Visible; + this.FadeTo(state == Visibility.Visible ? 1 : 0, fade_duration, Easing.OutQuint); + } + } + + protected override IEnumerable<Drawable> EffectTargets => new[] { background }; + + private Color4 flashColour; + + private readonly Container content; + private readonly Box background; + + public ScrollToTopButton() + { + Size = new Vector2(50); + Alpha = 0; + Add(content = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0f, 1f), + Radius = 3f, + Colour = Color4.Black.Opacity(0.25f), + }, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(15), + Icon = FontAwesome.Solid.ChevronUp + } + } + }); + + TooltipText = "Scroll to top"; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Background6; + HoverColour = colourProvider.Background5; + flashColour = colourProvider.Light1; + } + + protected override bool OnClick(ClickEvent e) + { + background.FlashColour(flashColour, 800, Easing.OutQuint); + return base.OnClick(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + content.ScaleTo(0.75f, 2000, Easing.OutQuint); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + content.ScaleTo(1, 1000, Easing.OutElastic); + base.OnMouseUp(e); + } + } + } +} diff --git a/osu.Game/Overlays/OverlaySortTabControl.cs b/osu.Game/Overlays/OverlaySortTabControl.cs new file mode 100644 index 0000000000..b2212336ef --- /dev/null +++ b/osu.Game/Overlays/OverlaySortTabControl.cs @@ -0,0 +1,178 @@ +// 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. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osuTK; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Bindables; +using osu.Framework.Allocation; +using osu.Game.Graphics.Sprites; +using osuTK.Graphics; +using osu.Game.Overlays.Comments; +using JetBrains.Annotations; +using System; +using osu.Framework.Extensions; + +namespace osu.Game.Overlays +{ + public class OverlaySortTabControl<T> : CompositeDrawable, IHasCurrentValue<T> + { + private readonly BindableWithCurrent<T> current = new BindableWithCurrent<T>(); + + public Bindable<T> Current + { + get => current.Current; + set => current.Current = value; + } + + public string Title + { + get => text.Text; + set => text.Text = value; + } + + private readonly OsuSpriteText text; + + public OverlaySortTabControl() + { + AutoSizeAxes = Axes.Both; + AddInternal(new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = @"Sort by" + }, + CreateControl().With(c => + { + c.Anchor = Anchor.CentreLeft; + c.Origin = Anchor.CentreLeft; + c.Current = current; + }) + } + }); + } + + [NotNull] + protected virtual SortTabControl CreateControl() => new SortTabControl(); + + protected class SortTabControl : OsuTabControl<T> + { + protected override Dropdown<T> CreateDropdown() => null; + + protected override TabItem<T> CreateTabItem(T value) => new SortTabItem(value); + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + }; + + public SortTabControl() + { + AutoSizeAxes = Axes.Both; + } + } + + protected class SortTabItem : TabItem<T> + { + public SortTabItem(T value) + : base(value) + { + AutoSizeAxes = Axes.Both; + Child = CreateTabButton(value); + } + + [NotNull] + protected virtual TabButton CreateTabButton(T value) => new TabButton(value) + { + Active = { BindTarget = Active } + }; + + protected override void OnActivated() + { + } + + protected override void OnDeactivated() + { + } + } + + protected class TabButton : HeaderButton + { + public readonly BindableBool Active = new BindableBool(); + + protected override Container<Drawable> Content => content; + + protected virtual Color4 ContentColour + { + set => text.Colour = value; + } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private readonly SpriteText text; + private readonly FillFlowContainer content; + + public TabButton(T value) + { + base.Content.Add(content = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Text = (value as Enum)?.GetDescription() ?? value.ToString() + } + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Active.BindValueChanged(_ => UpdateState(), true); + } + + protected override bool OnHover(HoverEvent e) + { + UpdateState(); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) => UpdateState(); + + protected virtual void UpdateState() + { + if (Active.Value || IsHovered) + ShowBackground(); + else + HideBackground(); + + ContentColour = Active.Value && !IsHovered ? colourProvider.Light1 : Color4.White; + + text.Font = text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.SemiBold); + } + } + } +} diff --git a/osu.Game/Overlays/OverlayStreamControl.cs b/osu.Game/Overlays/OverlayStreamControl.cs new file mode 100644 index 0000000000..8b6aca6d5d --- /dev/null +++ b/osu.Game/Overlays/OverlayStreamControl.cs @@ -0,0 +1,56 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics.UserInterface; +using JetBrains.Annotations; + +namespace osu.Game.Overlays +{ + public abstract class OverlayStreamControl<T> : TabControl<T> + { + protected OverlayStreamControl() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + public void Populate(List<T> streams) => streams.ForEach(AddItem); + + protected override Dropdown<T> CreateDropdown() => null; + + protected override TabItem<T> CreateTabItem(T value) => CreateStreamItem(value).With(item => + { + item.SelectedItem.BindTo(Current); + }); + + [NotNull] + protected abstract OverlayStreamItem<T> CreateStreamItem(T value); + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + AllowMultiline = true, + }; + + protected override bool OnHover(HoverEvent e) + { + foreach (var streamBadge in TabContainer.Children.OfType<OverlayStreamItem<T>>()) + streamBadge.UserHoveringArea = true; + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + foreach (var streamBadge in TabContainer.Children.OfType<OverlayStreamItem<T>>()) + streamBadge.UserHoveringArea = false; + + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Overlays/OverlayStreamItem.cs b/osu.Game/Overlays/OverlayStreamItem.cs new file mode 100644 index 0000000000..7f8559e7de --- /dev/null +++ b/osu.Game/Overlays/OverlayStreamItem.cs @@ -0,0 +1,140 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Allocation; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Overlays +{ + public abstract class OverlayStreamItem<T> : TabItem<T> + { + public readonly Bindable<T> SelectedItem = new Bindable<T>(); + + private bool userHoveringArea; + + public bool UserHoveringArea + { + set + { + if (value == userHoveringArea) + return; + + userHoveringArea = value; + updateState(); + } + } + + private FillFlowContainer<SpriteText> text; + private ExpandingBar expandingBar; + + protected OverlayStreamItem(T value) + : base(value) + { + Height = 60; + Width = 100; + Padding = new MarginPadding(5); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OsuColour colours) + { + AddRange(new Drawable[] + { + text = new FillFlowContainer<SpriteText> + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Margin = new MarginPadding { Top = 6 }, + Children = new[] + { + new OsuSpriteText + { + Text = MainText, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + }, + new OsuSpriteText + { + Text = AdditionalText, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Regular), + }, + new OsuSpriteText + { + Text = InfoText, + Font = OsuFont.GetFont(size: 10), + Colour = colourProvider.Foreground1 + }, + } + }, + expandingBar = new ExpandingBar + { + Anchor = Anchor.TopCentre, + Colour = GetBarColour(colours), + ExpandedSize = 4, + CollapsedSize = 2, + Expanded = true + }, + new HoverClickSounds() + }); + + SelectedItem.BindValueChanged(_ => updateState(), true); + } + + protected abstract string MainText { get; } + + protected abstract string AdditionalText { get; } + + protected virtual string InfoText => string.Empty; + + protected abstract Color4 GetBarColour(OsuColour colours); + + protected override void OnActivated() => updateState(); + + protected override void OnDeactivated() => 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() + { + // highlighted regardless if we are hovered + bool textHighlighted = IsHovered; + bool barExpanded = IsHovered; + + if (SelectedItem.Value == null) + { + // at listing, all badges are highlighted when user is not hovering any badge. + textHighlighted |= !userHoveringArea; + barExpanded |= !userHoveringArea; + } + else + { + // bar is always expanded when active + barExpanded |= Active.Value; + + // text is highlighted only when hovered or active (but not if in selection mode) + textHighlighted |= Active.Value && !userHoveringArea; + } + + expandingBar.Expanded = barExpanded; + text.FadeTo(textHighlighted ? 1 : 0.5f, 100, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/OverlayTabControl.cs b/osu.Game/Overlays/OverlayTabControl.cs index 20649c8a74..a1cbf2c1e7 100644 --- a/osu.Game/Overlays/OverlayTabControl.cs +++ b/osu.Game/Overlays/OverlayTabControl.cs @@ -1,6 +1,7 @@ // 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. +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; @@ -13,58 +14,51 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public abstract class OverlayTabControl<T> : TabControl<T> + public abstract class OverlayTabControl<T> : OsuTabControl<T> { private readonly Box bar; - private Color4 accentColour = Color4.White; - - public Color4 AccentColour + protected float BarHeight { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - bar.Colour = value; - - foreach (TabItem<T> tabItem in TabContainer) - { - ((OverlayTabItem<T>)tabItem).AccentColour = value; - } - } + set => bar.Height = value; } - public new MarginPadding Padding + public override Color4 AccentColour { - get => TabContainer.Padding; - set => TabContainer.Padding = value; + get => base.AccentColour; + set + { + base.AccentColour = value; + bar.Colour = value; + } } protected OverlayTabControl() { TabContainer.Masking = false; - TabContainer.Spacing = new Vector2(15, 0); + TabContainer.Spacing = new Vector2(20, 0); AddInternal(bar = new Box { RelativeSizeAxes = Axes.X, - Height = 2, Anchor = Anchor.BottomLeft, - Origin = Anchor.CentreLeft + Origin = Anchor.BottomLeft }); } + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AccentColour = colourProvider.Highlight1; + } + protected override Dropdown<T> CreateDropdown() => null; - protected override TabItem<T> CreateTabItem(T value) => new OverlayTabItem<T>(value); + protected override TabItem<T> CreateTabItem(T value) => new OverlayTabItem(value); - protected class OverlayTabItem<U> : TabItem<U> + protected class OverlayTabItem : TabItem<T>, IHasAccentColour { - private readonly ExpandingBar bar; - + protected readonly ExpandingBar Bar; protected readonly OsuSpriteText Text; private Color4 accentColour; @@ -78,13 +72,13 @@ namespace osu.Game.Overlays return; accentColour = value; - bar.Colour = value; + Bar.Colour = value; updateState(); } } - public OverlayTabItem(U value) + public OverlayTabItem(T value) : base(value) { AutoSizeAxes = Axes.X; @@ -99,10 +93,10 @@ namespace osu.Game.Overlays Anchor = Anchor.BottomLeft, Font = OsuFont.GetFont(), }, - bar = new ExpandingBar + Bar = new ExpandingBar { Anchor = Anchor.BottomCentre, - ExpandedSize = 7.5f, + ExpandedSize = 5f, CollapsedSize = 0 }, new HoverClickSounds() @@ -131,6 +125,7 @@ namespace osu.Game.Overlays { HoverAction(); Text.Font = Text.Font.With(weight: FontWeight.Bold); + Text.FadeColour(Color4.White, 120, Easing.InQuad); } protected override void OnDeactivated() @@ -147,15 +142,11 @@ namespace osu.Game.Overlays OnDeactivated(); } - protected virtual void HoverAction() - { - bar.Expand(); - Text.FadeColour(Color4.White, 120, Easing.InQuad); - } + protected virtual void HoverAction() => Bar.Expand(); protected virtual void UnhoverAction() { - bar.Collapse(); + Bar.Collapse(); Text.FadeColour(AccentColour, 120, Easing.InQuad); } } diff --git a/osu.Game/Overlays/OverlayTitle.cs b/osu.Game/Overlays/OverlayTitle.cs new file mode 100644 index 0000000000..c3ea35adfc --- /dev/null +++ b/osu.Game/Overlays/OverlayTitle.cs @@ -0,0 +1,90 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Overlays +{ + public abstract class OverlayTitle : CompositeDrawable, INamedOverlayComponent + { + public const float ICON_SIZE = 30; + + private readonly OsuSpriteText titleText; + private readonly Container icon; + + private string title; + + public string Title + { + get => title; + protected set => titleText.Text = title = value; + } + + public string Description { get; protected set; } + + private string iconTexture; + + public string IconTexture + { + get => iconTexture; + protected set => icon.Child = new OverlayTitleIcon(iconTexture = value); + } + + protected OverlayTitle() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10, 0), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + icon = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 5 }, // compensates for osu-web sprites having around 5px of whitespace on each side + Size = new Vector2(ICON_SIZE) + }, + titleText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Regular), + Margin = new MarginPadding { Vertical = 17.5f } // 15px padding + 2.5px line-height difference compensation + } + } + }; + } + + private class OverlayTitleIcon : Sprite + { + private readonly string textureName; + + public OverlayTitleIcon(string textureName) + { + this.textureName = textureName; + + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + FillMode = FillMode.Fit; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Texture = textures.Get(textureName); + } + } + } +} diff --git a/osu.Game/Overlays/OverlayView.cs b/osu.Game/Overlays/OverlayView.cs new file mode 100644 index 0000000000..c254cdf290 --- /dev/null +++ b/osu.Game/Overlays/OverlayView.cs @@ -0,0 +1,82 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; + +namespace osu.Game.Overlays +{ + /// <summary> + /// A subview containing online content, to be displayed inside a <see cref="FullscreenOverlay{T}"/>. + /// </summary> + /// <remarks> + /// Automatically performs a data fetch on load. + /// </remarks> + /// <typeparam name="T">The type of the API response.</typeparam> + public abstract class OverlayView<T> : CompositeDrawable + where T : class + { + [Resolved] + protected IAPIProvider API { get; private set; } + + private APIRequest<T> request; + + protected OverlayView() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + private readonly IBindable<APIState> apiState = new Bindable<APIState>(); + + [BackgroundDependencyLoader] + private void load() + { + apiState.BindTo(API.State); + apiState.BindValueChanged(onlineStateChanged, true); + } + + /// <summary> + /// Create the API request for fetching data. + /// </summary> + protected abstract APIRequest<T> CreateRequest(); + + /// <summary> + /// Fired when results arrive from the main API request. + /// </summary> + /// <param name="response"></param> + protected abstract void OnSuccess(T response); + + /// <summary> + /// Force a re-request for data from the API. + /// </summary> + protected void PerformFetch() + { + request?.Cancel(); + + request = CreateRequest(); + request.Success += response => Schedule(() => OnSuccess(response)); + + API.Queue(request); + } + + private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() => + { + switch (state.NewValue) + { + case APIState.Online: + PerformFetch(); + break; + } + }); + + protected override void Dispose(bool isDisposing) + { + request?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs index e7f7c2f490..ebee377a51 100644 --- a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. using System; @@ -33,16 +33,16 @@ namespace osu.Game.Overlays.Profile.Header } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - iconColour = colours.GreySeafoamLighter; + iconColour = colourProvider.Foreground1; InternalChildren = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoamDark, + Colour = colourProvider.Background4 }, new FillFlowContainer { @@ -82,7 +82,7 @@ namespace osu.Game.Overlays.Profile.Header else { topLinkContainer.AddText("Joined "); - topLinkContainer.AddText(new DrawableDate(user.JoinDate), embolden); + topLinkContainer.AddText(new DrawableDate(user.JoinDate, italic: false), embolden); } addSpacer(topLinkContainer); @@ -95,7 +95,7 @@ namespace osu.Game.Overlays.Profile.Header else if (user.LastVisit.HasValue) { topLinkContainer.AddText("Last seen "); - topLinkContainer.AddText(new DrawableDate(user.LastVisit.Value), embolden); + topLinkContainer.AddText(new DrawableDate(user.LastVisit.Value, italic: false), embolden); addSpacer(topLinkContainer); } @@ -111,34 +111,44 @@ namespace osu.Game.Overlays.Profile.Header topLinkContainer.AddText("Contributed "); topLinkContainer.AddLink($@"{user.PostCount:#,##0} forum posts", $"https://osu.ppy.sh/users/{user.Id}/posts", creationParameters: embolden); - string websiteWithoutProtcol = user.Website; + string websiteWithoutProtocol = user.Website; - if (!string.IsNullOrEmpty(websiteWithoutProtcol)) + if (!string.IsNullOrEmpty(websiteWithoutProtocol)) { - if (Uri.TryCreate(websiteWithoutProtcol, UriKind.Absolute, out var uri)) + if (Uri.TryCreate(websiteWithoutProtocol, UriKind.Absolute, out var uri)) { - websiteWithoutProtcol = uri.Host + uri.PathAndQuery + uri.Fragment; - websiteWithoutProtcol = websiteWithoutProtcol.TrimEnd('/'); + websiteWithoutProtocol = uri.Host + uri.PathAndQuery + uri.Fragment; + websiteWithoutProtocol = websiteWithoutProtocol.TrimEnd('/'); } } - tryAddInfo(FontAwesome.Solid.MapMarker, user.Location); - tryAddInfo(OsuIcon.Heart, user.Interests); - tryAddInfo(FontAwesome.Solid.Suitcase, user.Occupation); - bottomLinkContainer.NewLine(); + bool anyInfoAdded = false; + + anyInfoAdded |= tryAddInfo(FontAwesome.Solid.MapMarker, user.Location); + anyInfoAdded |= tryAddInfo(OsuIcon.Heart, user.Interests); + anyInfoAdded |= tryAddInfo(FontAwesome.Solid.Suitcase, user.Occupation); + + if (anyInfoAdded) + bottomLinkContainer.NewLine(); + if (!string.IsNullOrEmpty(user.Twitter)) - tryAddInfo(FontAwesome.Brands.Twitter, "@" + user.Twitter, $@"https://twitter.com/{user.Twitter}"); - tryAddInfo(FontAwesome.Brands.Discord, user.Discord); - tryAddInfo(FontAwesome.Brands.Skype, user.Skype, @"skype:" + user.Skype + @"?chat"); - tryAddInfo(FontAwesome.Brands.Lastfm, user.Lastfm, $@"https://last.fm/users/{user.Lastfm}"); - tryAddInfo(FontAwesome.Solid.Link, websiteWithoutProtcol, user.Website); + 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 + bottomLinkContainer.Alpha = anyInfoAdded ? 1 : 0; } private void addSpacer(OsuTextFlowContainer textFlow) => textFlow.AddArbitraryDrawable(new Container { Width = 15 }); - private void tryAddInfo(IconUsage icon, string content, string link = null) + private bool tryAddInfo(IconUsage icon, string content, string link = null) { - if (string.IsNullOrEmpty(content)) return; + if (string.IsNullOrEmpty(content)) return false; + + // newlines could be contained in API returned user content. + content = content.Replace('\n', ' '); bottomLinkContainer.AddIcon(icon, text => { @@ -152,6 +162,7 @@ namespace osu.Game.Overlays.Profile.Header bottomLinkContainer.AddText(" " + content, embolden); addSpacer(bottomLinkContainer); + return true; } private void embolden(SpriteText text) => text.Font = text.Font.With(weight: FontWeight.Bold); diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs index 68fd77dd84..658cdb8ce3 100644 --- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs @@ -7,7 +7,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; -using osu.Game.Graphics; using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; using osuTK; @@ -28,7 +27,7 @@ namespace osu.Game.Overlays.Profile.Header } [BackgroundDependencyLoader] - private void load(OsuColour colours, TextureStore textures) + private void load(OverlayColourProvider colourProvider, TextureStore textures) { Container<Drawable> hiddenDetailContainer; Container<Drawable> expandedDetailContainer; @@ -38,7 +37,7 @@ namespace osu.Game.Overlays.Profile.Header new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoam + Colour = colourProvider.Background4 }, new FillFlowContainer { @@ -119,12 +118,12 @@ namespace osu.Game.Overlays.Profile.Header hiddenDetailGlobal = new OverlinedInfoContainer { Title = "Global Ranking", - LineColour = colours.Yellow + LineColour = colourProvider.Highlight1 }, hiddenDetailCountry = new OverlinedInfoContainer { Title = "Country Ranking", - LineColour = colours.Yellow + LineColour = colourProvider.Highlight1 }, } } diff --git a/osu.Game/Overlays/Profile/Header/Components/AddFriendButton.cs b/osu.Game/Overlays/Profile/Header/Components/AddFriendButton.cs index 2e4fd6fe3d..6e1b6e2c7d 100644 --- a/osu.Game/Overlays/Profile/Header/Components/AddFriendButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/AddFriendButton.cs @@ -53,6 +53,6 @@ namespace osu.Game.Overlays.Profile.Header.Components User.BindValueChanged(user => updateFollowers(user.NewValue), true); } - private void updateFollowers(User user) => followerText.Text = user?.FollowerCount?.Length > 0 ? user.FollowerCount[0].ToString("#,##0") : "0"; + private void updateFollowers(User user) => followerText.Text = user?.FollowerCount.ToString("#,##0"); } } diff --git a/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs b/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs index ea259fe49a..7eed4d3b6b 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs @@ -12,6 +12,7 @@ using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { + [LongRunningLoad] public class DrawableBadge : CompositeDrawable, IHasTooltip { public static readonly Vector2 DRAWABLE_BADGE_SIZE = new Vector2(86, 40); diff --git a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs index 46d24608ed..29e13e4f51 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs @@ -6,7 +6,6 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; using osuTK; namespace osu.Game.Overlays.Profile.Header.Components @@ -25,10 +24,10 @@ namespace osu.Game.Overlays.Profile.Header.Components } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - IdleColour = colours.GreySeafoamLight; - HoverColour = colours.GreySeafoamLight.Darken(0.2f); + IdleColour = colourProvider.Background2; + HoverColour = colourProvider.Background2.Lighten(0.2f); Child = icon = new SpriteIcon { diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs index 8069937810..29471375b5 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs @@ -24,7 +24,7 @@ namespace osu.Game.Overlays.Profile.Header.Components public LevelBadge() { - TooltipText = "Level"; + TooltipText = "level"; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs index 6a6532764f..c97df3bc4d 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs @@ -25,11 +25,11 @@ namespace osu.Game.Overlays.Profile.Header.Components public LevelProgressBar() { - TooltipText = "Progress to next level"; + TooltipText = "progress to next level"; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { InternalChildren = new Drawable[] { @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Profile.Header.Components RelativeSizeAxes = Axes.Both, BackgroundColour = Color4.Black, Direction = BarDirection.LeftToRight, - AccentColour = colours.Yellow + AccentColour = colourProvider.Highlight1 } }, levelProgressText = new OsuSpriteText diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs b/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs index c40ddca688..9f56a34aa6 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs +++ b/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs @@ -43,7 +43,8 @@ namespace osu.Game.Overlays.Profile.Header.Components line = new Circle { RelativeSizeAxes = Axes.X, - Height = 4, + Height = 2, + Margin = new MarginPadding { Bottom = 2 } }, title = new OsuSpriteText { @@ -53,7 +54,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { Font = OsuFont.GetFont(size: big ? 40 : 18, weight: FontWeight.Light) }, - new Container //Add a minimum size to the FillFlowContainer + new Container // Add a minimum size to the FillFlowContainer { Width = minimumWidth, } diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs index 2c88a83680..be96840217 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs +++ b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs @@ -6,7 +6,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Game.Graphics; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -27,12 +26,12 @@ namespace osu.Game.Overlays.Profile.Header.Components } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { InternalChild = info = new OverlinedInfoContainer { Title = "Total Play Time", - LineColour = colours.Yellow, + LineColour = colourProvider.Highlight1, }; User.BindValueChanged(updateTime, true); diff --git a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs new file mode 100644 index 0000000000..e4c0fe3a5a --- /dev/null +++ b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs @@ -0,0 +1,169 @@ +// 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. + +using System; +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.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Profile.Header.Components +{ + public class PreviousUsernames : CompositeDrawable + { + private const int duration = 200; + private const int margin = 10; + private const int width = 310; + private const int move_offset = 15; + + public readonly Bindable<User> User = new Bindable<User>(); + + private readonly TextFlowContainer text; + private readonly Box background; + private readonly SpriteText header; + + public PreviousUsernames() + { + HoverIconContainer hoverIcon; + + AutoSizeAxes = Axes.Y; + Width = width; + Masking = true; + CornerRadius = 5; + + AddRangeInternal(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new GridContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize) + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Distributed) + }, + Content = new[] + { + new Drawable[] + { + hoverIcon = new HoverIconContainer(), + header = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = @"formerly known as", + Font = OsuFont.GetFont(size: 10, italics: true) + } + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + }, + text = new TextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold, italics: true)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + Margin = new MarginPadding { Bottom = margin, Top = margin / 2f } + } + } + } + } + }); + + hoverIcon.ActivateHover += showContent; + hideContent(); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + background.Colour = colours.GreySeafoamDarker; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + User.BindValueChanged(onUserChanged, true); + } + + private void onUserChanged(ValueChangedEvent<User> user) + { + text.Text = string.Empty; + + var usernames = user.NewValue?.PreviousUsernames; + + if (usernames?.Any() ?? false) + { + text.Text = string.Join(", ", usernames); + Show(); + return; + } + + Hide(); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + hideContent(); + } + + private void showContent() + { + text.FadeIn(duration, Easing.OutQuint); + header.FadeIn(duration, Easing.OutQuint); + background.FadeIn(duration, Easing.OutQuint); + this.MoveToY(-move_offset, duration, Easing.OutQuint); + } + + private void hideContent() + { + text.FadeOut(duration, Easing.OutQuint); + header.FadeOut(duration, Easing.OutQuint); + background.FadeOut(duration, Easing.OutQuint); + this.MoveToY(0, duration, Easing.OutQuint); + } + + private class HoverIconContainer : Container + { + public Action ActivateHover; + + public HoverIconContainer() + { + AutoSizeAxes = Axes.Both; + Child = new SpriteIcon + { + Margin = new MarginPadding { Top = 6, Left = margin, Right = margin * 2 }, + Size = new Vector2(15), + Icon = FontAwesome.Solid.IdCard, + }; + } + + protected override bool OnHover(HoverEvent e) + { + ActivateHover?.Invoke(); + return base.OnHover(e); + } + } + } +} diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs index ddcf011277..e14d73dd98 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs @@ -2,12 +2,11 @@ // 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; -using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Header.Components { @@ -24,9 +23,6 @@ namespace osu.Game.Overlays.Profile.Header.Components { AutoSizeAxes = Axes.X; - IdleColour = Color4.Black; - HoverColour = OsuColour.Gray(0.1f); - base.Content.Add(new CircularContainer { Masking = true, @@ -47,5 +43,12 @@ namespace osu.Game.Overlays.Profile.Header.Components } }); } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Background6; + HoverColour = colourProvider.Background5; + } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs index b6112a6501..41a3ee8ad6 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetSelector.cs @@ -1,66 +1,29 @@ // 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. -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; using osu.Game.Rulesets; -using osuTK; -using osuTK.Graphics; +using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components { - public class ProfileRulesetSelector : RulesetSelector + public class ProfileRulesetSelector : OverlayRulesetSelector { - private Color4 accentColour = Color4.White; + public readonly Bindable<User> User = new Bindable<User>(); - public ProfileRulesetSelector() + protected override void LoadComplete() { - TabContainer.Masking = false; - TabContainer.Spacing = new Vector2(10, 0); - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - accentColour = colours.Seafoam; - - foreach (TabItem<RulesetInfo> tabItem in TabContainer) - ((ProfileRulesetTabItem)tabItem).AccentColour = accentColour; + base.LoadComplete(); + User.BindValueChanged(u => SetDefaultRuleset(Rulesets.GetRuleset(u.NewValue?.PlayMode ?? "osu")), true); } public void SetDefaultRuleset(RulesetInfo ruleset) { - // Todo: This method shouldn't exist, but bindables don't provide the concept of observing a change to the default value - foreach (TabItem<RulesetInfo> tabItem in TabContainer) + foreach (var tabItem in TabContainer) ((ProfileRulesetTabItem)tabItem).IsDefault = ((ProfileRulesetTabItem)tabItem).Value.ID == ruleset.ID; } - public void SelectDefaultRuleset() - { - // Todo: This method shouldn't exist, but bindables don't provide the concept of observing a change to the default value - foreach (TabItem<RulesetInfo> tabItem in TabContainer) - { - if (((ProfileRulesetTabItem)tabItem).IsDefault) - { - Current.Value = ((ProfileRulesetTabItem)tabItem).Value; - return; - } - } - } - - protected override TabItem<RulesetInfo> CreateTabItem(RulesetInfo value) => new ProfileRulesetTabItem(value) - { - AccentColour = accentColour - }; - - protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - }; + protected override TabItem<RulesetInfo> CreateTabItem(RulesetInfo value) => new ProfileRulesetTabItem(value); } } diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs index b5170ea3a2..3d20fba542 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileRulesetTabItem.cs @@ -2,40 +2,15 @@ // 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.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Profile.Header.Components { - public class ProfileRulesetTabItem : TabItem<RulesetInfo>, IHasAccentColour + public class ProfileRulesetTabItem : OverlayRulesetTabItem { - private readonly OsuSpriteText text; - private readonly SpriteIcon icon; - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - if (accentColour == value) - return; - - accentColour = value; - - updateState(); - } - } - private bool isDefault; public bool IsDefault @@ -52,74 +27,30 @@ namespace osu.Game.Overlays.Profile.Header.Components } } + protected override Color4 AccentColour + { + get => base.AccentColour; + set + { + base.AccentColour = value; + icon.FadeColour(value, 120, Easing.OutQuint); + } + } + + private readonly SpriteIcon icon; + public ProfileRulesetTabItem(RulesetInfo value) : base(value) { - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] + Add(icon = new SpriteIcon { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(3, 0), - Children = new Drawable[] - { - text = new OsuSpriteText - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Text = value.Name, - }, - icon = new SpriteIcon - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Alpha = 0, - AlwaysPresent = true, - Icon = FontAwesome.Solid.Star, - Size = new Vector2(12), - }, - } - }, - new HoverClickSounds() - }; - } - - protected override bool OnHover(HoverEvent e) - { - base.OnHover(e); - updateState(); - return true; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - base.OnHoverLost(e); - updateState(); - } - - protected override void OnActivated() => updateState(); - - protected override void OnDeactivated() => updateState(); - - private void updateState() - { - text.Font = text.Font.With(weight: Active.Value ? FontWeight.Bold : FontWeight.Medium); - - if (IsHovered || Active.Value) - { - text.FadeColour(Color4.White, 120, Easing.InQuad); - icon.FadeColour(Color4.White, 120, Easing.InQuad); - } - else - { - text.FadeColour(AccentColour, 120, Easing.InQuad); - icon.FadeColour(AccentColour, 120, Easing.InQuad); - } + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Alpha = 0, + AlwaysPresent = true, + Icon = FontAwesome.Solid.Star, + Size = new Vector2(12), + }); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs index 5f79386b76..26126bca58 100644 --- a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs +++ b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs @@ -30,8 +30,8 @@ namespace osu.Game.Overlays.Profile.Header.Components private readonly OsuSpriteText placeholder; private KeyValuePair<int, int>[] ranks; - private int dayIndex; - public Bindable<User> User = new Bindable<User>(); + private int hoveredIndex = -1; + public readonly Bindable<UserStatistics> Statistics = new Bindable<UserStatistics>(); public RankGraph() { @@ -55,9 +55,7 @@ namespace osu.Game.Overlays.Profile.Header.Components } }; - graph.OnBallMove += i => dayIndex = i; - - User.ValueChanged += userChanged; + graph.OnBallMove += i => hoveredIndex = i; } [BackgroundDependencyLoader] @@ -66,18 +64,26 @@ namespace osu.Game.Overlays.Profile.Header.Components graph.LineColour = colours.Yellow; } - private void userChanged(ValueChangedEvent<User> e) + protected override void LoadComplete() + { + base.LoadComplete(); + + Statistics.BindValueChanged(statistics => updateStatistics(statistics.NewValue), true); + } + + private void updateStatistics(UserStatistics statistics) { placeholder.FadeIn(fade_duration, Easing.Out); + hoveredIndex = -1; - if (e.NewValue?.Statistics?.Ranks.Global == null) + if (statistics?.Ranks.Global == null) { graph.FadeOut(fade_duration, Easing.Out); ranks = null; return; } - int[] userRanks = e.NewValue.RankHistory?.Data ?? new[] { e.NewValue.Statistics.Ranks.Global.Value }; + int[] userRanks = statistics.RankHistory?.Data ?? new[] { statistics.Ranks.Global.Value }; ranks = userRanks.Select((x, index) => new KeyValuePair<int, int>(index, x)).Where(x => x.Value != 0).ToArray(); if (ranks.Length > 1) @@ -85,17 +91,22 @@ namespace osu.Game.Overlays.Profile.Header.Components placeholder.FadeOut(fade_duration, Easing.Out); graph.DefaultValueCount = ranks.Length; - graph.Values = ranks.Select(x => -(float)Math.Log(x.Value)); + graph.Values = ranks.Select(x => -MathF.Log(x.Value)); } graph.FadeTo(ranks.Length > 1 ? 1 : 0, fade_duration, Easing.Out); + + if (IsHovered) + graph.UpdateBallPosition(lastHoverPosition); } + private float lastHoverPosition; + protected override bool OnHover(HoverEvent e) { if (ranks?.Length > 1) { - graph.UpdateBallPosition(e.MousePosition.X); + graph.UpdateBallPosition(lastHoverPosition = e.MousePosition.X); graph.ShowBar(); } @@ -112,11 +123,7 @@ namespace osu.Game.Overlays.Profile.Header.Components protected override void OnHoverLost(HoverLostEvent e) { - if (ranks?.Length > 1) - { - graph.HideBar(); - } - + graph.HideBar(); base.OnHoverLost(e); } @@ -162,9 +169,9 @@ namespace osu.Game.Overlays.Profile.Header.Components } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { - ballBg.Colour = colours.GreySeafoamDarker; + ballBg.Colour = colourProvider.Background5; movingBall.BorderColour = line.Colour = colours.Yellow; } @@ -182,7 +189,7 @@ namespace osu.Game.Overlays.Profile.Header.Components public void HideBar() => bar.FadeOut(fade_duration); - private int calculateIndex(float mouseXPosition) => (int)Math.Round(mouseXPosition / DrawWidth * (DefaultValueCount - 1)); + private int calculateIndex(float mouseXPosition) => (int)Math.Clamp(MathF.Round(mouseXPosition / DrawWidth * (DefaultValueCount - 1)), 0, DefaultValueCount - 1); private Vector2 calculateBallPosition(int index) { @@ -191,17 +198,30 @@ namespace osu.Game.Overlays.Profile.Header.Components } } - public string TooltipText => User.Value?.Statistics?.Ranks.Global == null ? "" : $"#{ranks[dayIndex].Value:#,##0}|{ranked_days - ranks[dayIndex].Key + 1}"; + public object TooltipContent + { + get + { + if (ranks == null || hoveredIndex == -1) + return null; + + var days = ranked_days - ranks[hoveredIndex].Key + 1; + + return new TooltipDisplayContent + { + Rank = $"#{ranks[hoveredIndex].Value:#,##0}", + Time = days == 0 ? "now" : $"{days} days ago" + }; + } + } public ITooltip GetCustomTooltip() => new RankGraphTooltip(); - public class RankGraphTooltip : VisibilityContainer, ITooltip + private class RankGraphTooltip : VisibilityContainer, ITooltip { private readonly OsuSpriteText globalRankingText, timeText; private readonly Box background; - public string TooltipText { get; set; } - public RankGraphTooltip() { AutoSizeAxes = Axes.Both; @@ -252,14 +272,19 @@ namespace osu.Game.Overlays.Profile.Header.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - background.Colour = colours.GreySeafoamDark; + // Temporary colour since it's currently impossible to change it without bugs (see https://github.com/ppy/osu-framework/issues/3231) + // If above is fixed, this should use OverlayColourProvider + background.Colour = colours.Gray1; } - public void Refresh() + public bool SetContent(object content) { - var info = TooltipText.Split('|'); - globalRankingText.Text = info[0]; - timeText.Text = info[1] == "0" ? "now" : $"{info[1]} days ago"; + if (!(content is TooltipDisplayContent info)) + return false; + + globalRankingText.Text = info.Rank; + timeText.Text = info.Time; + return true; } private bool instantMove = true; @@ -275,9 +300,19 @@ namespace osu.Game.Overlays.Profile.Header.Components this.MoveTo(pos, 200, Easing.OutQuint); } - protected override void PopIn() => this.FadeIn(200, Easing.OutQuint); + protected override void PopIn() + { + instantMove |= !IsPresent; + this.FadeIn(200, Easing.OutQuint); + } protected override void PopOut() => this.FadeOut(200, Easing.OutQuint); } + + private class TooltipDisplayContent + { + public string Rank; + public string Time; + } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs index c5e61f68f4..d581e2750c 100644 --- a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs +++ b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs @@ -1,6 +1,7 @@ // 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. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -8,7 +9,6 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osuTK; namespace osu.Game.Overlays.Profile.Header.Components { @@ -24,7 +24,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { set { - int count = MathHelper.Clamp(value, 0, 3); + int count = Math.Clamp(value, 0, 3); if (count == 0) { @@ -80,7 +80,6 @@ namespace osu.Game.Overlays.Profile.Header.Components private void load(OsuColour colours) { background.Colour = colours.Pink; - iconContainer.Colour = colours.GreySeafoam; } } } diff --git a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs index 0db1cb32d7..cf6ae1a3fc 100644 --- a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs @@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Profile.Header } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { AutoSizeAxes = Axes.Y; @@ -65,7 +65,7 @@ namespace osu.Game.Overlays.Profile.Header new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoamDarker, + Colour = colourProvider.Background5, }, fillFlow = new FillFlowContainer { @@ -152,12 +152,12 @@ namespace osu.Game.Overlays.Profile.Header detailGlobalRank = new OverlinedInfoContainer(true, 110) { Title = "Global Ranking", - LineColour = colours.Yellow, + LineColour = colourProvider.Highlight1, }, detailCountryRank = new OverlinedInfoContainer(false, 110) { Title = "Country Ranking", - LineColour = colours.Yellow, + LineColour = colourProvider.Highlight1, }, } } @@ -179,7 +179,7 @@ namespace osu.Game.Overlays.Profile.Header detailGlobalRank.Content = user?.Statistics?.Ranks.Global?.ToString("\\##,##0") ?? "-"; detailCountryRank.Content = user?.Statistics?.Ranks.Country?.ToString("\\##,##0") ?? "-"; - rankGraph.User.Value = user; + rankGraph.Statistics.Value = user?.Statistics; } private class ScoreRankInfo : CompositeDrawable diff --git a/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs index 45bc60f794..e7df4eb5eb 100644 --- a/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/MedalHeaderContainer.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; using osuTK; @@ -23,7 +22,7 @@ namespace osu.Game.Overlays.Profile.Header public readonly Bindable<User> User = new Bindable<User>(); [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { Alpha = 0; AutoSizeAxes = Axes.Y; @@ -34,9 +33,9 @@ namespace osu.Game.Overlays.Profile.Header new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoamDarker, + Colour = colourProvider.Background5, }, - new Container //artificial shadow + new Container // artificial shadow { RelativeSizeAxes = Axes.X, Height = 3, diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index b0d7070994..2cc1f6533f 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -1,9 +1,9 @@ // 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. -using System; 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; @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Profile.Header private FillFlowContainer userStats; [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { Height = 150; @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Profile.Header new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeafoamDark, + Colour = colourProvider.Background5, }, new FillFlowContainer { @@ -117,7 +117,7 @@ namespace osu.Game.Overlays.Profile.Header RelativeSizeAxes = Axes.X, Height = 1.5f, Margin = new MarginPadding { Top = 10 }, - Colour = colours.GreySeafoamLighter, + Colour = colourProvider.Light1, }, new FillFlowContainer { @@ -137,7 +137,7 @@ namespace osu.Game.Overlays.Profile.Header Margin = new MarginPadding { Left = 10 }, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - Colour = colours.GreySeafoamLighter, + Colour = colourProvider.Light1, } } }, @@ -171,14 +171,14 @@ namespace osu.Game.Overlays.Profile.Header userCountryText.Text = user?.Country?.FullName ?? "Alien"; supporterTag.SupportLevel = user?.SupportLevel ?? 0; titleText.Text = user?.Title ?? string.Empty; - titleText.Colour = OsuColour.FromHex(user?.Colour ?? "fff"); + titleText.Colour = Color4Extensions.FromHex(user?.Colour ?? "fff"); userStats.Clear(); if (user?.Statistics != null) { userStats.Add(new UserStatsLine("Ranked Score", user.Statistics.RankedScore.ToString("#,##0"))); - userStats.Add(new UserStatsLine("Hit Accuracy", Math.Round(user.Statistics.Accuracy, 2).ToString("#0.00'%'"))); + userStats.Add(new UserStatsLine("Hit Accuracy", user.Statistics.DisplayAccuracy)); userStats.Add(new UserStatsLine("Play Count", user.Statistics.PlayCount.ToString("#,##0"))); userStats.Add(new UserStatsLine("Total Score", user.Statistics.TotalScore.ToString("#,##0"))); userStats.Add(new UserStatsLine("Total Hits", user.Statistics.TotalHits.ToString("#,##0"))); diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index 76613c156d..c947ef0781 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -1,21 +1,18 @@ // 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. -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.Graphics; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Profile.Header; using osu.Game.Users; namespace osu.Game.Overlays.Profile { - public class ProfileHeader : OverlayHeader + public class ProfileHeader : TabControlOverlayHeader<string> { private UserCoverBackground coverContainer; @@ -26,34 +23,32 @@ namespace osu.Game.Overlays.Profile public ProfileHeader() { + ContentSidePadding = UserProfileOverlay.CONTENT_X_MARGIN; + User.ValueChanged += e => updateDisplay(e.NewValue); - TabControl.AddItem("Info"); - TabControl.AddItem("Modding"); + TabControl.AddItem("info"); + TabControl.AddItem("modding"); centreHeaderContainer.DetailsVisible.BindValueChanged(visible => detailHeaderContainer.Expanded = visible.NewValue, true); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - TabControl.AccentColour = colours.Seafoam; - } - protected override Drawable CreateBackground() => new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + Height = 150, + Masking = true, Children = new Drawable[] { - coverContainer = new UserCoverBackground + coverContainer = new ProfileCoverBackground { RelativeSizeAxes = Axes.Both, }, new Box { RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(OsuColour.FromHex("222").Opacity(0.8f), OsuColour.FromHex("222").Opacity(0.2f)) + Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("222").Opacity(0.8f), Color4Extensions.FromHex("222").Opacity(0.2f)) }, } }; @@ -93,23 +88,22 @@ namespace osu.Game.Overlays.Profile } }; - protected override ScreenTitle CreateTitle() => new ProfileHeaderTitle(); + protected override OverlayTitle CreateTitle() => new ProfileHeaderTitle(); private void updateDisplay(User user) => coverContainer.User = user; - private class ProfileHeaderTitle : ScreenTitle + private class ProfileHeaderTitle : OverlayTitle { public ProfileHeaderTitle() { - Title = "Player"; - Section = "Info"; + Title = "player info"; + IconTexture = "Icons/Hexacons/profile"; } + } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AccentColour = colours.Seafoam; - } + private class ProfileCoverBackground : UserCoverBackground + { + protected override double LoadDelay => 0; } } } diff --git a/osu.Game/Overlays/Profile/ProfileSection.cs b/osu.Game/Overlays/Profile/ProfileSection.cs index f3590d4bb7..21f7921da6 100644 --- a/osu.Game/Overlays/Profile/ProfileSection.cs +++ b/osu.Game/Overlays/Profile/ProfileSection.cs @@ -59,7 +59,7 @@ namespace osu.Game.Overlays.Profile { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN, Top = 15, - Bottom = 10, + Bottom = 20, }, Children = new Drawable[] { @@ -95,10 +95,10 @@ namespace osu.Game.Overlays.Profile } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - background.Colour = colours.GreySeafoamDarker; - underscore.Colour = colours.Seafoam; + background.Colour = colourProvider.Background5; + underscore.Colour = colourProvider.Highlight1; } private class SectionTriangles : Container @@ -128,11 +128,11 @@ namespace osu.Game.Overlays.Profile } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - triangles.ColourLight = colours.GreySeafoamDark; - triangles.ColourDark = colours.GreySeafoamDarker.Darken(0.2f); - foreground.Colour = ColourInfo.GradientVertical(colours.GreySeafoamDarker, colours.GreySeafoamDarker.Opacity(0)); + triangles.ColourLight = colourProvider.Background4; + triangles.ColourDark = colourProvider.Background5.Darken(0.2f); + foreground.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Background5.Opacity(0)); } } } diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs index 13b547eed3..67a976fe6f 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs @@ -10,7 +10,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Profile.Sections { /// <summary> - /// Display artist/title/mapper information, commonly used as the left portion of a profile or score display row (see <see cref="DrawableProfileRow"/>). + /// Display artist/title/mapper information, commonly used as the left portion of a profile or score display row. /// </summary> public abstract class BeatmapMetadataContainer : OsuHoverContainer { diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index 1b6c1c99a6..4b7de8de90 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -1,63 +1,70 @@ // 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. -using System.Linq; +using System.Collections.Generic; +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.Overlays.Direct; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Users; using osuTK; namespace osu.Game.Overlays.Profile.Sections.Beatmaps { - public class PaginatedBeatmapContainer : PaginatedContainer + public class PaginatedBeatmapContainer : PaginatedContainer<APIBeatmapSet> { private const float panel_padding = 10f; private readonly BeatmapSetType type; - private GetUserBeatmapsRequest request; - public PaginatedBeatmapContainer(BeatmapSetType type, Bindable<User> user, string header, string missing = "None... yet.") - : base(user, header, missing) + public PaginatedBeatmapContainer(BeatmapSetType type, Bindable<User> user, string headerText) + : base(user, headerText, "", CounterVisibilityState.AlwaysVisible) { this.type = type; - ItemsPerPage = 6; + } + [BackgroundDependencyLoader] + private void load() + { ItemsContainer.Spacing = new Vector2(panel_padding); } - protected override void ShowMore() + protected override int GetCount(User user) { - request = new GetUserBeatmapsRequest(User.Value.Id, type, VisiblePages++, ItemsPerPage); - request.Success += sets => Schedule(() => + switch (type) { - MoreButton.FadeTo(sets.Count == ItemsPerPage ? 1 : 0); - MoreButton.IsLoading = false; + case BeatmapSetType.Favourite: + return user.FavouriteBeatmapsetCount; - if (!sets.Any() && VisiblePages == 1) - { - MissingText.Show(); - return; - } + case BeatmapSetType.Graveyard: + return user.GraveyardBeatmapsetCount; - foreach (var s in sets) - { - if (!s.OnlineBeatmapSetID.HasValue) - continue; + case BeatmapSetType.Loved: + return user.LovedBeatmapsetCount; - var panel = new DirectGridPanel(s.ToBeatmapSet(Rulesets)); - ItemsContainer.Add(panel); - } - }); + case BeatmapSetType.RankedAndApproved: + return user.RankedAndApprovedBeatmapsetCount; - Api.Queue(request); + case BeatmapSetType.Unranked: + return user.UnrankedBeatmapsetCount; + + default: + return 0; + } } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - request?.Cancel(); - } + protected override APIRequest<List<APIBeatmapSet>> CreateRequest() => + new GetUserBeatmapsRequest(User.Value.Id, type, VisiblePages++, ItemsPerPage); + + protected override Drawable CreateDrawableItem(APIBeatmapSet model) => !model.OnlineBeatmapSetID.HasValue + ? null + : new GridBeatmapPanel(model.ToBeatmapSet(Rulesets)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }; } } diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs index ae91837d29..c283de42f3 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs @@ -18,8 +18,9 @@ namespace osu.Game.Overlays.Profile.Sections { new PaginatedBeatmapContainer(BeatmapSetType.Favourite, User, "Favourite Beatmaps"), new PaginatedBeatmapContainer(BeatmapSetType.RankedAndApproved, User, "Ranked & Approved Beatmaps"), + new PaginatedBeatmapContainer(BeatmapSetType.Loved, User, "Loved Beatmaps"), new PaginatedBeatmapContainer(BeatmapSetType.Unranked, User, "Pending Beatmaps"), - new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, "Graveyarded Beatmaps"), + new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, "Graveyarded Beatmaps") }; } } diff --git a/osu.Game/Overlays/Profile/Sections/CounterPill.cs b/osu.Game/Overlays/Profile/Sections/CounterPill.cs new file mode 100644 index 0000000000..ca8abcfe5a --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/CounterPill.cs @@ -0,0 +1,54 @@ +// 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. + +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Framework.Bindables; +using osu.Game.Graphics.Sprites; +using osu.Framework.Allocation; + +namespace osu.Game.Overlays.Profile.Sections +{ + public class CounterPill : CircularContainer + { + public readonly BindableInt Current = new BindableInt(); + + private OsuSpriteText counter; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AutoSizeAxes = Axes.Both; + Masking = true; + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6 + }, + counter = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 10, Bottom = 1 }, + Font = OsuFont.GetFont(size: 11.2f, weight: FontWeight.Bold), + Colour = colourProvider.Foreground1 + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Current.BindValueChanged(onCurrentChanged, true); + } + + private void onCurrentChanged(ValueChangedEvent<int> value) + { + counter.Text = value.NewValue.ToString("N0"); + } + } +} diff --git a/osu.Game/Overlays/Profile/Sections/DrawableProfileRow.cs b/osu.Game/Overlays/Profile/Sections/DrawableProfileRow.cs deleted file mode 100644 index 23fe6e9cd5..0000000000 --- a/osu.Game/Overlays/Profile/Sections/DrawableProfileRow.cs +++ /dev/null @@ -1,125 +0,0 @@ -// 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. - -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.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Overlays.Profile.Sections -{ - public abstract class DrawableProfileRow : Container - { - private const int fade_duration = 200; - - private Box underscoreLine; - private readonly Box coloredBackground; - private readonly Container background; - - /// <summary> - /// A visual element displayed to the left of <see cref="LeftFlowContainer"/> content. - /// </summary> - protected abstract Drawable CreateLeftVisual(); - - protected FillFlowContainer LeftFlowContainer { get; private set; } - protected FillFlowContainer RightFlowContainer { get; private set; } - - protected override Container<Drawable> Content { get; } - - protected DrawableProfileRow() - { - RelativeSizeAxes = Axes.X; - Height = 60; - InternalChildren = new Drawable[] - { - background = new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 3, - Alpha = 0, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Offset = new Vector2(0f, 1f), - Radius = 1f, - Colour = Color4.Black.Opacity(0.2f), - }, - Child = coloredBackground = new Box { RelativeSizeAxes = Axes.Both } - }, - Content = new Container - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Width = 0.97f, - }, - }; - } - - [BackgroundDependencyLoader(true)] - private void load(OsuColour colour) - { - AddRange(new Drawable[] - { - underscoreLine = new Box - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.X, - Height = 1, - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new[] - { - CreateLeftVisual(), - LeftFlowContainer = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Left = 10 }, - Direction = FillDirection.Vertical, - }, - } - }, - RightFlowContainer = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Direction = FillDirection.Vertical, - }, - }); - - coloredBackground.Colour = underscoreLine.Colour = colour.Gray4; - } - - protected override bool OnClick(ClickEvent e) => true; - - protected override bool OnHover(HoverEvent e) - { - background.FadeIn(fade_duration, Easing.OutQuint); - underscoreLine.FadeOut(fade_duration, Easing.OutQuint); - return true; - } - - protected override void OnHoverLost(HoverLostEvent e) - { - background.FadeOut(fade_duration, Easing.OutQuint); - underscoreLine.FadeIn(fade_duration, Easing.OutQuint); - base.OnHoverLost(e); - } - } -} diff --git a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs index 0206c4e13b..5b7c5efbe2 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; @@ -13,44 +12,34 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; -using System.Collections.Generic; using osu.Framework.Graphics.Cursor; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class DrawableMostPlayedBeatmap : OsuHoverContainer + public class DrawableMostPlayedBeatmap : CompositeDrawable { private const int cover_width = 100; private const int corner_radius = 6; - private const int height = 50; private readonly BeatmapInfo beatmap; private readonly int playCount; - private Box background; - - protected override IEnumerable<Drawable> EffectTargets => new[] { background }; - public DrawableMostPlayedBeatmap(BeatmapInfo beatmap, int playCount) { this.beatmap = beatmap; this.playCount = playCount; - Enabled.Value = true; //manually enabled, because we have no action RelativeSizeAxes = Axes.X; - Height = height; + Height = 50; Masking = true; CornerRadius = corner_radius; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - IdleColour = colours.GreySeafoam; - HoverColour = colours.GreySeafoamLight; - - Children = new Drawable[] + AddRangeInternal(new Drawable[] { new UpdateableBeatmapSetCover { @@ -72,46 +61,61 @@ namespace osu.Game.Overlays.Profile.Sections.Historical CornerRadius = corner_radius, Children = new Drawable[] { - background = new Box { RelativeSizeAxes = Axes.Both }, - new Container + new MostPlayedBeatmapContainer { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(10), - Children = new Drawable[] + Child = new Container { - new FillFlowContainer + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Children = new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new FillFlowContainer { - new MostPlayedBeatmapMetadataContainer(beatmap), - new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular)) + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Colour = colours.GreySeafoamLighter - }.With(d => - { - d.AddText("mapped by "); - d.AddUserLink(beatmap.Metadata.Author); - }), - } - }, - new PlayCountText(playCount) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight - }, - } - }, + new MostPlayedBeatmapMetadataContainer(beatmap), + new LinkFlowContainer(t => + { + t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular); + t.Colour = colourProvider.Foreground1; + }) + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }.With(d => + { + d.AddText("mapped by "); + d.AddUserLink(beatmap.Metadata.Author); + }), + } + }, + new PlayCountText(playCount) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight + }, + } + }, + } } } } } - }; + }); + } + + private class MostPlayedBeatmapContainer : ProfileItemContainer + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Background4; + HoverColour = colourProvider.Background3; + } } private class MostPlayedBeatmapMetadataContainer : BeatmapMetadataContainer diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs index 23072f8d90..8f19cd900c 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs @@ -1,56 +1,36 @@ // 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. -using System.Linq; +using System.Collections.Generic; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Sections.Historical { - public class PaginatedMostPlayedBeatmapContainer : PaginatedContainer + public class PaginatedMostPlayedBeatmapContainer : PaginatedContainer<APIUserMostPlayedBeatmap> { - private GetUserMostPlayedBeatmapsRequest request; - public PaginatedMostPlayedBeatmapContainer(Bindable<User> user) : base(user, "Most Played Beatmaps", "No records. :(") { ItemsPerPage = 5; + } + [BackgroundDependencyLoader] + private void load() + { ItemsContainer.Direction = FillDirection.Vertical; } - protected override void ShowMore() - { - request = new GetUserMostPlayedBeatmapsRequest(User.Value.Id, VisiblePages++, ItemsPerPage); - request.Success += beatmaps => Schedule(() => - { - MoreButton.FadeTo(beatmaps.Count == ItemsPerPage ? 1 : 0); - MoreButton.IsLoading = false; + protected override APIRequest<List<APIUserMostPlayedBeatmap>> CreateRequest() => + new GetUserMostPlayedBeatmapsRequest(User.Value.Id, VisiblePages++, ItemsPerPage); - if (!beatmaps.Any() && VisiblePages == 1) - { - MissingText.Show(); - return; - } - - MissingText.Hide(); - - foreach (var beatmap in beatmaps) - { - ItemsContainer.Add(new DrawableMostPlayedBeatmap(beatmap.GetBeatmapInfo(Rulesets), beatmap.PlayCount)); - } - }); - - Api.Queue(request); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - request?.Cancel(); - } + protected override Drawable CreateDrawableItem(APIUserMostPlayedBeatmap model) => + new DrawableMostPlayedBeatmap(model.GetBeatmapInfo(Rulesets), model.PlayCount); } } diff --git a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs index 4bdd25ee66..bfc47bd88c 100644 --- a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs +++ b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs @@ -19,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Sections Children = new Drawable[] { new PaginatedMostPlayedBeatmapContainer(User), - new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)", "No performance records. :("), + new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)", CounterVisibilityState.VisibleWhenZero), }; } } diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs new file mode 100644 index 0000000000..d0cfe9fa54 --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/DrawableKudosuHistoryItem.cs @@ -0,0 +1,147 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using System; +using osuTK; + +namespace osu.Game.Overlays.Profile.Sections.Kudosu +{ + public class DrawableKudosuHistoryItem : CompositeDrawable + { + private const int height = 25; + + [Resolved] + private OsuColour colours { get; set; } + + private readonly APIKudosuHistory historyItem; + private readonly LinkFlowContainer linkFlowContainer; + private readonly DrawableDate date; + + public DrawableKudosuHistoryItem(APIKudosuHistory historyItem) + { + this.historyItem = historyItem; + + Height = height; + RelativeSizeAxes = Axes.X; + AddRangeInternal(new Drawable[] + { + linkFlowContainer = new LinkFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(0, 3), + }, + date = new DrawableDate(historyItem.CreatedAt) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + } + }); + } + + [BackgroundDependencyLoader] + private void load() + { + date.Colour = colours.GreySeafoamLighter; + var formattedSource = MessageFormatter.FormatText(getString(historyItem)); + linkFlowContainer.AddLinks(formattedSource.Text, formattedSource.Links); + } + + private string getString(APIKudosuHistory item) + { + string amount = $"{Math.Abs(item.Amount)} kudosu"; + string post = $"[{item.Post.Title}]({item.Post.Url})"; + + switch (item.Source) + { + case KudosuSource.AllowKudosu: + switch (item.Action) + { + case KudosuAction.Give: + return $"Received {amount} from kudosu deny repeal of modding post {post}"; + } + + break; + + case KudosuSource.DenyKudosu: + switch (item.Action) + { + case KudosuAction.Reset: + return $"Denied {amount} from modding post {post}"; + } + + break; + + case KudosuSource.Delete: + switch (item.Action) + { + case KudosuAction.Reset: + return $"Lost {amount} from modding post deletion of {post}"; + } + + break; + + case KudosuSource.Restore: + switch (item.Action) + { + case KudosuAction.Give: + return $"Received {amount} from modding post restoration of {post}"; + } + + break; + + case KudosuSource.Vote: + switch (item.Action) + { + case KudosuAction.Give: + return $"Received {amount} from obtaining votes in modding post of {post}"; + + case KudosuAction.Reset: + return $"Lost {amount} from losing votes in modding post of {post}"; + } + + break; + + case KudosuSource.Recalculate: + switch (item.Action) + { + case KudosuAction.Give: + return $"Received {amount} from votes recalculation in modding post of {post}"; + + case KudosuAction.Reset: + return $"Lost {amount} from votes recalculation in modding post of {post}"; + } + + break; + + case KudosuSource.Forum: + + string giver = $"[{item.Giver?.Username}]({item.Giver?.Url})"; + + switch (historyItem.Action) + { + case KudosuAction.Give: + return $"Received {amount} from {giver} for a post at {post}"; + + case KudosuAction.Reset: + return $"Kudosu reset by {giver} for the post {post}"; + + case KudosuAction.Revoke: + return $"Denied kudosu by {giver} for the post {post}"; + } + + break; + } + + return $"Unknown event ({amount} change)"; + } + } +} diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs index aabfa56ee6..d4d0976724 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs @@ -101,7 +101,7 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu { Masking = true, RelativeSizeAxes = Axes.X, - Height = 5, + Height = 2, Child = lineBackground = new Box { RelativeSizeAxes = Axes.Both, @@ -128,10 +128,10 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - lineBackground.Colour = colours.Yellow; - DescriptionText.Colour = colours.GreySeafoamLighter; + lineBackground.Colour = colourProvider.Highlight1; + DescriptionText.Colour = colourProvider.Foreground1; } } } diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs new file mode 100644 index 0000000000..1b8bd23eb4 --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs @@ -0,0 +1,27 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Game.Online.API.Requests; +using osu.Game.Users; +using osu.Framework.Bindables; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.API; +using System.Collections.Generic; + +namespace osu.Game.Overlays.Profile.Sections.Kudosu +{ + public class PaginatedKudosuHistoryContainer : PaginatedContainer<APIKudosuHistory> + { + public PaginatedKudosuHistoryContainer(Bindable<User> user) + : base(user, missingText: "This user hasn't received any kudosu!") + { + ItemsPerPage = 5; + } + + protected override APIRequest<List<APIKudosuHistory>> CreateRequest() + => new GetUserKudosuHistoryRequest(User.Value.Id, VisiblePages++, ItemsPerPage); + + protected override Drawable CreateDrawableItem(APIKudosuHistory item) => new DrawableKudosuHistoryItem(item); + } +} diff --git a/osu.Game/Overlays/Profile/Sections/KudosuSection.cs b/osu.Game/Overlays/Profile/Sections/KudosuSection.cs index a17b68933c..a9e9952257 100644 --- a/osu.Game/Overlays/Profile/Sections/KudosuSection.cs +++ b/osu.Game/Overlays/Profile/Sections/KudosuSection.cs @@ -1,6 +1,7 @@ // 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. +using osu.Framework.Graphics; using osu.Game.Overlays.Profile.Sections.Kudosu; namespace osu.Game.Overlays.Profile.Sections @@ -13,9 +14,10 @@ namespace osu.Game.Overlays.Profile.Sections public KudosuSection() { - Children = new[] + Children = new Drawable[] { new KudosuInfo(User), + new PaginatedKudosuHistoryContainer(User), }; } } diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs b/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs index b459afcb49..c1107ce907 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedContainer.cs @@ -11,39 +11,56 @@ using osu.Game.Graphics.Sprites; using osu.Game.Online.API; using osu.Game.Rulesets; using osu.Game.Users; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Profile.Sections { - public abstract class PaginatedContainer : FillFlowContainer + public abstract class PaginatedContainer<TModel> : FillFlowContainer { - protected readonly FillFlowContainer ItemsContainer; - protected readonly ShowMoreButton MoreButton; - protected readonly OsuSpriteText MissingText; + [Resolved] + private IAPIProvider api { get; set; } protected int VisiblePages; protected int ItemsPerPage; protected readonly Bindable<User> User = new Bindable<User>(); - - protected IAPIProvider Api; - protected APIRequest RetrievalRequest; + protected FillFlowContainer ItemsContainer; protected RulesetStore Rulesets; - protected PaginatedContainer(Bindable<User> user, string header, string missing) - { - User.BindTo(user); + private APIRequest<List<TModel>> retrievalRequest; + private CancellationTokenSource loadCancellation; + private readonly string missingText; + private ShowMoreButton moreButton; + private OsuSpriteText missing; + private PaginatedContainerHeader header; + + private readonly string headerText; + private readonly CounterVisibilityState counterVisibilityState; + + protected PaginatedContainer(Bindable<User> user, string headerText = "", string missingText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) + { + this.headerText = headerText; + this.missingText = missingText; + this.counterVisibilityState = counterVisibilityState; + User.BindTo(user); + } + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Direction = FillDirection.Vertical; Children = new Drawable[] { - new OsuSpriteText + header = new PaginatedContainerHeader(headerText, counterVisibilityState) { - Text = header, - Font = OsuFont.GetFont(size: 20, weight: FontWeight.Bold), - Margin = new MarginPadding { Top = 10, Bottom = 10 }, + Alpha = string.IsNullOrEmpty(headerText) ? 0 : 1 }, ItemsContainer = new FillFlowContainer { @@ -51,27 +68,22 @@ namespace osu.Game.Overlays.Profile.Sections RelativeSizeAxes = Axes.X, Spacing = new Vector2(0, 2), }, - MoreButton = new ShowMoreButton + moreButton = new ShowMoreButton { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Alpha = 0, Margin = new MarginPadding { Top = 10 }, - Action = ShowMore, + Action = showMore, }, - MissingText = new OsuSpriteText + missing = new OsuSpriteText { Font = OsuFont.GetFont(size: 15), - Text = missing, + Text = missingText, Alpha = 0, }, }; - } - [BackgroundDependencyLoader] - private void load(IAPIProvider api, RulesetStore rulesets) - { - Api = api; Rulesets = rulesets; User.ValueChanged += onUserChanged; @@ -80,13 +92,70 @@ namespace osu.Game.Overlays.Profile.Sections private void onUserChanged(ValueChangedEvent<User> e) { + loadCancellation?.Cancel(); + retrievalRequest?.Cancel(); + VisiblePages = 0; ItemsContainer.Clear(); if (e.NewValue != null) - ShowMore(); + { + showMore(); + SetCount(GetCount(e.NewValue)); + } } - protected abstract void ShowMore(); + private void showMore() + { + loadCancellation = new CancellationTokenSource(); + + retrievalRequest = CreateRequest(); + retrievalRequest.Success += UpdateItems; + + api.Queue(retrievalRequest); + } + + protected virtual void UpdateItems(List<TModel> items) => Schedule(() => + { + OnItemsReceived(items); + + if (!items.Any() && VisiblePages == 1) + { + moreButton.Hide(); + moreButton.IsLoading = false; + + if (!string.IsNullOrEmpty(missing.Text)) + missing.Show(); + + return; + } + + LoadComponentsAsync(items.Select(CreateDrawableItem).Where(d => d != null), drawables => + { + missing.Hide(); + moreButton.FadeTo(items.Count == ItemsPerPage ? 1 : 0); + moreButton.IsLoading = false; + + ItemsContainer.AddRange(drawables); + }, loadCancellation.Token); + }); + + protected virtual int GetCount(User user) => 0; + + protected void SetCount(int value) => header.Current.Value = value; + + protected virtual void OnItemsReceived(List<TModel> items) + { + } + + protected abstract APIRequest<List<TModel>> CreateRequest(); + + protected abstract Drawable CreateDrawableItem(TModel model); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + retrievalRequest?.Cancel(); + } } } diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedContainerHeader.cs b/osu.Game/Overlays/Profile/Sections/PaginatedContainerHeader.cs new file mode 100644 index 0000000000..8c617e5fbd --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/PaginatedContainerHeader.cs @@ -0,0 +1,123 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Bindables; +using System; +using osu.Framework.Graphics.Shapes; +using osuTK; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; + +namespace osu.Game.Overlays.Profile.Sections +{ + public class PaginatedContainerHeader : CompositeDrawable, IHasCurrentValue<int> + { + private readonly BindableWithCurrent<int> current = new BindableWithCurrent<int>(); + + public Bindable<int> Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly string text; + private readonly CounterVisibilityState counterState; + + private CounterPill counterPill; + + public PaginatedContainerHeader(string text, CounterVisibilityState counterState) + { + this.text = text; + this.counterState = counterState; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + AutoSizeAxes = Axes.Both; + Padding = new MarginPadding { Vertical = 10 }; + InternalChildren = new Drawable[] + { + new CircularContainer + { + RelativeSizeAxes = Axes.Y, + Height = 0.65f, + Width = 3, + Masking = true, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreRight, + Margin = new MarginPadding { Right = 10 }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Highlight1 + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = text, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), + }, + counterPill = new CounterPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = { BindTarget = current } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + current.BindValueChanged(onCurrentChanged, true); + } + + private void onCurrentChanged(ValueChangedEvent<int> countValue) + { + float alpha; + + switch (counterState) + { + case CounterVisibilityState.AlwaysHidden: + alpha = 0; + break; + + case CounterVisibilityState.AlwaysVisible: + alpha = 1; + break; + + case CounterVisibilityState.VisibleWhenZero: + alpha = current.Value == 0 ? 1 : 0; + break; + + default: + throw new NotImplementedException($"{counterState} has an incorrect value."); + } + + counterPill.Alpha = alpha; + } + } + + public enum CounterVisibilityState + { + AlwaysHidden, + AlwaysVisible, + VisibleWhenZero + } +} diff --git a/osu.Game/Overlays/Profile/Sections/ProfileItemContainer.cs b/osu.Game/Overlays/Profile/Sections/ProfileItemContainer.cs new file mode 100644 index 0000000000..afa6bd9f79 --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/ProfileItemContainer.cs @@ -0,0 +1,89 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Profile.Sections +{ + public class ProfileItemContainer : Container + { + private const int hover_duration = 200; + + protected override Container<Drawable> Content => content; + + private readonly Box background; + private readonly Container content; + + private Color4 idleColour; + + protected Color4 IdleColour + { + get => idleColour; + set + { + idleColour = value; + fadeBackgroundColour(); + } + } + + private Color4 hoverColour; + + protected Color4 HoverColour + { + get => hoverColour; + set + { + hoverColour = value; + fadeBackgroundColour(); + } + } + + public ProfileItemContainer() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + CornerRadius = 6; + + AddRangeInternal(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + content = new Container + { + RelativeSizeAxes = Axes.Both, + } + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Background3; + HoverColour = colourProvider.Background2; + } + + protected override bool OnHover(HoverEvent e) + { + fadeBackgroundColour(hover_duration); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + fadeBackgroundColour(hover_duration); + } + + private void fadeBackgroundColour(double fadeDuration = 0) + { + background.FadeColour(IsHovered ? HoverColour : IdleColour, fadeDuration, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawablePerformanceScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawablePerformanceScore.cs deleted file mode 100644 index 843f9b7ef2..0000000000 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawablePerformanceScore.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Scoring; - -namespace osu.Game.Overlays.Profile.Sections.Ranks -{ - public class DrawablePerformanceScore : DrawableProfileScore - { - private readonly double? weight; - - public DrawablePerformanceScore(ScoreInfo score, double? weight = null) - : base(score) - { - this.weight = weight; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - double pp = Score.PP ?? 0; - RightFlowContainer.Add(new OsuSpriteText - { - Text = $"{pp:0}pp", - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold, italics: true) - }); - - if (weight.HasValue) - { - RightFlowContainer.Add(new OsuSpriteText - { - Text = $"weighted: {pp * weight:0}pp ({weight:P0})", - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = colour.GrayA, - Font = OsuFont.GetFont(size: 11, weight: FontWeight.Regular, italics: true) - }); - } - } - } -} diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index e54ce44ca2..2c20dcc0ef 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -1,75 +1,251 @@ // 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. -using osuTK; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; 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.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.Leaderboards; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osu.Game.Scoring; -using osu.Game.Beatmaps; -using osu.Framework.Localisation; +using osuTK; namespace osu.Game.Overlays.Profile.Sections.Ranks { - public abstract class DrawableProfileScore : DrawableProfileRow + public class DrawableProfileScore : CompositeDrawable { - private readonly ScoreModsContainer modsContainer; + private const int height = 40; + private const int performance_width = 100; + + private const float performance_background_shear = 0.45f; + protected readonly ScoreInfo Score; - protected DrawableProfileScore(ScoreInfo score) + [Resolved] + private OsuColour colours { get; set; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + public DrawableProfileScore(ScoreInfo score) { Score = score; RelativeSizeAxes = Axes.X; - Height = 60; - Children = new Drawable[] + Height = height; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(new ProfileItemContainer { - modsContainer = new ScoreModsContainer + Children = new Drawable[] { - AutoSizeAxes = Axes.Y, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Width = 60, - Margin = new MarginPadding { Right = 160 } + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 20, Right = performance_width }, + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new UpdateableRank(Score.Rank) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(50, 20), + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2), + Children = new Drawable[] + { + new ScoreBeatmapMetadataContainer(Score.Beatmap), + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(15, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = $"{Score.Beatmap.Version}", + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), + Colour = colours.Yellow + }, + new DrawableDate(Score.Date, 12) + { + Colour = colourProvider.Foreground1 + } + } + } + } + } + } + }, + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(15), + Children = new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Vertical = 5 }, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Child = CreateRightContent() + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2), + Children = Score.Mods.Select(mod => new ModIcon(mod) + { + Scale = new Vector2(0.35f) + }).ToList(), + } + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.Y, + Width = performance_width, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Children = new Drawable[] + { + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Colour = colourProvider.Background4, + Shear = new Vector2(-performance_background_shear, 0), + EdgeSmoothness = new Vector2(2, 0), + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Y, + Height = -0.5f, + Position = new Vector2(0, 1), + Colour = colourProvider.Background4, + Shear = new Vector2(performance_background_shear, 0), + EdgeSmoothness = new Vector2(2, 0), + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Vertical = 5, + Left = 30, + Right = 20 + }, + Child = createDrawablePerformance().With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }) + } + } + } } - }; + }); } - [BackgroundDependencyLoader(true)] - private void load(OsuColour colour) - { - var text = new OsuSpriteText - { - Text = $"accuracy: {Score.Accuracy:P2}", - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = colour.GrayA, - Font = OsuFont.GetFont(size: 11, weight: FontWeight.Regular, italics: true) - }; - - RightFlowContainer.Insert(1, text); - - LeftFlowContainer.Add(new ProfileScoreBeatmapMetadataContainer(Score.Beatmap)); - LeftFlowContainer.Add(new DrawableDate(Score.Date)); - - foreach (Mod mod in Score.Mods) - modsContainer.Add(new ModIcon(mod) { Scale = new Vector2(0.5f) }); - } - - protected override Drawable CreateLeftVisual() => new UpdateableRank(Score.Rank) + [NotNull] + protected virtual Drawable CreateRightContent() => CreateDrawableAccuracy(); + + protected Drawable CreateDrawableAccuracy() => new Container { + Width = 65, RelativeSizeAxes = Axes.Y, - Width = 60, - FillMode = FillMode.Fit, + Child = new OsuSpriteText + { + Text = Score.DisplayAccuracy, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + Colour = colours.Yellow, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + } }; - private class ProfileScoreBeatmapMetadataContainer : BeatmapMetadataContainer + private Drawable createDrawablePerformance() { - public ProfileScoreBeatmapMetadataContainer(BeatmapInfo beatmap) + if (Score.PP.HasValue) + { + return new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new[] + { + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = $"{Score.PP:0}", + Colour = colourProvider.Highlight1 + }, + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Text = "pp", + Colour = colourProvider.Light3 + } + } + }; + } + + return new OsuSpriteText + { + Font = OsuFont.GetFont(weight: FontWeight.Bold), + Text = "-", + Colour = colourProvider.Highlight1 + }; + } + + private class ScoreBeatmapMetadataContainer : BeatmapMetadataContainer + { + public ScoreBeatmapMetadataContainer(BeatmapInfo beatmap) : base(beatmap) { } @@ -78,16 +254,19 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { new OsuSpriteText { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, Text = new LocalisedString(( - $"{beatmap.Metadata.TitleUnicode ?? beatmap.Metadata.Title} [{beatmap.Version}] ", - $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} [{beatmap.Version}] ")), - Font = OsuFont.GetFont(size: 15, weight: FontWeight.SemiBold, italics: true) + $"{beatmap.Metadata.TitleUnicode ?? beatmap.Metadata.Title} ", + $"{beatmap.Metadata.Title ?? beatmap.Metadata.TitleUnicode} ")), + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold, italics: true) }, new OsuSpriteText { - Text = new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)), - Padding = new MarginPadding { Top = 3 }, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular, italics: true) + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = "by " + new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)), + Font = OsuFont.GetFont(size: 12, italics: true) }, }; } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs new file mode 100644 index 0000000000..3afa79e59e --- /dev/null +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs @@ -0,0 +1,59 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Overlays.Profile.Sections.Ranks +{ + public class DrawableProfileWeightedScore : DrawableProfileScore + { + private readonly double weight; + + public DrawableProfileWeightedScore(ScoreInfo score, double weight) + : base(score) + { + this.weight = weight; + } + + protected override Drawable CreateRightContent() => new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new[] + { + CreateDrawableAccuracy(), + new Container + { + AutoSizeAxes = Axes.Y, + Width = 50, + Child = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), + Text = $"{Score.PP * weight:0}pp", + }, + } + } + }, + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12), + Text = $@"weighted {weight:0%}" + } + } + }; + } +} diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableTotalScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableTotalScore.cs deleted file mode 100644 index 8bfca08fe7..0000000000 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableTotalScore.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Scoring; - -namespace osu.Game.Overlays.Profile.Sections.Ranks -{ - public class DrawableTotalScore : DrawableProfileScore - { - public DrawableTotalScore(ScoreInfo score) - : base(score) - { - } - - [BackgroundDependencyLoader] - private void load() - { - RightFlowContainer.Add(new OsuSpriteText - { - Text = Score.TotalScore.ToString("#,###"), - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold, italics: true) - }); - } - } -} diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index 4a9ac6e5c7..1ce3079d52 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -5,76 +5,71 @@ using osu.Framework.Graphics.Containers; using osu.Game.Online.API.Requests; using osu.Game.Users; using System; -using System.Collections.Generic; -using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Online.API.Requests.Responses; +using System.Collections.Generic; +using osu.Game.Online.API; +using osu.Framework.Allocation; namespace osu.Game.Overlays.Profile.Sections.Ranks { - public class PaginatedScoreContainer : PaginatedContainer + public class PaginatedScoreContainer : PaginatedContainer<APILegacyScoreInfo> { - private readonly bool includeWeight; private readonly ScoreType type; - private GetUserScoresRequest request; - public PaginatedScoreContainer(ScoreType type, Bindable<User> user, string header, string missing, bool includeWeight = false) - : base(user, header, missing) + public PaginatedScoreContainer(ScoreType type, Bindable<User> user, string headerText, CounterVisibilityState counterVisibilityState, string missingText = "") + : base(user, headerText, missingText, counterVisibilityState) { this.type = type; - this.includeWeight = includeWeight; ItemsPerPage = 5; + } + [BackgroundDependencyLoader] + private void load() + { ItemsContainer.Direction = FillDirection.Vertical; } - protected override void ShowMore() + protected override int GetCount(User user) { - request = new GetUserScoresRequest(User.Value.Id, type, VisiblePages++, ItemsPerPage); - request.Success += scores => Schedule(() => + switch (type) { - foreach (var s in scores) - s.Ruleset = Rulesets.GetRuleset(s.RulesetID); + case ScoreType.Firsts: + return user.ScoresFirstCount; - if (!scores.Any() && VisiblePages == 1) - { - MoreButton.Hide(); - MoreButton.IsLoading = false; - MissingText.Show(); - return; - } - - IEnumerable<DrawableProfileScore> drawableScores; - - switch (type) - { - default: - drawableScores = scores.Select(score => new DrawablePerformanceScore(score, includeWeight ? Math.Pow(0.95, ItemsContainer.Count) : (double?)null)); - break; - - case ScoreType.Recent: - drawableScores = scores.Select(score => new DrawableTotalScore(score)); - break; - } - - LoadComponentsAsync(drawableScores, s => - { - MissingText.Hide(); - MoreButton.FadeTo(scores.Count == ItemsPerPage ? 1 : 0); - MoreButton.IsLoading = false; - - ItemsContainer.AddRange(s); - }); - }); - - Api.Queue(request); + default: + return 0; + } } - protected override void Dispose(bool isDisposing) + protected override void OnItemsReceived(List<APILegacyScoreInfo> items) { - base.Dispose(isDisposing); - request?.Cancel(); + if (VisiblePages == 0) + drawableItemIndex = 0; + + base.OnItemsReceived(items); + + if (type == ScoreType.Recent) + SetCount(items.Count); + } + + protected override APIRequest<List<APILegacyScoreInfo>> CreateRequest() => + new GetUserScoresRequest(User.Value.Id, type, VisiblePages++, ItemsPerPage); + + private int drawableItemIndex; + + protected override Drawable CreateDrawableItem(APILegacyScoreInfo model) + { + switch (type) + { + default: + return new DrawableProfileScore(model.CreateScoreInfo(Rulesets)); + + case ScoreType.Best: + return new DrawableProfileWeightedScore(model.CreateScoreInfo(Rulesets), Math.Pow(0.95, drawableItemIndex++)); + } } } } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/ScoreModsContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/ScoreModsContainer.cs deleted file mode 100644 index 1ce04effa8..0000000000 --- a/osu.Game/Overlays/Profile/Sections/Ranks/ScoreModsContainer.cs +++ /dev/null @@ -1,21 +0,0 @@ -// 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. - -using osuTK; -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.UI; -using System.Collections.Generic; -using System.Linq; - -namespace osu.Game.Overlays.Profile.Sections.Ranks -{ - public class ScoreModsContainer : FlowContainer<ModIcon> - { - protected override IEnumerable<Vector2> ComputeLayoutPositions() - { - int count = FlowingChildren.Count(); - for (int i = 0; i < count; i++) - yield return new Vector2(DrawWidth * i * (count == 1 ? 0 : 1f / (count - 1)), 0); - } - } -} diff --git a/osu.Game/Overlays/Profile/Sections/RanksSection.cs b/osu.Game/Overlays/Profile/Sections/RanksSection.cs index c4b933593e..e41e414893 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", "No performance records. :(", true), - new PaginatedScoreContainer(ScoreType.Firsts, User, "First Place Ranks", "No awesome performance records yet. :("), + new PaginatedScoreContainer(ScoreType.Best, User, "Best Performance", CounterVisibilityState.AlwaysHidden, "No performance records. :("), + new PaginatedScoreContainer(ScoreType.Firsts, User, "First Place Ranks", CounterVisibilityState.AlwaysVisible) }; } } diff --git a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs index b5a508bff7..8782e82642 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs @@ -1,9 +1,11 @@ // 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. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API; @@ -11,12 +13,19 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets; namespace osu.Game.Overlays.Profile.Sections.Recent { - public class DrawableRecentActivity : DrawableProfileRow + public class DrawableRecentActivity : CompositeDrawable { - private IAPIProvider api; + private const int font_size = 14; + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } private readonly APIRecentActivity activity; @@ -28,136 +37,191 @@ namespace osu.Game.Overlays.Profile.Sections.Recent } [BackgroundDependencyLoader] - private void load(IAPIProvider api) + private void load(OverlayColourProvider colourProvider) { - this.api = api; - - LeftFlowContainer.Padding = new MarginPadding { Left = 10, Right = 160 }; - - LeftFlowContainer.Add(content = new LinkFlowContainer + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + AddInternal(new GridContainer { - AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, size: 28), + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = createIcon().With(icon => + { + icon.Anchor = Anchor.Centre; + icon.Origin = Anchor.Centre; + }) + }, + content = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: font_size)) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + }, + new DrawableDate(activity.CreatedAt) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Colour = colourProvider.Foreground1, + Font = OsuFont.GetFont(size: font_size), + } + } + } }); - RightFlowContainer.Add(new DrawableDate(activity.CreatedAt) - { - Font = OsuFont.GetFont(size: 13), - Colour = OsuColour.Gray(0xAA), - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }); - - var formatted = createMessage(); - - content.AddLinks(formatted.Text, formatted.Links); + createMessage(); } - protected override Drawable CreateLeftVisual() + private Drawable createIcon() { switch (activity.Type) { case RecentActivityType.Rank: return new UpdateableRank(activity.ScoreRank) { - RelativeSizeAxes = Axes.Y, - Width = 60, + RelativeSizeAxes = Axes.X, + Height = 11, FillMode = FillMode.Fit, + Margin = new MarginPadding { Top = 2 } }; case RecentActivityType.Achievement: - return new MedalIcon(activity.Achievement.Slug) + return new DelayedLoadWrapper(new MedalIcon(activity.Achievement.Slug) { - RelativeSizeAxes = Axes.Y, - Width = 60, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, FillMode = FillMode.Fit, + }) + { + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Height = 18 }; default: - return new Container - { - RelativeSizeAxes = Axes.Y, - Width = 60, - FillMode = FillMode.Fit, - }; + return Empty(); } } - private string toAbsoluteUrl(string url) => $"{api.Endpoint}{url}"; - - private MessageFormatter.MessageFormatterResult createMessage() + private void createMessage() { - string userLinkTemplate() => $"[{toAbsoluteUrl(activity.User?.Url)} {activity.User?.Username}]"; - string beatmapLinkTemplate() => $"[{toAbsoluteUrl(activity.Beatmap?.Url)} {activity.Beatmap?.Title}]"; - string beatmapsetLinkTemplate() => $"[{toAbsoluteUrl(activity.Beatmapset?.Url)} {activity.Beatmapset?.Title}]"; - - string message; - switch (activity.Type) { case RecentActivityType.Achievement: - message = $"{userLinkTemplate()} unlocked the {activity.Achievement.Name} medal!"; + addUserLink(); + addText($" unlocked the \"{activity.Achievement.Name}\" medal!"); break; case RecentActivityType.BeatmapPlaycount: - message = $"{beatmapLinkTemplate()} has been played {activity.Count} times!"; + addBeatmapLink(); + addText($" has been played {activity.Count} times!"); break; case RecentActivityType.BeatmapsetApprove: - message = $"{beatmapsetLinkTemplate()} has been {activity.Approval.ToString().ToLowerInvariant()}!"; + addBeatmapsetLink(); + addText($" has been {activity.Approval.ToString().ToLowerInvariant()}!"); break; case RecentActivityType.BeatmapsetDelete: - message = $"{beatmapsetLinkTemplate()} has been deleted."; + addBeatmapsetLink(); + addText(" has been deleted."); break; case RecentActivityType.BeatmapsetRevive: - message = $"{beatmapsetLinkTemplate()} has been revived from eternal slumber by {userLinkTemplate()}."; + addBeatmapsetLink(); + addText(" has been revived from eternal slumber by "); + addUserLink(); break; case RecentActivityType.BeatmapsetUpdate: - message = $"{userLinkTemplate()} has updated the beatmap {beatmapsetLinkTemplate()}!"; + addUserLink(); + addText(" has updated the beatmap "); + addBeatmapsetLink(); break; case RecentActivityType.BeatmapsetUpload: - message = $"{userLinkTemplate()} has submitted a new beatmap {beatmapsetLinkTemplate()}!"; + addUserLink(); + addText(" has submitted a new beatmap "); + addBeatmapsetLink(); break; case RecentActivityType.Medal: // apparently this shouldn't exist look at achievement instead (https://github.com/ppy/osu-web/blob/master/resources/assets/coffee/react/profile-page/recent-activity.coffee#L111) - message = string.Empty; break; case RecentActivityType.Rank: - message = $"{userLinkTemplate()} achieved rank #{activity.Rank} on {beatmapLinkTemplate()} ({activity.Mode}!)"; + addUserLink(); + addText($" achieved rank #{activity.Rank} on "); + addBeatmapLink(); + addText($" ({getRulesetName()})"); break; case RecentActivityType.RankLost: - message = $"{userLinkTemplate()} has lost first place on {beatmapLinkTemplate()} ({activity.Mode}!)"; + addUserLink(); + addText(" has lost first place on "); + addBeatmapLink(); + addText($" ({getRulesetName()})"); break; case RecentActivityType.UserSupportAgain: - message = $"{userLinkTemplate()} has once again chosen to support osu! - thanks for your generosity!"; + addUserLink(); + addText(" has once again chosen to support osu! - thanks for your generosity!"); break; case RecentActivityType.UserSupportFirst: - message = $"{userLinkTemplate()} has become an osu!supporter - thanks for your generosity!"; + addUserLink(); + addText(" has become an osu!supporter - thanks for your generosity!"); break; case RecentActivityType.UserSupportGift: - message = $"{userLinkTemplate()} has received the gift of osu!supporter!"; + addUserLink(); + addText(" has received the gift of osu!supporter!"); break; case RecentActivityType.UsernameChange: - message = $"{activity.User?.PreviousUsername} has changed their username to {userLinkTemplate()}!"; - break; - - default: - message = string.Empty; + addText($"{activity.User?.PreviousUsername} has changed their username to "); + addUserLink(); break; } - - return MessageFormatter.FormatText(message); } + + private string getRulesetName() => + rulesets.AvailableRulesets.FirstOrDefault(r => r.ShortName == activity.Mode)?.Name ?? activity.Mode; + + private void addUserLink() + => content.AddLink(activity.User?.Username, LinkAction.OpenUserProfile, getLinkArgument(activity.User?.Url), creationParameters: t => t.Font = getLinkFont(FontWeight.Bold)); + + private void addBeatmapLink() + => content.AddLink(activity.Beatmap?.Title, LinkAction.OpenBeatmap, getLinkArgument(activity.Beatmap?.Url), creationParameters: t => t.Font = getLinkFont()); + + private void addBeatmapsetLink() + => content.AddLink(activity.Beatmapset?.Title, LinkAction.OpenBeatmapSet, getLinkArgument(activity.Beatmapset?.Url), creationParameters: t => t.Font = getLinkFont()); + + private string getLinkArgument(string url) => MessageFormatter.GetLinkDetails($"{api.Endpoint}{url}").Argument; + + private FontUsage getLinkFont(FontWeight fontWeight = FontWeight.Regular) + => OsuFont.GetFont(size: font_size, weight: fontWeight, italics: true); + + private void addText(string text) + => content.AddText(text, t => t.Font = OsuFont.GetFont(size: font_size, weight: FontWeight.SemiBold)); } } diff --git a/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs b/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs index 56ff4d4dec..0c1f8b2e92 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/MedalIcon.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Overlays.Profile.Sections.Recent { + [LongRunningLoad] public class MedalIcon : Container { private readonly string slug; @@ -22,8 +23,7 @@ namespace osu.Game.Overlays.Profile.Sections.Recent Child = sprite = new Sprite { - Height = 40, - Width = 40, + RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, }; diff --git a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs index f2a778a874..08f39c6272 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs @@ -4,51 +4,32 @@ using osu.Framework.Graphics; using osu.Game.Online.API.Requests; using osu.Game.Users; -using System.Linq; using osu.Framework.Bindables; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.API; +using System.Collections.Generic; +using osuTK; +using osu.Framework.Allocation; namespace osu.Game.Overlays.Profile.Sections.Recent { - public class PaginatedRecentActivityContainer : PaginatedContainer + public class PaginatedRecentActivityContainer : PaginatedContainer<APIRecentActivity> { - private GetUserRecentActivitiesRequest request; - - public PaginatedRecentActivityContainer(Bindable<User> user, string header, string missing) - : base(user, header, missing) + public PaginatedRecentActivityContainer(Bindable<User> user) + : base(user, missingText: "This user hasn't done anything notable recently!") { - ItemsPerPage = 5; + ItemsPerPage = 10; } - protected override void ShowMore() + [BackgroundDependencyLoader] + private void load() { - request = new GetUserRecentActivitiesRequest(User.Value.Id, VisiblePages++, ItemsPerPage); - request.Success += activities => Schedule(() => - { - MoreButton.FadeTo(activities.Count == ItemsPerPage ? 1 : 0); - MoreButton.IsLoading = false; - - if (!activities.Any() && VisiblePages == 1) - { - MissingText.Show(); - return; - } - - MissingText.Hide(); - - foreach (APIRecentActivity activity in activities) - { - ItemsContainer.Add(new DrawableRecentActivity(activity)); - } - }); - - Api.Queue(request); + ItemsContainer.Spacing = new Vector2(0, 8); } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - request?.Cancel(); - } + protected override APIRequest<List<APIRecentActivity>> CreateRequest() => + new GetUserRecentActivitiesRequest(User.Value.Id, VisiblePages++, ItemsPerPage); + + protected override Drawable CreateDrawableItem(APIRecentActivity model) => new DrawableRecentActivity(model); } } diff --git a/osu.Game/Overlays/Profile/Sections/RecentSection.cs b/osu.Game/Overlays/Profile/Sections/RecentSection.cs index 8fcc5cc7c0..1e6cfcc9fd 100644 --- a/osu.Game/Overlays/Profile/Sections/RecentSection.cs +++ b/osu.Game/Overlays/Profile/Sections/RecentSection.cs @@ -15,7 +15,7 @@ namespace osu.Game.Overlays.Profile.Sections { Children = new[] { - new PaginatedRecentActivityContainer(User, null, @"This user hasn't done anything notable recently!"), + new PaginatedRecentActivityContainer(User), }; } } diff --git a/osu.Game/Overlays/Profile/Sections/ShowMoreButton.cs b/osu.Game/Overlays/Profile/Sections/ShowMoreButton.cs deleted file mode 100644 index 5ed546c62b..0000000000 --- a/osu.Game/Overlays/Profile/Sections/ShowMoreButton.cs +++ /dev/null @@ -1,146 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osuTK; -using System.Collections.Generic; - -namespace osu.Game.Overlays.Profile.Sections -{ - public class ShowMoreButton : OsuHoverContainer - { - private const float fade_duration = 200; - - private readonly Box background; - private readonly LoadingAnimation loading; - private readonly FillFlowContainer content; - - protected override IEnumerable<Drawable> EffectTargets => new[] { background }; - - private bool isLoading; - - public bool IsLoading - { - get => isLoading; - set - { - if (isLoading == value) - return; - - isLoading = value; - - Enabled.Value = !isLoading; - - if (value) - { - loading.FadeIn(fade_duration, Easing.OutQuint); - content.FadeOut(fade_duration, Easing.OutQuint); - } - else - { - loading.FadeOut(fade_duration, Easing.OutQuint); - content.FadeIn(fade_duration, Easing.OutQuint); - } - } - } - - public ShowMoreButton() - { - AutoSizeAxes = Axes.Both; - Children = new Drawable[] - { - new CircularContainer - { - Masking = true, - Size = new Vector2(140, 30), - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both, - }, - content = new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(7), - Children = new Drawable[] - { - new ChevronIcon(), - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = "show more".ToUpper(), - }, - new ChevronIcon(), - } - }, - loading = new LoadingAnimation - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(12) - }, - } - } - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colors) - { - IdleColour = colors.GreySeafoamDark; - HoverColour = colors.GreySeafoam; - } - - protected override bool OnClick(ClickEvent e) - { - if (!Enabled.Value) - return false; - - try - { - return base.OnClick(e); - } - finally - { - // run afterwards as this will disable this button. - IsLoading = true; - } - } - - private class ChevronIcon : SpriteIcon - { - private const int bottom_margin = 2; - private const int icon_size = 8; - - public ChevronIcon() - { - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - Margin = new MarginPadding { Bottom = bottom_margin }; - Size = new Vector2(icon_size); - Icon = FontAwesome.Solid.ChevronDown; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colors) - { - Colour = colors.Yellow; - } - } - } -} diff --git a/osu.Game/Overlays/Rankings/CountryFilter.cs b/osu.Game/Overlays/Rankings/CountryFilter.cs new file mode 100644 index 0000000000..4bdefb06ef --- /dev/null +++ b/osu.Game/Overlays/Rankings/CountryFilter.cs @@ -0,0 +1,105 @@ +// 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. + +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.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Overlays.Rankings +{ + public class CountryFilter : CompositeDrawable, IHasCurrentValue<Country> + { + private const int duration = 200; + private const int height = 50; + + private readonly BindableWithCurrent<Country> current = new BindableWithCurrent<Country>(); + + public Bindable<Country> Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly Box background; + private readonly CountryPill countryPill; + private readonly Container content; + + public CountryFilter() + { + RelativeSizeAxes = Axes.X; + + InternalChild = content = new Container + { + RelativeSizeAxes = Axes.X, + Height = height, + Alpha = 0, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Margin = new MarginPadding { Left = UserProfileOverlay.CONTENT_X_MARGIN }, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = @"filtered by country:", + Font = OsuFont.GetFont(size: 14) + }, + countryPill = new CountryPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Alpha = 0, + Current = Current + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Dark3; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Current.BindValueChanged(onCountryChanged, true); + } + + private void onCountryChanged(ValueChangedEvent<Country> country) + { + if (country.NewValue == null) + { + countryPill.Collapse(); + this.ResizeHeightTo(0, duration, Easing.OutQuint); + content.FadeOut(duration, Easing.OutQuint); + return; + } + + this.ResizeHeightTo(height, duration, Easing.OutQuint); + content.FadeIn(duration, Easing.OutQuint); + countryPill.Expand(); + } + } +} diff --git a/osu.Game/Overlays/Rankings/CountryPill.cs b/osu.Game/Overlays/Rankings/CountryPill.cs new file mode 100644 index 0000000000..1b19bbd95e --- /dev/null +++ b/osu.Game/Overlays/Rankings/CountryPill.cs @@ -0,0 +1,164 @@ +// 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. + +using System.Collections.Generic; +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.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Rankings +{ + public class CountryPill : CompositeDrawable, IHasCurrentValue<Country> + { + private const int duration = 200; + + private readonly BindableWithCurrent<Country> current = new BindableWithCurrent<Country>(); + + public Bindable<Country> Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly Container content; + private readonly Box background; + private readonly UpdateableFlag flag; + private readonly OsuSpriteText countryName; + + public CountryPill() + { + AutoSizeAxes = Axes.Both; + + InternalChild = content = new CircularContainer + { + Height = 25, + AutoSizeDuration = duration, + AutoSizeEasing = Easing.OutQuint, + Masking = true, + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Margin = new MarginPadding { Horizontal = 10 }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(8, 0), + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(3, 0), + Children = new Drawable[] + { + flag = new UpdateableFlag + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(22, 15) + }, + countryName = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(size: 14) + } + } + }, + new CloseButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => Current.Value = null + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background5; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Current.BindValueChanged(onCountryChanged, true); + } + + public void Expand() + { + content.ClearTransforms(); + content.AutoSizeAxes = Axes.X; + + this.FadeIn(duration, Easing.OutQuint); + } + + public void Collapse() + { + content.ClearTransforms(); + content.AutoSizeAxes = Axes.None; + content.ResizeWidthTo(0, duration, Easing.OutQuint); + + this.FadeOut(duration, Easing.OutQuint); + } + + private void onCountryChanged(ValueChangedEvent<Country> country) + { + if (country.NewValue == null) + return; + + flag.Country = country.NewValue; + countryName.Text = country.NewValue.FullName; + } + + private class CloseButton : OsuHoverContainer + { + private readonly SpriteIcon icon; + + protected override IEnumerable<Drawable> EffectTargets => new[] { icon }; + + public CloseButton() + { + AutoSizeAxes = Axes.Both; + Add(icon = new SpriteIcon + { + Size = new Vector2(8), + Icon = FontAwesome.Solid.Times + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Light2; + HoverColour = Color4.White; + } + } + } +} diff --git a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs new file mode 100644 index 0000000000..92e22f5873 --- /dev/null +++ b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs @@ -0,0 +1,46 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Game.Rulesets; +using osu.Game.Users; + +namespace osu.Game.Overlays.Rankings +{ + public class RankingsOverlayHeader : TabControlOverlayHeader<RankingsScope> + { + public Bindable<RulesetInfo> Ruleset => rulesetSelector.Current; + + public Bindable<Country> Country => countryFilter.Current; + + private OverlayRulesetSelector rulesetSelector; + private CountryFilter countryFilter; + + protected override OverlayTitle CreateTitle() => new RankingsTitle(); + + protected override Drawable CreateTitleContent() => rulesetSelector = new OverlayRulesetSelector(); + + protected override Drawable CreateContent() => countryFilter = new CountryFilter(); + + protected override Drawable CreateBackground() => new OverlayHeaderBackground("Headers/rankings"); + + private class RankingsTitle : OverlayTitle + { + public RankingsTitle() + { + Title = "ranking"; + Description = "find out who's the best right now"; + IconTexture = "Icons/Hexacons/rankings"; + } + } + } + + public enum RankingsScope + { + Performance, + Spotlights, + Score, + Country + } +} diff --git a/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs new file mode 100644 index 0000000000..c0bbf46e30 --- /dev/null +++ b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs @@ -0,0 +1,19 @@ +// 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. + +namespace osu.Game.Overlays.Rankings +{ + public class RankingsSortTabControl : OverlaySortTabControl<RankingsSortCriteria> + { + public RankingsSortTabControl() + { + Title = "Show"; + } + } + + public enum RankingsSortCriteria + { + All, + Friends + } +} diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs new file mode 100644 index 0000000000..422373d099 --- /dev/null +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -0,0 +1,185 @@ +// 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. + +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.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; +using osuTK; +using System; +using System.Collections.Generic; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Online.API.Requests; + +namespace osu.Game.Overlays.Rankings +{ + public class SpotlightSelector : CompositeDrawable, IHasCurrentValue<APISpotlight> + { + private readonly BindableWithCurrent<APISpotlight> current = new BindableWithCurrent<APISpotlight>(); + public readonly Bindable<RankingsSortCriteria> Sort = new Bindable<RankingsSortCriteria>(); + + public Bindable<APISpotlight> Current + { + get => current.Current; + set => current.Current = value; + } + + public IEnumerable<APISpotlight> Spotlights + { + get => dropdown.Items; + set => dropdown.Items = value; + } + + private readonly Box background; + private readonly SpotlightsDropdown dropdown; + private readonly InfoColumn startDateColumn; + private readonly InfoColumn endDateColumn; + private readonly InfoColumn mapCountColumn; + private readonly InfoColumn participantsColumn; + + public SpotlightSelector() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChildren = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = UserProfileOverlay.CONTENT_X_MARGIN }, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new Container + { + Margin = new MarginPadding { Vertical = 20 }, + RelativeSizeAxes = Axes.X, + Height = 40, + Depth = -float.MaxValue, + Child = dropdown = new SpotlightsDropdown + { + RelativeSizeAxes = Axes.X, + Current = Current + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Margin = new MarginPadding { Bottom = 5 }, + Children = new Drawable[] + { + startDateColumn = new InfoColumn(@"Start Date"), + endDateColumn = new InfoColumn(@"End Date"), + mapCountColumn = new InfoColumn(@"Map Count"), + participantsColumn = new InfoColumn(@"Participants") + } + }, + new RankingsSortTabControl + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Current = Sort + } + } + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Dark3; + } + + public void ShowInfo(GetSpotlightRankingsResponse response) + { + startDateColumn.Value = dateToString(response.Spotlight.StartDate); + endDateColumn.Value = dateToString(response.Spotlight.EndDate); + mapCountColumn.Value = response.BeatmapSets.Count.ToString(); + participantsColumn.Value = response.Spotlight.Participants?.ToString("N0"); + } + + private string dateToString(DateTimeOffset date) => date.ToString("yyyy-MM-dd"); + + private class InfoColumn : FillFlowContainer + { + public string Value + { + set => valueText.Text = value; + } + + private readonly OsuSpriteText valueText; + + public InfoColumn(string name) + { + AutoSizeAxes = Axes.Both; + Direction = FillDirection.Vertical; + Margin = new MarginPadding { Vertical = 10 }; + Children = new Drawable[] + { + new OsuSpriteText + { + Text = name, + Font = OsuFont.GetFont(size: 10, weight: FontWeight.Regular), + }, + new Container + { + AutoSizeAxes = Axes.X, + Height = 20, + Child = valueText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Light), + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + valueText.Colour = colourProvider.Content2; + } + } + + private class SpotlightsDropdown : OsuDropdown<APISpotlight> + { + private DropdownMenu menu; + + protected override DropdownMenu CreateMenu() => menu = base.CreateMenu().With(m => m.MaxHeight = 400); + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + menu.BackgroundColour = colourProvider.Background5; + AccentColour = colourProvider.Background6; + } + } + } +} diff --git a/osu.Game/Overlays/Rankings/SpotlightsLayout.cs b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs new file mode 100644 index 0000000000..61339df76f --- /dev/null +++ b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs @@ -0,0 +1,163 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Game.Rulesets; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osuTK; +using osu.Framework.Allocation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Rankings.Tables; +using System.Linq; +using System.Threading; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays.BeatmapListing.Panels; + +namespace osu.Game.Overlays.Rankings +{ + public class SpotlightsLayout : CompositeDrawable + { + public readonly Bindable<RulesetInfo> Ruleset = new Bindable<RulesetInfo>(); + + private readonly Bindable<APISpotlight> selectedSpotlight = new Bindable<APISpotlight>(); + private readonly Bindable<RankingsSortCriteria> sort = new Bindable<RankingsSortCriteria>(); + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } + + private CancellationTokenSource cancellationToken; + private GetSpotlightRankingsRequest getRankingsRequest; + private GetSpotlightsRequest spotlightsRequest; + + private SpotlightSelector selector; + private Container content; + private LoadingLayer loading; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = new ReverseChildIDFillFlowContainer<Drawable> + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + selector = new SpotlightSelector + { + Current = selectedSpotlight, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Vertical = 10 } + }, + loading = new LoadingLayer(content) + } + } + } + }; + + sort.BindTo(selector.Sort); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedSpotlight.BindValueChanged(_ => onSpotlightChanged()); + sort.BindValueChanged(_ => onSpotlightChanged()); + Ruleset.BindValueChanged(onRulesetChanged); + + getSpotlights(); + } + + private void getSpotlights() + { + spotlightsRequest = new GetSpotlightsRequest(); + spotlightsRequest.Success += response => Schedule(() => selector.Spotlights = response.Spotlights); + api.Queue(spotlightsRequest); + } + + private void onRulesetChanged(ValueChangedEvent<RulesetInfo> ruleset) + { + if (!selector.Spotlights.Any()) + return; + + selectedSpotlight.TriggerChange(); + } + + private void onSpotlightChanged() + { + loading.Show(); + + cancellationToken?.Cancel(); + getRankingsRequest?.Cancel(); + + getRankingsRequest = new GetSpotlightRankingsRequest(Ruleset.Value, selectedSpotlight.Value.Id, sort.Value); + getRankingsRequest.Success += onSuccess; + api.Queue(getRankingsRequest); + } + + private void onSuccess(GetSpotlightRankingsResponse response) + { + LoadComponentAsync(createContent(response), loaded => + { + selector.ShowInfo(response); + + content.Clear(); + content.Add(loaded); + + loading.Hide(); + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + private Drawable createContent(GetSpotlightRankingsResponse response) => new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Children = new Drawable[] + { + new ScoresTable(1, response.Users), + new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Spacing = new Vector2(10), + Children = response.BeatmapSets.Select(b => new GridBeatmapPanel(b.ToBeatmapSet(rulesets)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }).ToList() + } + } + }; + + protected override void Dispose(bool isDisposing) + { + spotlightsRequest?.Cancel(); + getRankingsRequest?.Cancel(); + cancellationToken?.Cancel(); + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs new file mode 100644 index 0000000000..0b9a48ce0e --- /dev/null +++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs @@ -0,0 +1,97 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using System; +using osu.Game.Users; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.Rankings.Tables +{ + public class CountriesTable : RankingsTable<CountryStatistics> + { + public CountriesTable(int page, IReadOnlyList<CountryStatistics> rankings) + : base(page, rankings) + { + } + + protected override TableColumn[] CreateAdditionalHeaders() => new[] + { + new TableColumn("Active Users", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("Play Count", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("Ranked Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("Avg. Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("Performance", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("Avg. Perf.", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + }; + + protected override Country GetCountry(CountryStatistics item) => item.Country; + + protected override Drawable CreateFlagContent(CountryStatistics item) => new CountryName(item.Country); + + protected override Drawable[] CreateAdditionalContent(CountryStatistics item) => new Drawable[] + { + new ColoredRowText + { + Text = $@"{item.ActiveUsers:N0}", + }, + new ColoredRowText + { + Text = $@"{item.PlayCount:N0}", + }, + new ColoredRowText + { + Text = $@"{item.RankedScore:N0}", + }, + new ColoredRowText + { + Text = $@"{item.RankedScore / Math.Max(item.ActiveUsers, 1):N0}", + }, + new RowText + { + Text = $@"{item.Performance:N0}", + }, + new ColoredRowText + { + Text = $@"{item.Performance / Math.Max(item.ActiveUsers, 1):N0}", + } + }; + + private class CountryName : OsuHoverContainer + { + protected override IEnumerable<Drawable> EffectTargets => new[] { text }; + + [Resolved(canBeNull: true)] + private RankingsOverlay rankings { get; set; } + + private readonly OsuSpriteText text; + private readonly Country country; + + public CountryName(Country country) + { + this.country = country; + + AutoSizeAxes = Axes.Both; + Add(text = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 12), + Text = country.FullName ?? string.Empty, + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + IdleColour = colourProvider.Light2; + HoverColour = colourProvider.Content2; + + Action = () => rankings?.ShowCountry(country); + } + } + } +} diff --git a/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs new file mode 100644 index 0000000000..1e6b2307e0 --- /dev/null +++ b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs @@ -0,0 +1,28 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Users; + +namespace osu.Game.Overlays.Rankings.Tables +{ + public class PerformanceTable : UserBasedTable + { + public PerformanceTable(int page, IReadOnlyList<UserStatistics> rankings) + : base(page, rankings) + { + } + + protected override TableColumn[] CreateUniqueHeaders() => new[] + { + new TableColumn("Performance", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + }; + + protected override Drawable[] CreateUniqueContent(UserStatistics item) => new Drawable[] + { + new RowText { Text = $@"{item.PP:N0}", } + }; + } +} diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs new file mode 100644 index 0000000000..943897581e --- /dev/null +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -0,0 +1,140 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Overlays.Rankings.Tables +{ + public abstract class RankingsTable<TModel> : TableContainer + { + protected const int TEXT_SIZE = 12; + private const float horizontal_inset = 20; + private const float row_height = 25; + private const int items_per_page = 50; + + private readonly int page; + private readonly IReadOnlyList<TModel> rankings; + + protected RankingsTable(int page, IReadOnlyList<TModel> rankings) + { + this.page = page; + this.rankings = rankings; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Padding = new MarginPadding { Horizontal = horizontal_inset }; + RowSize = new Dimension(GridSizeMode.Absolute, row_height); + } + + [BackgroundDependencyLoader] + private void load() + { + FillFlowContainer backgroundFlow; + + AddInternal(backgroundFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Depth = 1f, + Margin = new MarginPadding { Top = row_height } + }); + + rankings.ForEach(_ => backgroundFlow.Add(new TableRowBackground())); + + Columns = mainHeaders.Concat(CreateAdditionalHeaders()).ToArray(); + Content = rankings.Select((s, i) => createContent((page - 1) * items_per_page + i, s)).ToArray().ToRectangular(); + } + + private Drawable[] createContent(int index, TModel item) => new Drawable[] { createIndexDrawable(index), createMainContent(item) }.Concat(CreateAdditionalContent(item)).ToArray(); + + private static TableColumn[] mainHeaders => new[] + { + new TableColumn(string.Empty, Anchor.Centre, new Dimension(GridSizeMode.Absolute, 40)), // place + new TableColumn(string.Empty, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed)), // flag and username (country name) + }; + + protected abstract TableColumn[] CreateAdditionalHeaders(); + + protected abstract Drawable[] CreateAdditionalContent(TModel item); + + protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? string.Empty, HighlightedColumn()); + + protected abstract Country GetCountry(TModel item); + + protected abstract Drawable CreateFlagContent(TModel item); + + private OsuSpriteText createIndexDrawable(int index) => new OsuSpriteText + { + Text = $"#{index + 1}", + Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.SemiBold) + }; + + private FillFlowContainer createMainContent(TModel item) => new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(7, 0), + Children = new[] + { + new UpdateableFlag(GetCountry(item)) + { + Size = new Vector2(20, 13), + ShowPlaceholderOnNull = false, + }, + CreateFlagContent(item) + } + }; + + protected virtual string HighlightedColumn() => @"Performance"; + + private class HeaderText : OsuSpriteText + { + private readonly string highlighted; + + public HeaderText(string text, string highlighted) + { + this.highlighted = highlighted; + + Text = text; + Font = OsuFont.GetFont(size: 12); + Margin = new MarginPadding { Horizontal = 10 }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + if (Text != highlighted) + Colour = colourProvider.Foreground1; + } + } + + protected class RowText : OsuSpriteText + { + public RowText() + { + Font = OsuFont.GetFont(size: TEXT_SIZE); + Margin = new MarginPadding { Horizontal = 10 }; + } + } + + protected class ColoredRowText : RowText + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Foreground1; + } + } + } +} diff --git a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs new file mode 100644 index 0000000000..370ee506c2 --- /dev/null +++ b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs @@ -0,0 +1,38 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Users; + +namespace osu.Game.Overlays.Rankings.Tables +{ + public class ScoresTable : UserBasedTable + { + public ScoresTable(int page, IReadOnlyList<UserStatistics> rankings) + : base(page, rankings) + { + } + + protected override TableColumn[] CreateUniqueHeaders() => new[] + { + new TableColumn("Total Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("Ranked Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)) + }; + + protected override Drawable[] CreateUniqueContent(UserStatistics item) => new Drawable[] + { + new ColoredRowText + { + Text = $@"{item.TotalScore:N0}", + }, + new RowText + { + Text = $@"{item.RankedScore:N0}", + } + }; + + protected override string HighlightedColumn() => @"Ranked Score"; + } +} diff --git a/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs b/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs new file mode 100644 index 0000000000..fe87a8b3d4 --- /dev/null +++ b/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs @@ -0,0 +1,55 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Rankings.Tables +{ + public class TableRowBackground : CompositeDrawable + { + private const int fade_duration = 100; + + private readonly Box background; + + private Color4 idleColour; + private Color4 hoverColour; + + public TableRowBackground() + { + RelativeSizeAxes = Axes.X; + Height = 25; + + CornerRadius = 3; + Masking = true; + + InternalChild = background = new Box + { + RelativeSizeAxes = Axes.Both, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = idleColour = colourProvider.Background4; + hoverColour = colourProvider.Background3; + } + + protected override bool OnHover(HoverEvent e) + { + background.FadeColour(hoverColour, fade_duration, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + background.FadeColour(idleColour, fade_duration, Easing.OutQuint); + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs new file mode 100644 index 0000000000..cad7364103 --- /dev/null +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -0,0 +1,57 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Users; +using osu.Game.Scoring; + +namespace osu.Game.Overlays.Rankings.Tables +{ + public abstract class UserBasedTable : RankingsTable<UserStatistics> + { + protected UserBasedTable(int page, IReadOnlyList<UserStatistics> rankings) + : base(page, rankings) + { + } + + protected override TableColumn[] CreateAdditionalHeaders() => new[] + { + new TableColumn("Accuracy", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("Play Count", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + }.Concat(CreateUniqueHeaders()).Concat(new[] + { + new TableColumn("SS", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("S", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("A", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + }).ToArray(); + + protected sealed override Country GetCountry(UserStatistics item) => item.User.Country; + + protected sealed override Drawable CreateFlagContent(UserStatistics item) + { + var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE, italics: true)) { AutoSizeAxes = Axes.Both }; + username.AddUserLink(item.User); + return username; + } + + protected sealed override Drawable[] CreateAdditionalContent(UserStatistics item) => new[] + { + new ColoredRowText { Text = item.DisplayAccuracy, }, + new ColoredRowText { Text = $@"{item.PlayCount:N0}", }, + }.Concat(CreateUniqueContent(item)).Concat(new[] + { + new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.XH] + item.GradesCount[ScoreRank.X]:N0}", }, + new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.SH] + item.GradesCount[ScoreRank.S]:N0}", }, + new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.A]:N0}", } + }).ToArray(); + + protected abstract TableColumn[] CreateUniqueHeaders(); + + protected abstract Drawable[] CreateUniqueContent(UserStatistics item); + } +} diff --git a/osu.Game/Overlays/RankingsOverlay.cs b/osu.Game/Overlays/RankingsOverlay.cs new file mode 100644 index 0000000000..ae6d49960a --- /dev/null +++ b/osu.Game/Overlays/RankingsOverlay.cs @@ -0,0 +1,243 @@ +// 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. + +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.Overlays.Rankings; +using osu.Game.Users; +using osu.Game.Rulesets; +using osu.Game.Online.API; +using System.Threading; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Rankings.Tables; + +namespace osu.Game.Overlays +{ + public class RankingsOverlay : FullscreenOverlay<RankingsOverlayHeader> + { + protected Bindable<Country> Country => Header.Country; + + protected Bindable<RankingsScope> Scope => Header.Current; + + private readonly OverlayScrollContainer scrollFlow; + private readonly Container contentContainer; + private readonly LoadingLayer loading; + private readonly Box background; + + private APIRequest lastRequest; + private CancellationTokenSource cancellationToken; + + [Resolved] + private IAPIProvider api { get; set; } + + public RankingsOverlay() + : base(OverlayColourScheme.Green, new RankingsOverlayHeader + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Depth = -float.MaxValue + }) + { + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + scrollFlow = new OverlayScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + Header, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + contentContainer = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Bottom = 10 } + }, + loading = new LoadingLayer(contentContainer), + } + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load() + { + background.Colour = ColourProvider.Background5; + } + + [Resolved] + private Bindable<RulesetInfo> ruleset { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Header.Ruleset.BindTo(ruleset); + + Country.BindValueChanged(_ => + { + // if a country is requested, force performance scope. + if (Country.Value != null) + Scope.Value = RankingsScope.Performance; + + Scheduler.AddOnce(loadNewContent); + }); + + Scope.BindValueChanged(_ => + { + // country filtering is only valid for performance scope. + if (Scope.Value != RankingsScope.Performance) + Country.Value = null; + + Scheduler.AddOnce(loadNewContent); + }); + + ruleset.BindValueChanged(_ => + { + if (Scope.Value == RankingsScope.Spotlights) + return; + + Scheduler.AddOnce(loadNewContent); + }); + + Scheduler.AddOnce(loadNewContent); + } + + public void ShowCountry(Country requested) + { + if (requested == null) + return; + + Show(); + + Country.Value = requested; + } + + public void ShowSpotlights() + { + Scope.Value = RankingsScope.Spotlights; + Show(); + } + + private void loadNewContent() + { + loading.Show(); + + cancellationToken?.Cancel(); + lastRequest?.Cancel(); + + if (Scope.Value == RankingsScope.Spotlights) + { + loadContent(new SpotlightsLayout + { + Ruleset = { BindTarget = ruleset } + }); + return; + } + + var request = createScopedRequest(); + lastRequest = request; + + if (request == null) + { + loadContent(null); + return; + } + + request.Success += () => Schedule(() => loadContent(createTableFromResponse(request))); + request.Failure += _ => Schedule(() => loadContent(null)); + + api.Queue(request); + } + + private APIRequest createScopedRequest() + { + switch (Scope.Value) + { + case RankingsScope.Performance: + return new GetUserRankingsRequest(ruleset.Value, country: Country.Value?.FlagName); + + case RankingsScope.Country: + return new GetCountryRankingsRequest(ruleset.Value); + + case RankingsScope.Score: + return new GetUserRankingsRequest(ruleset.Value, UserRankingsType.Score); + } + + return null; + } + + private Drawable createTableFromResponse(APIRequest request) + { + switch (request) + { + case GetUserRankingsRequest userRequest: + switch (userRequest.Type) + { + case UserRankingsType.Performance: + return new PerformanceTable(1, userRequest.Result.Users); + + case UserRankingsType.Score: + return new ScoresTable(1, userRequest.Result.Users); + } + + return null; + + case GetCountryRankingsRequest countryRequest: + return new CountriesTable(1, countryRequest.Result.Countries); + } + + return null; + } + + private void loadContent(Drawable content) + { + scrollFlow.ScrollToStart(); + + if (content == null) + { + contentContainer.Clear(); + loading.Hide(); + return; + } + + LoadComponentAsync(content, loaded => + { + loading.Hide(); + contentContainer.Child = loaded; + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + protected override void Dispose(bool isDisposing) + { + lastRequest?.Cancel(); + cancellationToken?.Cancel(); + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs b/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs index 0808cc8fcc..ffbc1c9586 100644 --- a/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs +++ b/osu.Game/Overlays/SearchableList/DisplayStyleControl.cs @@ -10,43 +10,23 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.SearchableList { - public class DisplayStyleControl<T> : Container + public class DisplayStyleControl : CompositeDrawable { - public readonly SlimEnumDropdown<T> Dropdown; public readonly Bindable<PanelDisplayStyle> DisplayStyle = new Bindable<PanelDisplayStyle>(); public DisplayStyleControl() { AutoSizeAxes = Axes.Both; - Children = new[] + InternalChild = new FillFlowContainer { - new FillFlowContainer + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5f, 0f), + Direction = FillDirection.Horizontal, + Children = new[] { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Spacing = new Vector2(10f, 0f), - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5f, 0f), - Direction = FillDirection.Horizontal, - Children = new[] - { - new DisplayStyleToggleButton(FontAwesome.Solid.ThLarge, PanelDisplayStyle.Grid, DisplayStyle), - new DisplayStyleToggleButton(FontAwesome.Solid.ListUl, PanelDisplayStyle.List, DisplayStyle), - }, - }, - Dropdown = new SlimEnumDropdown<T> - { - RelativeSizeAxes = Axes.None, - Width = 160f, - }, - }, + new DisplayStyleToggleButton(FontAwesome.Solid.ThLarge, PanelDisplayStyle.Grid, DisplayStyle), + new DisplayStyleToggleButton(FontAwesome.Solid.ListUl, PanelDisplayStyle.List, DisplayStyle), }, }; @@ -89,6 +69,8 @@ namespace osu.Game.Overlays.SearchableList protected override void Dispose(bool isDisposing) { + base.Dispose(isDisposing); + bindable.ValueChanged -= Bindable_ValueChanged; } } diff --git a/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs b/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs index b0a8a0e77d..1990674aa9 100644 --- a/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs +++ b/osu.Game/Overlays/SearchableList/SearchableListFilterControl.cs @@ -13,36 +13,38 @@ using osu.Framework.Graphics.Shapes; namespace osu.Game.Overlays.SearchableList { - public abstract class SearchableListFilterControl<T, U> : Container + public abstract class SearchableListFilterControl<TTab, TCategory> : Container + where TTab : struct, Enum + where TCategory : struct, Enum { private const float padding = 10; - private readonly Container filterContainer; + private readonly Drawable filterContainer; + private readonly Drawable rightFilterContainer; private readonly Box tabStrip; public readonly SearchTextBox Search; - public readonly PageTabControl<T> Tabs; - public readonly DisplayStyleControl<U> DisplayStyleControl; + public readonly PageTabControl<TTab> Tabs; + public readonly SlimEnumDropdown<TCategory> Dropdown; + public readonly DisplayStyleControl DisplayStyleControl; protected abstract Color4 BackgroundColour { get; } - protected abstract T DefaultTab { get; } + protected abstract TTab DefaultTab { get; } + protected abstract TCategory DefaultCategory { get; } protected virtual Drawable CreateSupplementaryControls() => null; /// <summary> /// The amount of padding added to content (does not affect background or tab control strip). /// </summary> - protected virtual float ContentHorizontalPadding => SearchableListOverlay.WIDTH_PADDING; + protected virtual float ContentHorizontalPadding => WaveOverlayContainer.WIDTH_PADDING; protected SearchableListFilterControl() { - if (!typeof(T).IsEnum) - throw new InvalidOperationException("SearchableListFilterControl's sort tabs only support enums as the generic type argument"); - RelativeSizeAxes = Axes.X; var controls = CreateSupplementaryControls(); Container controlsContainer; - Children = new Drawable[] + Children = new[] { filterContainer = new Container { @@ -84,11 +86,17 @@ namespace osu.Game.Overlays.SearchableList AutoSizeAxes = Axes.Y, Margin = new MarginPadding { Top = controls != null ? padding : 0 }, }, - Tabs = new PageTabControl<T> + new Container { RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 225 }, + Child = Tabs = new PageTabControl<TTab> + { + RelativeSizeAxes = Axes.X, + }, }, - new Box //keep the tab strip part of autosize, but don't put it in the flow container + new Box // keep the tab strip part of autosize, but don't put it in the flow container { RelativeSizeAxes = Axes.X, Height = 1, @@ -98,17 +106,36 @@ namespace osu.Game.Overlays.SearchableList }, }, }, - DisplayStyleControl = new DisplayStyleControl<U> + rightFilterContainer = new FillFlowContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - }, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + Dropdown = new SlimEnumDropdown<TCategory> + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.None, + Width = 160f, + }, + DisplayStyleControl = new DisplayStyleControl + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + } + } }; if (controls != null) controlsContainer.Children = new[] { controls }; Tabs.Current.Value = DefaultTab; Tabs.Current.TriggerChange(); + + Dropdown.Current.Value = DefaultCategory; + Dropdown.Current.TriggerChange(); } [BackgroundDependencyLoader] @@ -122,13 +149,11 @@ namespace osu.Game.Overlays.SearchableList base.Update(); Height = filterContainer.Height; - DisplayStyleControl.Margin = new MarginPadding { Top = filterContainer.Height - 35, Right = SearchableListOverlay.WIDTH_PADDING }; + rightFilterContainer.Margin = new MarginPadding { Top = filterContainer.Height - 30, Right = ContentHorizontalPadding }; } private class FilterSearchTextBox : SearchTextBox { - protected override bool AllowCommit => true; - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Overlays/SearchableList/SearchableListHeader.cs b/osu.Game/Overlays/SearchableList/SearchableListHeader.cs deleted file mode 100644 index 73dca956d1..0000000000 --- a/osu.Game/Overlays/SearchableList/SearchableListHeader.cs +++ /dev/null @@ -1,84 +0,0 @@ -// 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. - -using System; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; - -namespace osu.Game.Overlays.SearchableList -{ - public abstract class SearchableListHeader<T> : Container - { - public readonly HeaderTabControl<T> Tabs; - - protected abstract Color4 BackgroundColour { get; } - protected abstract T DefaultTab { get; } - protected abstract Drawable CreateHeaderText(); - protected abstract IconUsage Icon { get; } - - protected SearchableListHeader() - { - if (!typeof(T).IsEnum) - throw new InvalidOperationException("BrowseHeader only supports enums as the generic type argument"); - - RelativeSizeAxes = Axes.X; - Height = 90; - - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = BackgroundColour, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = SearchableListOverlay.WIDTH_PADDING, Right = SearchableListOverlay.WIDTH_PADDING }, - Children = new Drawable[] - { - new FillFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.BottomLeft, - Position = new Vector2(-35f, 5f), - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10f, 0f), - Children = new[] - { - new SpriteIcon - { - Size = new Vector2(25), - Icon = Icon, - }, - CreateHeaderText(), - }, - }, - Tabs = new HeaderTabControl<T> - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - }, - }, - }, - }; - - Tabs.Current.Value = DefaultTab; - Tabs.Current.TriggerChange(); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Tabs.StripColour = colours.Green; - } - } -} diff --git a/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs b/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs deleted file mode 100644 index 293ee4bcda..0000000000 --- a/osu.Game/Overlays/SearchableList/SearchableListOverlay.cs +++ /dev/null @@ -1,121 +0,0 @@ -// 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. - -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Graphics.Backgrounds; -using osu.Game.Graphics.Containers; - -namespace osu.Game.Overlays.SearchableList -{ - public abstract class SearchableListOverlay : FullscreenOverlay - { - public static readonly float WIDTH_PADDING = 80; - } - - public abstract class SearchableListOverlay<T, U, S> : SearchableListOverlay - { - private readonly Container scrollContainer; - - protected readonly SearchableListHeader<T> Header; - protected readonly SearchableListFilterControl<U, S> Filter; - protected readonly FillFlowContainer ScrollFlow; - - protected abstract Color4 BackgroundColour { get; } - protected abstract Color4 TrianglesColourLight { get; } - protected abstract Color4 TrianglesColourDark { get; } - protected abstract SearchableListHeader<T> CreateHeader(); - protected abstract SearchableListFilterControl<U, S> CreateFilterControl(); - - protected SearchableListOverlay() - { - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = BackgroundColour, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new[] - { - new Triangles - { - RelativeSizeAxes = Axes.Both, - TriangleScale = 5, - ColourLight = TrianglesColourLight, - ColourDark = TrianglesColourDark, - }, - }, - }, - scrollContainer = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new[] - { - new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - ScrollbarVisible = false, - Children = new[] - { - ScrollFlow = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = WIDTH_PADDING, Bottom = 50 }, - Direction = FillDirection.Vertical, - }, - }, - }, - }, - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - Header = CreateHeader(), - Filter = CreateFilterControl(), - }, - }, - }; - - Filter.Search.Exit = Hide; - } - - protected override void Update() - { - base.Update(); - - scrollContainer.Padding = new MarginPadding { Top = Header.Height + Filter.Height }; - } - - protected override void OnFocus(FocusEvent e) - { - Filter.Search.TakeFocus(); - } - - protected override void PopIn() - { - base.PopIn(); - - Filter.Search.HoldFocus = true; - } - - protected override void PopOut() - { - base.PopOut(); - - Filter.Search.HoldFocus = false; - } - } -} diff --git a/osu.Game/Overlays/SearchableList/SlimEnumDropdown.cs b/osu.Game/Overlays/SearchableList/SlimEnumDropdown.cs index f320ef1344..9e7ff1205f 100644 --- a/osu.Game/Overlays/SearchableList/SlimEnumDropdown.cs +++ b/osu.Game/Overlays/SearchableList/SlimEnumDropdown.cs @@ -1,6 +1,7 @@ // 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. +using System; using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -11,6 +12,7 @@ using osuTK; namespace osu.Game.Overlays.SearchableList { public class SlimEnumDropdown<T> : OsuEnumDropdown<T> + where T : struct, Enum { protected override DropdownHeader CreateHeader() => new SlimDropdownHeader(); diff --git a/osu.Game/Overlays/Settings/ISettingsItem.cs b/osu.Game/Overlays/Settings/ISettingsItem.cs new file mode 100644 index 0000000000..e7afa48502 --- /dev/null +++ b/osu.Game/Overlays/Settings/ISettingsItem.cs @@ -0,0 +1,13 @@ +// 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. + +using System; +using osu.Framework.Graphics; + +namespace osu.Game.Overlays.Settings +{ + public interface ISettingsItem : IDrawable, IDisposable + { + event Action SettingChanged; + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index 2c25808170..bed74542c9 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -14,14 +14,10 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { protected override string Header => "Devices"; - private AudioManager audio; - private SettingsDropdown<string> dropdown; + [Resolved] + private AudioManager audio { get; set; } - [BackgroundDependencyLoader] - private void load(AudioManager audio) - { - this.audio = audio; - } + private SettingsDropdown<string> dropdown; protected override void Dispose(bool isDisposing) { @@ -60,12 +56,15 @@ namespace osu.Game.Overlays.Settings.Sections.Audio Children = new Drawable[] { - dropdown = new AudioDeviceSettingsDropdown() + dropdown = new AudioDeviceSettingsDropdown + { + Keywords = new[] { "speaker", "headphone", "output" } + } }; updateItems(); - dropdown.Bindable = audio.AudioDevice; + dropdown.Current = audio.AudioDevice; audio.OnNewDevice += onDeviceChanged; audio.OnLostDevice += onDeviceChanged; diff --git a/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs index 4e43caff23..7682967d10 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs @@ -1,7 +1,10 @@ // 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. +using System; +using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Audio @@ -13,18 +16,36 @@ namespace osu.Game.Overlays.Settings.Sections.Audio [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - Children = new[] + Children = new Drawable[] { new SettingsCheckbox { LabelText = "Interface voices", - Bindable = config.GetBindable<bool>(OsuSetting.MenuVoice) + Current = config.GetBindable<bool>(OsuSetting.MenuVoice) }, new SettingsCheckbox { LabelText = "osu! music theme", - Bindable = config.GetBindable<bool>(OsuSetting.MenuMusic) + Current = config.GetBindable<bool>(OsuSetting.MenuMusic) }, + new SettingsDropdown<IntroSequence> + { + LabelText = "Intro sequence", + Current = config.GetBindable<IntroSequence>(OsuSetting.IntroSequence), + Items = Enum.GetValues(typeof(IntroSequence)).Cast<IntroSequence>() + }, + new SettingsDropdown<BackgroundSource> + { + LabelText = "Background source", + Current = config.GetBindable<BackgroundSource>(OsuSetting.MenuBackgroundSource), + Items = Enum.GetValues(typeof(BackgroundSource)).Cast<BackgroundSource>() + }, + new SettingsDropdown<SeasonalBackgroundMode> + { + LabelText = "Seasonal backgrounds", + Current = config.GetBindable<SeasonalBackgroundMode>(OsuSetting.SeasonalBackgroundMode), + Items = Enum.GetValues(typeof(SeasonalBackgroundMode)).Cast<SeasonalBackgroundMode>() + } }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs index aaa4302553..c9a81b955b 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs @@ -20,7 +20,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio new SettingsSlider<double, OffsetSlider> { LabelText = "Audio offset", - Bindable = config.GetBindable<double>(OsuSetting.AudioOffset), + Current = config.GetBindable<double>(OsuSetting.AudioOffset), KeyboardStep = 1f }, new SettingsButton diff --git a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs index 0124f7090e..c172a76ab9 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs @@ -17,10 +17,34 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { Children = new Drawable[] { - new SettingsSlider<double> { LabelText = "Master", Bindable = audio.Volume, KeyboardStep = 0.01f }, - new SettingsSlider<double> { LabelText = "Master (window inactive)", Bindable = config.GetBindable<double>(OsuSetting.VolumeInactive), KeyboardStep = 0.01f }, - new SettingsSlider<double> { LabelText = "Effect", Bindable = audio.VolumeSample, KeyboardStep = 0.01f }, - new SettingsSlider<double> { LabelText = "Music", Bindable = audio.VolumeTrack, KeyboardStep = 0.01f }, + new SettingsSlider<double> + { + LabelText = "Master", + Current = audio.Volume, + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsSlider<double> + { + LabelText = "Master (window inactive)", + Current = config.GetBindable<double>(OsuSetting.VolumeInactive), + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsSlider<double> + { + LabelText = "Effect", + Current = audio.VolumeSample, + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsSlider<double> + { + LabelText = "Music", + Current = audio.VolumeTrack, + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, }; } } diff --git a/osu.Game/Overlays/Settings/Sections/AudioSection.cs b/osu.Game/Overlays/Settings/Sections/AudioSection.cs index 772f5c2039..69538358f1 100644 --- a/osu.Game/Overlays/Settings/Sections/AudioSection.cs +++ b/osu.Game/Overlays/Settings/Sections/AudioSection.cs @@ -1,6 +1,8 @@ // 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. +using System.Collections.Generic; +using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Settings.Sections.Audio; @@ -10,7 +12,13 @@ namespace osu.Game.Overlays.Settings.Sections public class AudioSection : SettingsSection { public override string Header => "Audio"; - public override IconUsage Icon => FontAwesome.Solid.VolumeUp; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.VolumeUp + }; + + public override IEnumerable<string> FilterTerms => base.FilterTerms.Concat(new[] { "sound" }); public AudioSection() { @@ -19,7 +27,7 @@ namespace osu.Game.Overlays.Settings.Sections new AudioDevicesSettings(), new VolumeSettings(), new OffsetSettings(), - new MainMenuSettings(), + new MainMenuSettings() }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs index f063898a9f..f05b876d8f 100644 --- a/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs @@ -19,22 +19,12 @@ namespace osu.Game.Overlays.Settings.Sections.Debug new SettingsCheckbox { LabelText = "Show log overlay", - Bindable = frameworkConfig.GetBindable<bool>(FrameworkSetting.ShowLogOverlay) - }, - new SettingsCheckbox - { - LabelText = "Performance logging", - Bindable = frameworkConfig.GetBindable<bool>(FrameworkSetting.PerformanceLogging) - }, - new SettingsCheckbox - { - LabelText = "Bypass caching (slow)", - Bindable = config.GetBindable<bool>(DebugSetting.BypassCaching) + Current = frameworkConfig.GetBindable<bool>(FrameworkSetting.ShowLogOverlay) }, new SettingsCheckbox { LabelText = "Bypass front-to-back render pass", - Bindable = config.GetBindable<bool>(DebugSetting.BypassFrontToBackPass) + Current = config.GetBindable<bool>(DebugSetting.BypassFrontToBackPass) } }; } diff --git a/osu.Game/Overlays/Settings/Sections/Debug/GCSettings.cs b/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs similarity index 63% rename from osu.Game/Overlays/Settings/Sections/Debug/GCSettings.cs rename to osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs index 6897b42f4f..db64c9a8ac 100644 --- a/osu.Game/Overlays/Settings/Sections/Debug/GCSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs @@ -1,26 +1,26 @@ // 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. -using System; using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Platform; namespace osu.Game.Overlays.Settings.Sections.Debug { - public class GCSettings : SettingsSubsection + public class MemorySettings : SettingsSubsection { - protected override string Header => "Garbage Collector"; + protected override string Header => "Memory"; [BackgroundDependencyLoader] - private void load(FrameworkDebugConfigManager config) + private void load(FrameworkDebugConfigManager config, GameHost host) { Children = new Drawable[] { new SettingsButton { - Text = "Force garbage collection", - Action = GC.Collect + Text = "Clear all caches", + Action = host.Collect }, }; } diff --git a/osu.Game/Overlays/Settings/Sections/DebugSection.cs b/osu.Game/Overlays/Settings/Sections/DebugSection.cs index 0149cab802..44d4088972 100644 --- a/osu.Game/Overlays/Settings/Sections/DebugSection.cs +++ b/osu.Game/Overlays/Settings/Sections/DebugSection.cs @@ -10,14 +10,18 @@ namespace osu.Game.Overlays.Settings.Sections public class DebugSection : SettingsSection { public override string Header => "Debug"; - public override IconUsage Icon => FontAwesome.Solid.Bug; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Bug + }; public DebugSection() { Children = new Drawable[] { new GeneralSettings(), - new GCSettings(), + new MemorySettings(), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 9142492610..9cb02ff3b9 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -1,6 +1,7 @@ // 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. +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; @@ -20,36 +21,74 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay new SettingsSlider<double> { LabelText = "Background dim", - Bindable = config.GetBindable<double>(OsuSetting.DimLevel), - KeyboardStep = 0.01f + Current = config.GetBindable<double>(OsuSetting.DimLevel), + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsSlider<double> { LabelText = "Background blur", - Bindable = config.GetBindable<double>(OsuSetting.BlurLevel), - KeyboardStep = 0.01f + Current = config.GetBindable<double>(OsuSetting.BlurLevel), + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsCheckbox { - LabelText = "Show score overlay", - Bindable = config.GetBindable<bool>(OsuSetting.ShowInterface) + LabelText = "Lighten playfield during breaks", + Current = config.GetBindable<bool>(OsuSetting.LightenDuringBreaks) + }, + new SettingsEnumDropdown<HUDVisibilityMode> + { + LabelText = "HUD overlay visibility mode", + Current = config.GetBindable<HUDVisibilityMode>(OsuSetting.HUDVisibilityMode) + }, + new SettingsCheckbox + { + LabelText = "Show difficulty graph on progress bar", + Current = config.GetBindable<bool>(OsuSetting.ShowProgressGraph) }, new SettingsCheckbox { LabelText = "Show health display even when you can't fail", - Bindable = config.GetBindable<bool>(OsuSetting.ShowHealthDisplayWhenCantFail), + Current = config.GetBindable<bool>(OsuSetting.ShowHealthDisplayWhenCantFail), + Keywords = new[] { "hp", "bar" } + }, + new SettingsCheckbox + { + LabelText = "Fade playfield to red when health is low", + Current = config.GetBindable<bool>(OsuSetting.FadePlayfieldWhenHealthLow), }, new SettingsCheckbox { LabelText = "Always show key overlay", - Bindable = config.GetBindable<bool>(OsuSetting.KeyOverlay) + Current = config.GetBindable<bool>(OsuSetting.KeyOverlay) + }, + new SettingsCheckbox + { + LabelText = "Positional hitsounds", + Current = config.GetBindable<bool>(OsuSetting.PositionalHitSounds) + }, + new SettingsEnumDropdown<ScoreMeterType> + { + LabelText = "Score meter type", + Current = config.GetBindable<ScoreMeterType>(OsuSetting.ScoreMeter) }, new SettingsEnumDropdown<ScoringMode> { LabelText = "Score display mode", - Bindable = config.GetBindable<ScoringMode>(OsuSetting.ScoreDisplayMode) - } + Current = config.GetBindable<ScoringMode>(OsuSetting.ScoreDisplayMode), + Keywords = new[] { "scoring" } + }, }; + + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) + { + Add(new SettingsCheckbox + { + LabelText = "Disable Windows key during gameplay", + Current = config.GetBindable<bool>(OsuSetting.GameplayDisableWinKey) + }); + } } } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs index 2c6b2663c6..2b2fb9cef7 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs @@ -1,6 +1,8 @@ // 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. +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Game.Configuration; @@ -10,6 +12,8 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay { protected override string Header => "Mods"; + public override IEnumerable<string> FilterTerms => base.FilterTerms.Concat(new[] { "mod" }); + [BackgroundDependencyLoader] private void load(OsuConfigManager config) { @@ -18,7 +22,7 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay new SettingsCheckbox { LabelText = "Increase visibility of first object when visual impairment mods are enabled", - Bindable = config.GetBindable<bool>(OsuSetting.IncreaseFirstObjectVisibility) + Current = config.GetBindable<bool>(OsuSetting.IncreaseFirstObjectVisibility), }, }; } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs index 3e2272dba6..b26876556e 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs @@ -1,7 +1,9 @@ // 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. +using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -10,44 +12,60 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay { public class SongSelectSettings : SettingsSubsection { + private Bindable<double> minStars; + private Bindable<double> maxStars; + protected override string Header => "Song Select"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) { + minStars = config.GetBindable<double>(OsuSetting.DisplayStarsMinimum); + maxStars = config.GetBindable<double>(OsuSetting.DisplayStarsMaximum); + + minStars.ValueChanged += min => maxStars.Value = Math.Max(min.NewValue, maxStars.Value); + maxStars.ValueChanged += max => minStars.Value = Math.Min(max.NewValue, minStars.Value); + Children = new Drawable[] { new SettingsCheckbox { LabelText = "Right mouse drag to absolute scroll", - Bindable = config.GetBindable<bool>(OsuSetting.SongSelectRightMouseScroll), + Current = config.GetBindable<bool>(OsuSetting.SongSelectRightMouseScroll), }, new SettingsCheckbox { LabelText = "Show converted beatmaps", - Bindable = config.GetBindable<bool>(OsuSetting.ShowConvertedBeatmaps), + Current = config.GetBindable<bool>(OsuSetting.ShowConvertedBeatmaps), }, - new SettingsSlider<double, StarSlider> + new SettingsSlider<double, StarsSlider> { LabelText = "Display beatmaps from", - Bindable = config.GetBindable<double>(OsuSetting.DisplayStarsMinimum), - KeyboardStep = 0.1f + Current = config.GetBindable<double>(OsuSetting.DisplayStarsMinimum), + KeyboardStep = 0.1f, + Keywords = new[] { "minimum", "maximum", "star", "difficulty" } }, - new SettingsSlider<double, StarSlider> + new SettingsSlider<double, MaximumStarsSlider> { LabelText = "up to", - Bindable = config.GetBindable<double>(OsuSetting.DisplayStarsMaximum), - KeyboardStep = 0.1f + Current = config.GetBindable<double>(OsuSetting.DisplayStarsMaximum), + KeyboardStep = 0.1f, + Keywords = new[] { "minimum", "maximum", "star", "difficulty" } }, new SettingsEnumDropdown<RandomSelectAlgorithm> { LabelText = "Random selection algorithm", - Bindable = config.GetBindable<RandomSelectAlgorithm>(OsuSetting.RandomSelectAlgorithm), + Current = config.GetBindable<RandomSelectAlgorithm>(OsuSetting.RandomSelectAlgorithm), } }; } - private class StarSlider : OsuSliderBar<double> + private class MaximumStarsSlider : StarsSlider + { + public override string TooltipText => Current.IsDefault ? "no limit" : base.TooltipText; + } + + private class StarsSlider : OsuSliderBar<double> { public override string TooltipText => Current.Value.ToString(@"0.## stars"); } diff --git a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs index 97d9d3c697..e5cebd28e2 100644 --- a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs +++ b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs @@ -1,19 +1,25 @@ // 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. +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Overlays.Settings.Sections.Gameplay; using osu.Game.Rulesets; using System.Linq; using osu.Framework.Graphics.Sprites; +using osu.Framework.Logging; namespace osu.Game.Overlays.Settings.Sections { public class GameplaySection : SettingsSection { public override string Header => "Gameplay"; - public override IconUsage Icon => FontAwesome.Regular.Circle; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Regular.Circle + }; public GameplaySection() { @@ -30,9 +36,17 @@ namespace osu.Game.Overlays.Settings.Sections { foreach (Ruleset ruleset in rulesets.AvailableRulesets.Select(info => info.CreateInstance())) { - SettingsSubsection section = ruleset.CreateSettings(); - if (section != null) - Add(section); + try + { + SettingsSubsection section = ruleset.CreateSettings(); + + if (section != null) + Add(section); + } + catch (Exception e) + { + Logger.Error(e, "Failed to load ruleset settings"); + } } } } diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index 236bfbecc3..44e42ecbfe 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -19,7 +19,7 @@ namespace osu.Game.Overlays.Settings.Sections.General new SettingsCheckbox { LabelText = "Prefer metadata in original language", - Bindable = frameworkConfig.GetBindable<bool>(FrameworkSetting.ShowUnicode) + Current = frameworkConfig.GetBindable<bool>(FrameworkSetting.ShowUnicode) }, }; } diff --git a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs index 66fec1ecf9..873272bf12 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LoginSettings.cs @@ -13,6 +13,7 @@ using osu.Game.Online.API; using osuTK; using osu.Game.Users; using System.ComponentModel; +using osu.Framework.Bindables; using osu.Game.Graphics; using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; @@ -25,13 +26,15 @@ using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Overlays.Settings.Sections.General { - public class LoginSettings : FillFlowContainer, IOnlineComponent + public class LoginSettings : FillFlowContainer { private bool bounding = true; private LoginForm form; - private OsuColour colours; - private UserPanel panel; + [Resolved] + private OsuColour colours { get; set; } + + private UserGridPanel panel; private UserDropdown dropdown; /// <summary> @@ -39,6 +42,11 @@ namespace osu.Game.Overlays.Settings.Sections.General /// </summary> public Action RequestHide; + private readonly IBindable<APIState> apiState = new Bindable<APIState>(); + + [Resolved] + private IAPIProvider api { get; set; } + public override RectangleF BoundingBox => bounding ? base.BoundingBox : RectangleF.Empty; public bool Bounding @@ -59,19 +67,18 @@ namespace osu.Game.Overlays.Settings.Sections.General Spacing = new Vector2(0f, 5f); } - [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuColour colours, IAPIProvider api) + [BackgroundDependencyLoader] + private void load() { - this.colours = colours; - - api?.Register(this); + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); } - public void APIStateChanged(IAPIProvider api, APIState state) + private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() => { form = null; - switch (state) + switch (state.NewValue) { case APIState.Offline: Children = new Drawable[] @@ -80,7 +87,7 @@ namespace osu.Game.Overlays.Settings.Sections.General { Text = "ACCOUNT", Margin = new MarginPadding { Bottom = 5 }, - Font = OsuFont.GetFont(weight: FontWeight.Black), + Font = OsuFont.GetFont(weight: FontWeight.Bold), }, form = new LoginForm { @@ -95,7 +102,7 @@ namespace osu.Game.Overlays.Settings.Sections.General Children = new Drawable[] { - new LoadingAnimation + new LoadingSpinner { State = { Value = Visibility.Visible }, Anchor = Anchor.TopCentre, @@ -107,7 +114,7 @@ namespace osu.Game.Overlays.Settings.Sections.General Origin = Anchor.TopCentre, TextAnchor = Anchor.TopCentre, AutoSizeAxes = Axes.Both, - Text = state == APIState.Failing ? "Connection is failing, will attempt to reconnect... " : "Attempting to connect... ", + Text = state.NewValue == APIState.Failing ? "Connection is failing, will attempt to reconnect... " : "Attempting to connect... ", Margin = new MarginPadding { Top = 10, Bottom = 10 }, }, }; @@ -143,7 +150,7 @@ namespace osu.Game.Overlays.Settings.Sections.General }, }, }, - panel = new UserPanel(api.LocalUser.Value) + panel = new UserGridPanel(api.LocalUser.Value) { RelativeSizeAxes = Axes.X, Action = RequestHide @@ -184,7 +191,7 @@ namespace osu.Game.Overlays.Settings.Sections.General } if (form != null) GetContainingInputManager()?.ChangeFocus(form); - } + }); public override bool AcceptsFocus => true; @@ -200,7 +207,10 @@ namespace osu.Game.Overlays.Settings.Sections.General { private TextBox username; private TextBox password; - private IAPIProvider api; + private ShakeContainer shakeSignIn; + + [Resolved(CanBeNull = true)] + private IAPIProvider api { get; set; } public Action RequestHide; @@ -208,12 +218,13 @@ namespace osu.Game.Overlays.Settings.Sections.General { if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text)) api.Login(username.Text, password.Text); + else + shakeSignIn.Shake(); } [BackgroundDependencyLoader(permitNulls: true)] - private void load(IAPIProvider api, OsuConfigManager config, AccountCreationOverlay accountCreation) + private void load(OsuConfigManager config, AccountCreationOverlay accountCreation) { - this.api = api; Direction = FillDirection.Vertical; Spacing = new Vector2(0, 5); AutoSizeAxes = Axes.Y; @@ -222,7 +233,7 @@ namespace osu.Game.Overlays.Settings.Sections.General { username = new OsuTextBox { - PlaceholderText = "email address", + PlaceholderText = "username", RelativeSizeAxes = Axes.X, Text = api?.ProvidedUsername ?? string.Empty, TabbableContentContainer = this @@ -232,22 +243,34 @@ namespace osu.Game.Overlays.Settings.Sections.General PlaceholderText = "password", RelativeSizeAxes = Axes.X, TabbableContentContainer = this, - OnCommit = (sender, newText) => performLogin() }, new SettingsCheckbox { - LabelText = "Remember email address", - Bindable = config.GetBindable<bool>(OsuSetting.SaveUsername), + LabelText = "Remember username", + Current = config.GetBindable<bool>(OsuSetting.SaveUsername), }, new SettingsCheckbox { LabelText = "Stay signed in", - Bindable = config.GetBindable<bool>(OsuSetting.SavePassword), + Current = config.GetBindable<bool>(OsuSetting.SavePassword), }, - new SettingsButton + new Container { - Text = "Sign in", - Action = performLogin + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + shakeSignIn = new ShakeContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new SettingsButton + { + Text = "Sign in", + Action = performLogin + }, + } + } }, new SettingsButton { @@ -259,6 +282,8 @@ namespace osu.Game.Overlays.Settings.Sections.General } } }; + + password.OnCommit += (sender, newText) => performLogin(); } public override bool AcceptsFocus => true; @@ -281,10 +306,8 @@ namespace osu.Game.Overlays.Settings.Sections.General { set { - var h = Header as UserDropdownHeader; - if (h == null) return; - - h.StatusColour = value; + if (Header is UserDropdownHeader h) + h.StatusColour = value; } } @@ -309,8 +332,6 @@ namespace osu.Game.Overlays.Settings.Sections.General Colour = Color4.Black.Opacity(0.25f), Radius = 4, }; - - ItemsContainer.Padding = new MarginPadding(); } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index 188c9c05ef..c213313559 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -1,26 +1,65 @@ // 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. +using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; +using osu.Framework.Screens; using osu.Game.Configuration; +using osu.Game.Overlays.Notifications; +using osu.Game.Overlays.Settings.Sections.Maintenance; +using osu.Game.Updater; namespace osu.Game.Overlays.Settings.Sections.General { public class UpdateSettings : SettingsSubsection { + [Resolved(CanBeNull = true)] + private UpdateManager updateManager { get; set; } + protected override string Header => "Updates"; - [BackgroundDependencyLoader] - private void load(Storage storage, OsuConfigManager config) + private SettingsButton checkForUpdatesButton; + + [Resolved(CanBeNull = true)] + private NotificationOverlay notifications { get; set; } + + [BackgroundDependencyLoader(true)] + private void load(Storage storage, OsuConfigManager config, OsuGame game) { Add(new SettingsEnumDropdown<ReleaseStream> { LabelText = "Release stream", - Bindable = config.GetBindable<ReleaseStream>(OsuSetting.ReleaseStream), + Current = config.GetBindable<ReleaseStream>(OsuSetting.ReleaseStream), }); + if (updateManager?.CanCheckForUpdate == true) + { + Add(checkForUpdatesButton = new SettingsButton + { + Text = "Check for updates", + Action = () => + { + checkForUpdatesButton.Enabled.Value = false; + Task.Run(updateManager.CheckForUpdateAsync).ContinueWith(t => Schedule(() => + { + if (!t.Result) + { + notifications?.Post(new SimpleNotification + { + Text = $"You are running the latest release ({game.Version})", + Icon = FontAwesome.Solid.CheckCircle, + }); + } + + checkForUpdatesButton.Enabled.Value = true; + })); + } + }); + } + if (RuntimeInfo.IsDesktop) { Add(new SettingsButton @@ -28,6 +67,12 @@ namespace osu.Game.Overlays.Settings.Sections.General Text = "Open osu! folder", Action = storage.OpenInNativeExplorer, }); + + Add(new SettingsButton + { + Text = "Change folder location...", + Action = () => game?.PerformFromScreen(menu => menu.Push(new MigrationSelectScreen())) + }); } } } diff --git a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs index d9947f16cc..fefc3fe6a7 100644 --- a/osu.Game/Overlays/Settings/Sections/GeneralSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GeneralSection.cs @@ -10,7 +10,11 @@ namespace osu.Game.Overlays.Settings.Sections public class GeneralSection : SettingsSection { public override string Header => "General"; - public override IconUsage Icon => FontAwesome.Solid.Cog; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Cog + }; public GeneralSection() { diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs index 01cdc9aa32..30caa45995 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs @@ -18,23 +18,23 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { new SettingsCheckbox { - LabelText = "Storyboards", - Bindable = config.GetBindable<bool>(OsuSetting.ShowStoryboard) + LabelText = "Storyboard / Video", + Current = config.GetBindable<bool>(OsuSetting.ShowStoryboard) }, new SettingsCheckbox { - LabelText = "Rotate cursor when dragging", - Bindable = config.GetBindable<bool>(OsuSetting.CursorRotation) + LabelText = "Hit Lighting", + Current = config.GetBindable<bool>(OsuSetting.HitLighting) }, new SettingsEnumDropdown<ScreenshotFormat> { LabelText = "Screenshot format", - Bindable = config.GetBindable<ScreenshotFormat>(OsuSetting.ScreenshotFormat) + Current = config.GetBindable<ScreenshotFormat>(OsuSetting.ScreenshotFormat) }, new SettingsCheckbox { LabelText = "Show menu cursor in screenshots", - Bindable = config.GetBindable<bool>(OsuSetting.ScreenshotCaptureMenuCursor) + Current = config.GetBindable<bool>(OsuSetting.ScreenshotCaptureMenuCursor) } }; } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index f4de4c0c41..14b8dbfac0 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -29,7 +29,9 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private Bindable<Size> sizeFullscreen; private readonly IBindableList<WindowMode> windowModes = new BindableList<WindowMode>(); - private OsuGameBase game; + [Resolved] + private OsuGameBase game { get; set; } + private SettingsDropdown<Size> resolutionDropdown; private SettingsDropdown<WindowMode> windowModeDropdown; @@ -41,10 +43,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private const int transition_duration = 400; [BackgroundDependencyLoader] - private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, OsuGameBase game, GameHost host) + private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, GameHost host) { - this.game = game; - scalingMode = osuConfig.GetBindable<ScalingMode>(OsuSetting.Scaling); sizeFullscreen = config.GetBindable<Size>(FrameworkSetting.SizeFullscreen); scalingSizeX = osuConfig.GetBindable<float>(OsuSetting.ScalingSizeX); @@ -62,7 +62,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics windowModeDropdown = new SettingsDropdown<WindowMode> { LabelText = "Screen mode", - Bindable = config.GetBindable<WindowMode>(FrameworkSetting.WindowMode), + Current = config.GetBindable<WindowMode>(FrameworkSetting.WindowMode), ItemSource = windowModes, }, resolutionSettingsContainer = new Container @@ -74,13 +74,15 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { LabelText = "UI Scaling", TransferValueOnCommit = true, - Bindable = osuConfig.GetBindable<float>(OsuSetting.UIScale), - KeyboardStep = 0.01f + Current = osuConfig.GetBindable<float>(OsuSetting.UIScale), + KeyboardStep = 0.01f, + Keywords = new[] { "scale", "letterbox" }, }, new SettingsEnumDropdown<ScalingMode> { LabelText = "Screen Scaling", - Bindable = osuConfig.GetBindable<ScalingMode>(OsuSetting.Scaling), + Current = osuConfig.GetBindable<ScalingMode>(OsuSetting.Scaling), + Keywords = new[] { "scale", "letterbox" }, }, scalingSettings = new FillFlowContainer<SettingsSlider<float>> { @@ -95,32 +97,36 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics new SettingsSlider<float> { LabelText = "Horizontal position", - Bindable = scalingPositionX, - KeyboardStep = 0.01f + Current = scalingPositionX, + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsSlider<float> { LabelText = "Vertical position", - Bindable = scalingPositionY, - KeyboardStep = 0.01f + Current = scalingPositionY, + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsSlider<float> { LabelText = "Horizontal scale", - Bindable = scalingSizeX, - KeyboardStep = 0.01f + Current = scalingSizeX, + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, new SettingsSlider<float> { LabelText = "Vertical scale", - Bindable = scalingSizeY, - KeyboardStep = 0.01f + Current = scalingSizeY, + KeyboardStep = 0.01f, + DisplayAsPercentage = true }, } }, }; - scalingSettings.ForEach(s => bindPreviewEvent(s.Bindable)); + scalingSettings.ForEach(s => bindPreviewEvent(s.Current)); var resolutions = getResolutions(); @@ -131,10 +137,10 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics LabelText = "Resolution", ShowsDefaultIndicator = false, Items = resolutions, - Bindable = sizeFullscreen + Current = sizeFullscreen }; - windowModeDropdown.Bindable.BindValueChanged(mode => + windowModeDropdown.Current.BindValueChanged(mode => { if (mode.NewValue == WindowMode.Fullscreen) { @@ -157,8 +163,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics scalingSettings.ForEach(s => s.TransferValueOnCommit = mode.NewValue == ScalingMode.Everything); }, true); - windowModes.ItemsAdded += _ => windowModesChanged(); - windowModes.ItemsRemoved += _ => windowModesChanged(); + windowModes.CollectionChanged += (sender, args) => windowModesChanged(); windowModesChanged(); } @@ -203,15 +208,16 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private IReadOnlyList<Size> getResolutions() { var resolutions = new List<Size> { new Size(9999, 9999) }; + var currentDisplay = game.Window?.CurrentDisplay.Value; - if (game.Window != null) + if (currentDisplay != null) { - resolutions.AddRange(game.Window.AvailableResolutions - .Where(r => r.Width >= 800 && r.Height >= 600) - .OrderByDescending(r => r.Width) - .ThenByDescending(r => r.Height) - .Select(res => new Size(res.Width, res.Height)) - .Distinct()); + resolutions.AddRange(currentDisplay.DisplayModes + .Where(m => m.Size.Width >= 800 && m.Size.Height >= 600) + .OrderByDescending(m => m.Size.Width) + .ThenByDescending(m => m.Size.Height) + .Select(m => m.Size) + .Distinct()); } return resolutions; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/MainMenuSettings.cs deleted file mode 100644 index 92f64d0e14..0000000000 --- a/osu.Game/Overlays/Settings/Sections/Graphics/MainMenuSettings.cs +++ /dev/null @@ -1,26 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Game.Configuration; - -namespace osu.Game.Overlays.Settings.Sections.Graphics -{ - public class MainMenuSettings : SettingsSubsection - { - protected override string Header => "User Interface"; - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - Children = new[] - { - new SettingsCheckbox - { - LabelText = "Parallax", - Bindable = config.GetBindable<bool>(OsuSetting.MenuParallax) - }, - }; - } - } -} diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index 7317076c54..8773e6763c 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Platform; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Graphics @@ -22,12 +23,17 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics new SettingsEnumDropdown<FrameSync> { LabelText = "Frame limiter", - Bindable = config.GetBindable<FrameSync>(FrameworkSetting.FrameSync) + Current = config.GetBindable<FrameSync>(FrameworkSetting.FrameSync) + }, + new SettingsEnumDropdown<ExecutionMode> + { + LabelText = "Threading mode", + Current = config.GetBindable<ExecutionMode>(FrameworkSetting.ExecutionMode) }, new SettingsCheckbox { LabelText = "Show FPS", - Bindable = osuConfig.GetBindable<bool>(OsuSetting.ShowFpsDisplay) + Current = osuConfig.GetBindable<bool>(OsuSetting.ShowFpsDisplay) }, }; } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/UserInterfaceSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/UserInterfaceSettings.cs new file mode 100644 index 0000000000..38c30ddd64 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Graphics/UserInterfaceSettings.cs @@ -0,0 +1,44 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Settings.Sections.Graphics +{ + public class UserInterfaceSettings : SettingsSubsection + { + protected override string Header => "User Interface"; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = "Rotate cursor when dragging", + Current = config.GetBindable<bool>(OsuSetting.CursorRotation) + }, + new SettingsCheckbox + { + LabelText = "Parallax", + Current = config.GetBindable<bool>(OsuSetting.MenuParallax) + }, + new SettingsSlider<float, TimeSlider> + { + LabelText = "Hold-to-confirm activation time", + Current = config.GetBindable<float>(OsuSetting.UIHoldActivationDelay), + KeyboardStep = 50 + }, + }; + } + + private class TimeSlider : OsuSliderBar<float> + { + public override string TooltipText => Current.Value.ToString("N0") + "ms"; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs index 3d6086d3ea..c1b4b0bbcb 100644 --- a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs @@ -10,7 +10,11 @@ namespace osu.Game.Overlays.Settings.Sections public class GraphicsSection : SettingsSection { public override string Header => "Graphics"; - public override IconUsage Icon => FontAwesome.Solid.Laptop; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Laptop + }; public GraphicsSection() { @@ -19,7 +23,7 @@ namespace osu.Game.Overlays.Settings.Sections new RendererSettings(), new LayoutSettings(), new DetailSettings(), - new MainMenuSettings(), + new UserInterfaceSettings(), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs index 55c7210d6c..db6f24a954 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyboardSettings.cs @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input new SettingsButton { Text = "Key configuration", - TooltipText = "Change global shortcut keys and gameplay bindings", + 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 4f2f3dfd1a..f0d51a0d37 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -1,13 +1,14 @@ // 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. +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; -using osu.Framework.Input; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Input; namespace osu.Game.Overlays.Settings.Sections.Input { @@ -16,64 +17,79 @@ namespace osu.Game.Overlays.Settings.Sections.Input protected override string Header => "Mouse"; private readonly BindableBool rawInputToggle = new BindableBool(); + private Bindable<double> sensitivityBindable = new BindableDouble(); private Bindable<string> ignoredInputHandler; - private SensitivitySetting sensitivity; [BackgroundDependencyLoader] private void load(OsuConfigManager osuConfig, FrameworkConfigManager config) { + var configSensitivity = config.GetBindable<double>(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); + Children = new Drawable[] { new SettingsCheckbox { LabelText = "Raw input", - Bindable = rawInputToggle + Current = rawInputToggle }, - sensitivity = new SensitivitySetting + new SensitivitySetting { LabelText = "Cursor sensitivity", - Bindable = config.GetBindable<double>(FrameworkSetting.CursorSensitivity) + Current = sensitivityBindable }, new SettingsCheckbox { LabelText = "Map absolute input to window", - Bindable = config.GetBindable<bool>(FrameworkSetting.MapAbsoluteInputToWindow) + Current = config.GetBindable<bool>(FrameworkSetting.MapAbsoluteInputToWindow) }, - new SettingsEnumDropdown<ConfineMouseMode> + new SettingsEnumDropdown<OsuConfineMouseMode> { LabelText = "Confine mouse cursor to window", - Bindable = config.GetBindable<ConfineMouseMode>(FrameworkSetting.ConfineMouseMode), + Current = osuConfig.GetBindable<OsuConfineMouseMode>(OsuSetting.ConfineMouseMode) }, new SettingsCheckbox { LabelText = "Disable mouse wheel during gameplay", - Bindable = osuConfig.GetBindable<bool>(OsuSetting.MouseDisableWheel) + Current = osuConfig.GetBindable<bool>(OsuSetting.MouseDisableWheel) }, new SettingsCheckbox { LabelText = "Disable mouse buttons during gameplay", - Bindable = osuConfig.GetBindable<bool>(OsuSetting.MouseDisableButtons) + Current = osuConfig.GetBindable<bool>(OsuSetting.MouseDisableButtons) }, }; - rawInputToggle.ValueChanged += enabled => + if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) { - // this is temporary until we support per-handler settings. - const string raw_mouse_handler = @"OsuTKRawMouseHandler"; - const string standard_mouse_handler = @"OsuTKMouseHandler"; - - ignoredInputHandler.Value = enabled.NewValue ? standard_mouse_handler : raw_mouse_handler; - }; - - ignoredInputHandler = config.GetBindable<string>(FrameworkSetting.IgnoredInputHandlers); - ignoredInputHandler.ValueChanged += handler => + rawInputToggle.Disabled = true; + sensitivityBindable.Disabled = true; + } + else { - bool raw = !handler.NewValue.Contains("Raw"); - rawInputToggle.Value = raw; - sensitivity.Bindable.Disabled = !raw; - }; + rawInputToggle.ValueChanged += enabled => + { + // this is temporary until we support per-handler settings. + const string raw_mouse_handler = @"OsuTKRawMouseHandler"; + const string standard_mouse_handler = @"OsuTKMouseHandler"; - ignoredInputHandler.TriggerChange(); + ignoredInputHandler.Value = enabled.NewValue ? standard_mouse_handler : raw_mouse_handler; + }; + + ignoredInputHandler = config.GetBindable<string>(FrameworkSetting.IgnoredInputHandlers); + ignoredInputHandler.ValueChanged += handler => + { + bool raw = !handler.NewValue.Contains("Raw"); + rawInputToggle.Value = raw; + sensitivityBindable.Disabled = !raw; + }; + + ignoredInputHandler.TriggerChange(); + } } private class SensitivitySetting : SettingsSlider<double, SensitivitySlider> @@ -87,7 +103,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private class SensitivitySlider : OsuSliderBar<double> { - public override string TooltipText => Current.Disabled ? "Enable raw input to adjust sensitivity" : $"{base.TooltipText}x"; + public override string TooltipText => Current.Disabled ? "enable raw input to adjust sensitivity" : $"{base.TooltipText}x"; } } } diff --git a/osu.Game/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index 2a348b4e03..b43453f53d 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -10,7 +10,11 @@ namespace osu.Game.Overlays.Settings.Sections public class InputSection : SettingsSection { public override string Header => "Input"; - public override IconUsage Icon => FontAwesome.Regular.Keyboard; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Keyboard + }; public InputSection(KeyBindingPanel keyConfig) { diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index 832673703b..848ce381a9 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -3,9 +3,11 @@ using System.Linq; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Graphics.UserInterface; using osu.Game.Scoring; using osu.Game.Skinning; @@ -19,14 +21,15 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance private TriangleButton importBeatmapsButton; private TriangleButton importScoresButton; private TriangleButton importSkinsButton; + private TriangleButton importCollectionsButton; private TriangleButton deleteBeatmapsButton; private TriangleButton deleteScoresButton; private TriangleButton deleteSkinsButton; private TriangleButton restoreButton; private TriangleButton undeleteButton; - [BackgroundDependencyLoader] - private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, DialogOverlay dialogOverlay) + [BackgroundDependencyLoader(permitNulls: true)] + private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, DialogOverlay dialogOverlay) { if (beatmaps.SupportsImportFromStable) { @@ -93,20 +96,46 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance }); } - AddRange(new Drawable[] + Add(deleteSkinsButton = new DangerousSettingsButton { - deleteSkinsButton = new DangerousSettingsButton + Text = "Delete ALL skins", + Action = () => { - Text = "Delete ALL skins", + dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() => + { + deleteSkinsButton.Enabled.Value = false; + Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true)); + })); + } + }); + + if (collectionManager != null) + { + if (collectionManager.SupportsImportFromStable) + { + Add(importCollectionsButton = new SettingsButton + { + Text = "Import collections from stable", + Action = () => + { + importCollectionsButton.Enabled.Value = false; + collectionManager.ImportFromStableAsync().ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true)); + } + }); + } + + Add(new DangerousSettingsButton + { + Text = "Delete ALL collections", Action = () => { - dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() => - { - deleteSkinsButton.Enabled.Value = false; - Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true)); - })); + dialogOverlay?.Push(new DeleteAllBeatmapsDialog(collectionManager.DeleteAll)); } - }, + }); + } + + AddRange(new Drawable[] + { restoreButton = new SettingsButton { Text = "Restore all hidden difficulties", diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs new file mode 100644 index 0000000000..b0b61554eb --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationRunScreen.cs @@ -0,0 +1,115 @@ +// 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. + +using System.IO; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; +using osu.Framework.Screens; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens; +using osuTK; + +namespace osu.Game.Overlays.Settings.Sections.Maintenance +{ + public class MigrationRunScreen : OsuScreen + { + private readonly DirectoryInfo destination; + + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } + + public override bool AllowBackButton => false; + + public override bool AllowExternalScreenChange => false; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + public override bool HideOverlaysOnEnter => true; + + private Task migrationTask; + + public MigrationRunScreen(DirectoryInfo destination) + { + this.destination = destination; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(10), + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Migration in progress", + Font = OsuFont.Default.With(size: 40) + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "This could take a few minutes depending on the speed of your disk(s).", + Font = OsuFont.Default.With(size: 30) + }, + new LoadingSpinner(true) + { + State = { Value = Visibility.Visible } + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Please avoid interacting with the game!", + Font = OsuFont.Default.With(size: 30) + }, + } + }, + }; + + Beatmap.Value = Beatmap.Default; + + migrationTask = Task.Run(PerformMigration) + .ContinueWith(t => + { + if (t.IsFaulted) + Logger.Log($"Error during migration: {t.Exception?.Message}", level: LogLevel.Error); + + Schedule(this.Exit); + }); + } + + protected virtual void PerformMigration() => game?.Migrate(destination.FullName); + + public override void OnEntering(IScreen last) + { + base.OnEntering(last); + + this.FadeOut().Delay(250).Then().FadeIn(250); + } + + public override bool OnExiting(IScreen next) + { + // block until migration is finished + if (migrationTask?.IsCompleted == false) + return true; + + return base.OnExiting(next); + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs new file mode 100644 index 0000000000..ad540e3691 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/MigrationSelectScreen.cs @@ -0,0 +1,128 @@ +// 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. + +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.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 + { + private DirectorySelector directorySelector; + + public override bool AllowExternalScreenChange => false; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + public override bool HideOverlaysOnEnter => true; + + [BackgroundDependencyLoader(true)] + private void load(OsuGame game, Storage storage, OsuColour colours) + { + 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; + + try + { + if (target.GetDirectories().Length > 0 || target.GetFiles().Length > 0) + target = target.CreateSubdirectory("osu-lazer"); + } + catch (Exception e) + { + Logger.Log($"Error during migration: {e.Message}", level: LogLevel.Error); + return; + } + + ValidForResume = false; + BeginMigration(target); + } + + protected virtual void BeginMigration(DirectoryInfo target) => this.Push(new MigrationRunScreen(target)); + } +} diff --git a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs index 0f3acd5b7f..73c88b8e71 100644 --- a/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs +++ b/osu.Game/Overlays/Settings/Sections/MaintenanceSection.cs @@ -11,7 +11,11 @@ namespace osu.Game.Overlays.Settings.Sections public class MaintenanceSection : SettingsSection { public override string Header => "Maintenance"; - public override IconUsage Icon => FontAwesome.Solid.Wrench; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Wrench + }; public MaintenanceSection() { diff --git a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs index a8b3e45a83..8134c350a6 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs @@ -19,7 +19,19 @@ namespace osu.Game.Overlays.Settings.Sections.Online new SettingsCheckbox { LabelText = "Warn about opening external links", - Bindable = config.GetBindable<bool>(OsuSetting.ExternalLinkWarning) + Current = config.GetBindable<bool>(OsuSetting.ExternalLinkWarning) + }, + new SettingsCheckbox + { + LabelText = "Prefer downloads without video", + Keywords = new[] { "no-video" }, + Current = config.GetBindable<bool>(OsuSetting.PreferNoVideo) + }, + new SettingsCheckbox + { + LabelText = "Automatically download beatmaps when spectating", + Keywords = new[] { "spectator" }, + Current = config.GetBindable<bool>(OsuSetting.AutomaticallyDownloadWhenSpectating), }, }; } diff --git a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs index 80295690c0..150cddb388 100644 --- a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs +++ b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs @@ -10,7 +10,11 @@ namespace osu.Game.Overlays.Settings.Sections public class OnlineSection : SettingsSection { public override string Header => "Online"; - public override IconUsage Icon => FontAwesome.Solid.GlobeAsia; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.GlobeAsia + }; public OnlineSection() { diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 00f3d9783a..412103af38 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -1,13 +1,15 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.MathUtils; using osu.Framework.Graphics.Sprites; +using osu.Framework.Logging; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Skinning; @@ -21,63 +23,72 @@ namespace osu.Game.Overlays.Settings.Sections public override string Header => "Skin"; - public override IconUsage Icon => FontAwesome.Solid.PaintBrush; + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.PaintBrush + }; private readonly Bindable<SkinInfo> dropdownBindable = new Bindable<SkinInfo> { Default = SkinInfo.Default }; private readonly Bindable<int> configBindable = new Bindable<int>(); private static readonly SkinInfo random_skin_info = new RandomSkinInfo(); - private SkinManager skins; private List<SkinInfo> usableSkins; - [BackgroundDependencyLoader] - private void load(OsuConfigManager config, SkinManager skins) - { - this.skins = skins; + [Resolved] + private SkinManager skins { get; set; } + private IBindable<WeakReference<SkinInfo>> managerUpdated; + private IBindable<WeakReference<SkinInfo>> managerRemoved; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { FlowContent.Spacing = new Vector2(0, 5); + Children = new Drawable[] { skinDropdown = new SkinSettingsDropdown(), - new SettingsSlider<double, SizeSlider> + new ExportSkinButton(), + new SettingsSlider<float, SizeSlider> { LabelText = "Menu cursor size", - Bindable = config.GetBindable<double>(OsuSetting.MenuCursorSize), + Current = config.GetBindable<float>(OsuSetting.MenuCursorSize), KeyboardStep = 0.01f }, - new SettingsSlider<double, SizeSlider> + new SettingsSlider<float, SizeSlider> { LabelText = "Gameplay cursor size", - Bindable = config.GetBindable<double>(OsuSetting.GameplayCursorSize), + Current = config.GetBindable<float>(OsuSetting.GameplayCursorSize), KeyboardStep = 0.01f }, new SettingsCheckbox { LabelText = "Adjust gameplay cursor size based on current beatmap", - Bindable = config.GetBindable<bool>(OsuSetting.AutoCursorSize) + Current = config.GetBindable<bool>(OsuSetting.AutoCursorSize) }, new SettingsCheckbox { LabelText = "Beatmap skins", - Bindable = config.GetBindable<bool>(OsuSetting.BeatmapSkins) + Current = config.GetBindable<bool>(OsuSetting.BeatmapSkins) }, new SettingsCheckbox { LabelText = "Beatmap hitsounds", - Bindable = config.GetBindable<bool>(OsuSetting.BeatmapHitsounds) + Current = config.GetBindable<bool>(OsuSetting.BeatmapHitsounds) }, }; - skins.ItemAdded += itemAdded; - skins.ItemRemoved += itemRemoved; + managerUpdated = skins.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(itemUpdated); + + managerRemoved = skins.ItemRemoved.GetBoundCopy(); + managerRemoved.BindValueChanged(itemRemoved); config.BindWith(OsuSetting.Skin, configBindable); - usableSkins = skins.GetAllUsableSkins(); - - skinDropdown.Bindable = dropdownBindable; - resetSkinButtons(); + skinDropdown.Current = dropdownBindable; + updateItems(); // Todo: This should not be necessary when OsuConfigManager is databased if (skinDropdown.Items.All(s => s.ID != configBindable.Value)) @@ -86,10 +97,10 @@ namespace osu.Game.Overlays.Settings.Sections configBindable.BindValueChanged(id => dropdownBindable.Value = skinDropdown.Items.Single(s => s.ID == id.NewValue), true); dropdownBindable.BindValueChanged(skin => { - if (v == random_skin_info) + if (skin.NewValue == random_skin_info) randomizeSkin(); else - configBindable.Value = skin.NewValue.ID ?? 0; + configBindable.Value = skin.NewValue.ID; }); } @@ -102,41 +113,29 @@ namespace osu.Game.Overlays.Settings.Sections configBindable.Value = 0; } - private void itemRemoved(SkinInfo s) => Schedule(() => + private void updateItems() { - usableSkins.RemoveAll(i => i.ID == s.ID); - resetSkinButtons(); - }); + usableSkins = skins.GetAllUsableSkins(); - private void itemAdded(SkinInfo s) - { - if (existing) - return; + if (usableSkins.Count > 1) + usableSkins.Add(random_skin_info); - Schedule(() => - { - usableSkins.Add(s); - resetSkinButtons(); - }); + skinDropdown.Items = usableSkins; } - private void resetSkinButtons() + private void itemUpdated(ValueChangedEvent<WeakReference<SkinInfo>> weakItem) { - skinDropdown.Items = usableSkins.Count > 1 ? usableSkins.Concat(new[] { random_skin_info }) : usableSkins; + if (weakItem.NewValue.TryGetTarget(out var item)) + Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => !i.Equals(item)).Append(item).ToArray()); } - protected override void Dispose(bool isDisposing) + private void itemRemoved(ValueChangedEvent<WeakReference<SkinInfo>> weakItem) { - base.Dispose(isDisposing); - - if (skins != null) - { - skins.ItemAdded -= itemAdded; - skins.ItemRemoved -= itemRemoved; - } + if (weakItem.NewValue.TryGetTarget(out var item)) + Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => i.ID != item.ID).ToArray()); } - private class SizeSlider : OsuSliderBar<double> + private class SizeSlider : OsuSliderBar<float> { public override string TooltipText => Current.Value.ToString(@"0.##x"); } @@ -161,5 +160,35 @@ namespace osu.Game.Overlays.Settings.Sections public override string ToString() => Name; } + + private class ExportSkinButton : SettingsButton + { + [Resolved] + private SkinManager skins { get; set; } + + private Bindable<Skin> currentSkin; + + [BackgroundDependencyLoader] + private void load() + { + Text = "Export selected skin"; + Action = export; + + currentSkin = skins.CurrentSkin.GetBoundCopy(); + currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.ID > 0, true); + } + + private void export() + { + try + { + skins.Export(currentSkin.Value.SkinInfo); + } + catch (Exception e) + { + Logger.Log($"Could not export current skin: {e.Message}", level: LogLevel.Error); + } + } + } } } diff --git a/osu.Game/Overlays/Settings/SettingsCheckbox.cs b/osu.Game/Overlays/Settings/SettingsCheckbox.cs index a554159fd7..437b2e45b3 100644 --- a/osu.Game/Overlays/Settings/SettingsCheckbox.cs +++ b/osu.Game/Overlays/Settings/SettingsCheckbox.cs @@ -8,16 +8,14 @@ namespace osu.Game.Overlays.Settings { public class SettingsCheckbox : SettingsItem<bool> { - private OsuCheckbox checkbox; - private string labelText; - protected override Drawable CreateControl() => checkbox = new OsuCheckbox(); + protected override Drawable CreateControl() => new OsuCheckbox(); public override string LabelText { get => labelText; - set => checkbox.LabelText = labelText = value; + set => ((OsuCheckbox)Control).LabelText = labelText = value; } } } diff --git a/osu.Game/Overlays/Settings/SettingsDropdown.cs b/osu.Game/Overlays/Settings/SettingsDropdown.cs index 167061f485..1175ddaab8 100644 --- a/osu.Game/Overlays/Settings/SettingsDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsDropdown.cs @@ -38,6 +38,8 @@ namespace osu.Game.Overlays.Settings Margin = new MarginPadding { Top = 5 }; RelativeSizeAxes = Axes.X; } + + protected override DropdownMenu CreateMenu() => base.CreateMenu().With(m => m.MaxHeight = 200); } } } diff --git a/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs b/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs index 9f09f251c2..c77d14632b 100644 --- a/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs @@ -1,12 +1,14 @@ // 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. +using System; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { public class SettingsEnumDropdown<T> : SettingsDropdown<T> + where T : struct, Enum { protected override OsuDropdown<T> CreateDropdown() => new DropdownControl(); diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index d48c0b6b66..278479e04f 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -1,6 +1,7 @@ // 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. +using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -20,7 +21,7 @@ using osuTK; namespace osu.Game.Overlays.Settings { - public abstract class SettingsItem<T> : Container, IFilterable + public abstract class SettingsItem<T> : Container, IFilterable, ISettingsItem, IHasCurrentValue<T> { protected abstract Drawable CreateControl(); @@ -32,51 +33,43 @@ namespace osu.Game.Overlays.Settings protected readonly FillFlowContainer FlowContent; - private SpriteText text; - - private readonly RestoreDefaultValueButton restoreDefaultButton; + private SpriteText labelText; public bool ShowsDefaultIndicator = true; public virtual string LabelText { - get => text?.Text ?? string.Empty; + get => labelText?.Text ?? string.Empty; set { - if (text == null) + if (labelText == null) { // construct lazily for cases where the label is not needed (may be provided by the Control). - FlowContent.Insert(-1, text = new OsuSpriteText()); + FlowContent.Insert(-1, labelText = new OsuSpriteText()); + + updateDisabled(); } - text.Text = value; + labelText.Text = value; } } - // hold a reference to the provided bindable so we don't have to in every settings section. - private Bindable<T> bindable; - - public virtual Bindable<T> Bindable + [Obsolete("Use Current instead")] // Can be removed 20210406 + public Bindable<T> Bindable { - get => bindable; - - set - { - if (bindable != null) - controlWithCurrent?.Current.UnbindFrom(bindable); - - bindable = value; - controlWithCurrent?.Current.BindTo(bindable); - - if (ShowsDefaultIndicator) - { - restoreDefaultButton.Bindable = bindable.GetBoundCopy(); - restoreDefaultButton.Bindable.TriggerChange(); - } - } + get => Current; + set => Current = value; } - public virtual IEnumerable<string> FilterTerms => new[] { LabelText }; + public virtual Bindable<T> Current + { + get => controlWithCurrent.Current; + set => controlWithCurrent.Current = value; + } + + public virtual IEnumerable<string> FilterTerms => Keywords == null ? new[] { LabelText } : new List<string>(Keywords) { LabelText }.ToArray(); + + public IEnumerable<string> Keywords { get; set; } public bool MatchingFilter { @@ -85,8 +78,12 @@ namespace osu.Game.Overlays.Settings public bool FilteringActive { get; set; } + public event Action SettingChanged; + protected SettingsItem() { + RestoreDefaultValueButton restoreDefaultButton; + RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Padding = new MarginPadding { Right = SettingsPanel.CONTENT_MARGINS }; @@ -102,13 +99,23 @@ namespace osu.Game.Overlays.Settings Child = Control = CreateControl() }, }; + + // all bindable logic is in constructor intentionally to support "CreateSettingsControls" being used in a context it is + // never loaded, but requires bindable storage. + if (controlWithCurrent != null) + { + controlWithCurrent.Current.ValueChanged += _ => SettingChanged?.Invoke(); + controlWithCurrent.Current.DisabledChanged += _ => updateDisabled(); + + if (ShowsDefaultIndicator) + restoreDefaultButton.Bindable = controlWithCurrent.Current; + } } - [BackgroundDependencyLoader] - private void load() + private void updateDisabled() { - if (controlWithCurrent != null) - controlWithCurrent.Current.DisabledChanged += disabled => { Colour = disabled ? Color4.Gray : Color4.White; }; + if (labelText != null) + labelText.Alpha = controlWithCurrent.Current.Disabled ? 0.3f : 1; } private class RestoreDefaultValueButton : Container, IHasTooltip @@ -123,6 +130,8 @@ namespace osu.Game.Overlays.Settings bindable = value; bindable.ValueChanged += _ => UpdateState(); bindable.DisabledChanged += _ => UpdateState(); + bindable.DefaultChanged += _ => UpdateState(); + UpdateState(); } } @@ -167,11 +176,7 @@ namespace osu.Game.Overlays.Settings UpdateState(); } - public string TooltipText => "Revert to default"; - - protected override bool OnMouseDown(MouseDownEvent e) => true; - - protected override bool OnMouseUp(MouseUpEvent e) => true; + public string TooltipText => "revert to default"; protected override bool OnClick(ClickEvent e) { diff --git a/osu.Game/Overlays/Settings/SettingsSection.cs b/osu.Game/Overlays/Settings/SettingsSection.cs index c878a9fc65..97e4ba9da7 100644 --- a/osu.Game/Overlays/Settings/SettingsSection.cs +++ b/osu.Game/Overlays/Settings/SettingsSection.cs @@ -11,7 +11,6 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using System.Collections.Generic; using System.Linq; -using osu.Framework.Graphics.Sprites; namespace osu.Game.Overlays.Settings { @@ -20,11 +19,11 @@ namespace osu.Game.Overlays.Settings protected FillFlowContainer FlowContent; protected override Container<Drawable> Content => FlowContent; - public abstract IconUsage Icon { get; } + public abstract Drawable CreateIcon(); public abstract string Header { get; } public IEnumerable<IFilterable> FilterableChildren => Children.OfType<IFilterable>(); - public IEnumerable<string> FilterTerms => new[] { Header }; + public virtual IEnumerable<string> FilterTerms => new[] { Header }; private const int header_size = 26; private const int header_margin = 25; diff --git a/osu.Game/Overlays/Settings/SettingsSlider.cs b/osu.Game/Overlays/Settings/SettingsSlider.cs index fd96ea972a..9fc3379b94 100644 --- a/osu.Game/Overlays/Settings/SettingsSlider.cs +++ b/osu.Game/Overlays/Settings/SettingsSlider.cs @@ -3,35 +3,52 @@ using System; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { public class SettingsSlider<T> : SettingsSlider<T, OsuSliderBar<T>> - where T : struct, IEquatable<T>, IComparable, IConvertible + where T : struct, IEquatable<T>, IComparable<T>, IConvertible { } - public class SettingsSlider<T, U> : SettingsItem<T> - where T : struct, IEquatable<T>, IComparable, IConvertible - where U : OsuSliderBar<T>, new() + public class SettingsSlider<TValue, TSlider> : SettingsItem<TValue> + where TValue : struct, IEquatable<TValue>, IComparable<TValue>, IConvertible + where TSlider : OsuSliderBar<TValue>, new() { - protected override Drawable CreateControl() => new U + protected override Drawable CreateControl() => new TSlider { Margin = new MarginPadding { Top = 5, Bottom = 5 }, RelativeSizeAxes = Axes.X }; + /// <summary> + /// When set, value changes based on user input are only transferred to any bound control's Current on commit. + /// This is useful if the UI interaction could be adversely affected by the value changing, such as the position of the <see cref="SliderBar{T}"/> on the screen. + /// </summary> public bool TransferValueOnCommit { - get => ((U)Control).TransferValueOnCommit; - set => ((U)Control).TransferValueOnCommit = value; + get => ((TSlider)Control).TransferValueOnCommit; + set => ((TSlider)Control).TransferValueOnCommit = value; } + /// <summary> + /// A custom step value for each key press which actuates a change on this control. + /// </summary> public float KeyboardStep { - get => ((U)Control).KeyboardStep; - set => ((U)Control).KeyboardStep = value; + get => ((TSlider)Control).KeyboardStep; + set => ((TSlider)Control).KeyboardStep = value; + } + + /// <summary> + /// Whether to format the tooltip as a percentage or the actual value. + /// </summary> + public bool DisplayAsPercentage + { + get => ((TSlider)Control).DisplayAsPercentage; + set => ((TSlider)Control).DisplayAsPercentage = value; } } } diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index c9c763e8d4..b096c146a6 100644 --- a/osu.Game/Overlays/Settings/SettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/SettingsSubsection.cs @@ -21,7 +21,7 @@ namespace osu.Game.Overlays.Settings protected abstract string Header { get; } public IEnumerable<IFilterable> FilterableChildren => Children.OfType<IFilterable>(); - public IEnumerable<string> FilterTerms => new[] { Header }; + public virtual IEnumerable<string> FilterTerms => new[] { Header }; public bool MatchingFilter { @@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Settings { Text = Header.ToUpperInvariant(), Margin = new MarginPadding { Bottom = 10, Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, - Font = OsuFont.GetFont(weight: FontWeight.Black), + Font = OsuFont.GetFont(weight: FontWeight.Bold), }, FlowContent }); diff --git a/osu.Game/Overlays/Settings/SettingsTextBox.cs b/osu.Game/Overlays/Settings/SettingsTextBox.cs index 0f257c2bfb..5e700a1d6b 100644 --- a/osu.Game/Overlays/Settings/SettingsTextBox.cs +++ b/osu.Game/Overlays/Settings/SettingsTextBox.cs @@ -12,6 +12,7 @@ namespace osu.Game.Overlays.Settings { Margin = new MarginPadding { Top = 5 }, RelativeSizeAxes = Axes.X, + CommitOnFocusLost = true, }; } } diff --git a/osu.Game/Overlays/Settings/Sidebar.cs b/osu.Game/Overlays/Settings/Sidebar.cs index a80b7c1108..031ecaae46 100644 --- a/osu.Game/Overlays/Settings/Sidebar.cs +++ b/osu.Game/Overlays/Settings/Sidebar.cs @@ -4,22 +4,21 @@ using System; using System.Linq; using osu.Framework; -using osuTK; -using osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Game.Graphics.Containers; -using osu.Game.Overlays.Toolbar; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays.Settings { public class Sidebar : Container<SidebarButton>, IStateful<ExpandedState> { private readonly FillFlowContainer<SidebarButton> content; - public const float DEFAULT_WIDTH = ToolbarButton.WIDTH; + public const float DEFAULT_WIDTH = Toolbar.Toolbar.HEIGHT * 1.4f; public const int EXPANDED_WIDTH = 200; public event Action<ExpandedState> StateChanged; @@ -84,6 +83,7 @@ namespace osu.Game.Overlays.Settings Content.Anchor = Anchor.CentreLeft; Content.Origin = Anchor.CentreLeft; RelativeSizeAxes = Axes.Both; + ScrollbarVisible = false; } } diff --git a/osu.Game/Overlays/Settings/SidebarButton.cs b/osu.Game/Overlays/Settings/SidebarButton.cs index a94f76e7af..30a53b351d 100644 --- a/osu.Game/Overlays/Settings/SidebarButton.cs +++ b/osu.Game/Overlays/Settings/SidebarButton.cs @@ -1,7 +1,6 @@ // 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. -using System; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; @@ -9,21 +8,19 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings { - public class SidebarButton : Button + public class SidebarButton : OsuButton { - private readonly SpriteIcon drawableIcon; + private readonly ConstrainedIconContainer iconContainer; private readonly SpriteText headerText; private readonly Box selectionIndicator; private readonly Container text; - public new Action<SettingsSection> Action; private SettingsSection section; @@ -34,7 +31,7 @@ namespace osu.Game.Overlays.Settings { section = value; headerText.Text = value.Header; - drawableIcon.Icon = value.Icon; + iconContainer.Icon = value.CreateIcon(); } } @@ -62,12 +59,11 @@ namespace osu.Game.Overlays.Settings public SidebarButton() { - BackgroundColour = OsuColour.Gray(60); - Background.Alpha = 0; - Height = Sidebar.DEFAULT_WIDTH; RelativeSizeAxes = Axes.X; + BackgroundColour = Color4.Black; + AddRange(new Drawable[] { text = new Container @@ -83,7 +79,7 @@ namespace osu.Game.Overlays.Settings Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, - drawableIcon = new SpriteIcon + iconContainer = new ConstrainedIconContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -99,7 +95,6 @@ namespace osu.Game.Overlays.Settings Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, }, - new HoverClickSounds(HoverSampleSet.Loud), }); } @@ -108,23 +103,5 @@ namespace osu.Game.Overlays.Settings { selectionIndicator.Colour = colours.Yellow; } - - protected override bool OnClick(ClickEvent e) - { - Action?.Invoke(section); - return base.OnClick(e); - } - - protected override bool OnHover(HoverEvent e) - { - Background.FadeTo(0.4f, 200); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - Background.FadeTo(0, 200); - base.OnHoverLost(e); - } } } diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index bb84de5d3a..e1bcdbbaf0 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -13,8 +13,12 @@ using osu.Framework.Bindables; namespace osu.Game.Overlays { - public class SettingsOverlay : SettingsPanel + public class SettingsOverlay : SettingsPanel, INamedOverlayComponent { + public string IconTexture => "Icons/Hexacons/settings"; + public string Title => "settings"; + public string Description => "change the way osu! behaves"; + protected override IEnumerable<SettingsSection> CreateSections() => new SettingsSection[] { new GeneralSection(), @@ -30,7 +34,7 @@ namespace osu.Game.Overlays private readonly List<SettingsSubPanel> subPanels = new List<SettingsSubPanel>(); - protected override Drawable CreateHeader() => new SettingsHeader("settings", "Change the way osu! behaves"); + protected override Drawable CreateHeader() => new SettingsHeader(Title, Description); protected override Drawable CreateFooter() => new SettingsFooter(); public SettingsOverlay() diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index 474f529bb1..2948231c4b 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -37,7 +37,7 @@ namespace osu.Game.Overlays protected SettingsSectionsContainer SectionsContainer; - private SearchTextBox searchTextBox; + private SeekLimitedSearchTextBox searchTextBox; /// <summary> /// Provide a source for the toolbar height. @@ -80,7 +80,7 @@ namespace osu.Game.Overlays Masking = true, RelativeSizeAxes = Axes.Both, ExpandableHeader = CreateHeader(), - FixedHeader = searchTextBox = new SearchTextBox + FixedHeader = searchTextBox = new SeekLimitedSearchTextBox { RelativeSizeAxes = Axes.X, Origin = Anchor.TopCentre, @@ -91,7 +91,6 @@ namespace osu.Game.Overlays Top = 20, Bottom = 20 }, - Exit = Hide, }, Footer = CreateFooter() }, @@ -124,9 +123,9 @@ namespace osu.Game.Overlays var button = new SidebarButton { Section = section, - Action = s => + Action = () => { - SectionsContainer.ScrollTo(s); + SectionsContainer.ScrollTo(section); Sidebar.State = ExpandedState.Contracted; }, }; @@ -186,7 +185,7 @@ namespace osu.Game.Overlays base.UpdateAfterChildren(); ContentContainer.Margin = new MarginPadding { Left = Sidebar?.DrawWidth ?? 0 }; - ContentContainer.Padding = new MarginPadding { Top = GetToolbarHeight?.Invoke() ?? 0 }; + Padding = new MarginPadding { Top = GetToolbarHeight?.Invoke() ?? 0 }; } protected class SettingsSectionsContainer : SectionsContainer<SettingsSection> diff --git a/osu.Game/Overlays/SettingsSubPanel.cs b/osu.Game/Overlays/SettingsSubPanel.cs index 7f794e2927..1fa233d9d4 100644 --- a/osu.Game/Overlays/SettingsSubPanel.cs +++ b/osu.Game/Overlays/SettingsSubPanel.cs @@ -3,16 +3,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Input.Bindings; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; -using osu.Game.Screens.Ranking; using osuTK; +using osuTK.Graphics; namespace osu.Game.Overlays { @@ -36,28 +34,27 @@ namespace osu.Game.Overlays protected override bool DimMainContent => false; // dimming is handled by main overlay - private class BackButton : OsuClickableContainer, IKeyBindingHandler<GlobalAction> + private class BackButton : OsuButton { - private AspectContainer aspect; - [BackgroundDependencyLoader] private void load() { Size = new Vector2(Sidebar.DEFAULT_WIDTH); - Children = new Drawable[] + + BackgroundColour = Color4.Black; + + AddRange(new Drawable[] { - aspect = new AspectContainer + new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Y, Children = new Drawable[] { new SpriteIcon { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Y = -15, Size = new Vector2(15), Shadow = true, Icon = FontAwesome.Solid.ChevronLeft @@ -72,34 +69,8 @@ namespace osu.Game.Overlays }, } } - }; + }); } - - protected override bool OnMouseDown(MouseDownEvent e) - { - aspect.ScaleTo(0.75f, 2000, Easing.OutQuint); - return base.OnMouseDown(e); - } - - protected override bool OnMouseUp(MouseUpEvent e) - { - aspect.ScaleTo(1, 1000, Easing.OutElastic); - return base.OnMouseUp(e); - } - - public bool OnPressed(GlobalAction action) - { - switch (action) - { - case GlobalAction.Back: - Click(); - return true; - } - - return false; - } - - public bool OnReleased(GlobalAction action) => false; } } } diff --git a/osu.Game/Overlays/Social/FilterControl.cs b/osu.Game/Overlays/Social/FilterControl.cs deleted file mode 100644 index 6abf6ec98d..0000000000 --- a/osu.Game/Overlays/Social/FilterControl.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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. - -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Overlays.SearchableList; - -namespace osu.Game.Overlays.Social -{ - public class FilterControl : SearchableListFilterControl<SocialSortCriteria, SortDirection> - { - protected override Color4 BackgroundColour => OsuColour.FromHex(@"47253a"); - protected override SocialSortCriteria DefaultTab => SocialSortCriteria.Rank; - - public FilterControl() - { - Tabs.Margin = new MarginPadding { Top = 10 }; - } - } - - public enum SocialSortCriteria - { - Rank, - Name, - Location, - //[Description("Time Zone")] - //TimeZone, - //[Description("World Map")] - //WorldMap, - } -} diff --git a/osu.Game/Overlays/Social/Header.cs b/osu.Game/Overlays/Social/Header.cs deleted file mode 100644 index 22bca9b421..0000000000 --- a/osu.Game/Overlays/Social/Header.cs +++ /dev/null @@ -1,66 +0,0 @@ -// 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. - -using osu.Game.Overlays.SearchableList; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Framework.Allocation; -using System.ComponentModel; -using osu.Framework.Graphics.Sprites; - -namespace osu.Game.Overlays.Social -{ - public class Header : SearchableListHeader<SocialTab> - { - private OsuSpriteText browser; - - protected override Color4 BackgroundColour => OsuColour.FromHex(@"38202e"); - - protected override SocialTab DefaultTab => SocialTab.AllPlayers; - protected override IconUsage Icon => FontAwesome.Solid.Users; - - protected override Drawable CreateHeaderText() - { - return new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new[] - { - new OsuSpriteText - { - Text = "social ", - Font = OsuFont.GetFont(size: 25), - }, - browser = new OsuSpriteText - { - Text = "browser", - Font = OsuFont.GetFont(size: 25, weight: FontWeight.Light), - }, - }, - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - browser.Colour = colours.Pink; - } - } - - public enum SocialTab - { - [Description("All Players")] - AllPlayers, - - [Description("Friends")] - Friends, - //[Description("Team Members")] - //TeamMembers, - //[Description("Chat Channels")] - //ChatChannels, - } -} diff --git a/osu.Game/Overlays/Social/SocialGridPanel.cs b/osu.Game/Overlays/Social/SocialGridPanel.cs deleted file mode 100644 index 6f707d640b..0000000000 --- a/osu.Game/Overlays/Social/SocialGridPanel.cs +++ /dev/null @@ -1,16 +0,0 @@ -// 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. - -using osu.Game.Users; - -namespace osu.Game.Overlays.Social -{ - public class SocialGridPanel : SocialPanel - { - public SocialGridPanel(User user) - : base(user) - { - Width = 300; - } - } -} diff --git a/osu.Game/Overlays/Social/SocialListPanel.cs b/osu.Game/Overlays/Social/SocialListPanel.cs deleted file mode 100644 index 1ba91e9204..0000000000 --- a/osu.Game/Overlays/Social/SocialListPanel.cs +++ /dev/null @@ -1,17 +0,0 @@ -// 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. - -using osu.Framework.Graphics; -using osu.Game.Users; - -namespace osu.Game.Overlays.Social -{ - public class SocialListPanel : SocialPanel - { - public SocialListPanel(User user) - : base(user) - { - RelativeSizeAxes = Axes.X; - } - } -} diff --git a/osu.Game/Overlays/Social/SocialPanel.cs b/osu.Game/Overlays/Social/SocialPanel.cs deleted file mode 100644 index 555527670a..0000000000 --- a/osu.Game/Overlays/Social/SocialPanel.cs +++ /dev/null @@ -1,61 +0,0 @@ -// 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. - -using osuTK; -using osuTK.Graphics; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Effects; -using osu.Framework.Input.Events; -using osu.Game.Users; - -namespace osu.Game.Overlays.Social -{ - public class SocialPanel : UserPanel - { - private const double hover_transition_time = 400; - - public SocialPanel(User user) - : base(user) - { - } - - private readonly EdgeEffectParameters edgeEffectNormal = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Offset = new Vector2(0f, 1f), - Radius = 2f, - Colour = Color4.Black.Opacity(0.25f), - }; - - private readonly EdgeEffectParameters edgeEffectHovered = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Offset = new Vector2(0f, 5f), - Radius = 10f, - Colour = Color4.Black.Opacity(0.3f), - }; - - protected override bool OnHover(HoverEvent e) - { - Content.TweenEdgeEffectTo(edgeEffectHovered, hover_transition_time, Easing.OutQuint); - Content.MoveToY(-4, hover_transition_time, Easing.OutQuint); - - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - Content.TweenEdgeEffectTo(edgeEffectNormal, hover_transition_time, Easing.OutQuint); - Content.MoveToY(0, hover_transition_time, Easing.OutQuint); - - base.OnHoverLost(e); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - this.FadeInFromZero(200, Easing.Out); - } - } -} diff --git a/osu.Game/Overlays/SocialOverlay.cs b/osu.Game/Overlays/SocialOverlay.cs deleted file mode 100644 index 4def249200..0000000000 --- a/osu.Game/Overlays/SocialOverlay.cs +++ /dev/null @@ -1,213 +0,0 @@ -// 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. - -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Bindables; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.SearchableList; -using osu.Game.Overlays.Social; -using osu.Game.Users; -using osu.Framework.Threading; - -namespace osu.Game.Overlays -{ - public class SocialOverlay : SearchableListOverlay<SocialTab, SocialSortCriteria, SortDirection> - { - private readonly LoadingAnimation loading; - private FillFlowContainer<SocialPanel> panels; - - protected override Color4 BackgroundColour => OsuColour.FromHex(@"60284b"); - protected override Color4 TrianglesColourLight => OsuColour.FromHex(@"672b51"); - protected override Color4 TrianglesColourDark => OsuColour.FromHex(@"5c2648"); - - protected override SearchableListHeader<SocialTab> CreateHeader() => new Header(); - protected override SearchableListFilterControl<SocialSortCriteria, SortDirection> CreateFilterControl() => new FilterControl(); - - private IEnumerable<User> users; - - public IEnumerable<User> Users - { - get => users; - set - { - if (users?.Equals(value) ?? false) - return; - - users = value?.ToList(); - } - } - - public SocialOverlay() - { - Waves.FirstWaveColour = OsuColour.FromHex(@"cb5fa0"); - Waves.SecondWaveColour = OsuColour.FromHex(@"b04384"); - Waves.ThirdWaveColour = OsuColour.FromHex(@"9b2b6e"); - Waves.FourthWaveColour = OsuColour.FromHex(@"6d214d"); - - Add(loading = new LoadingAnimation()); - - Filter.Search.Current.ValueChanged += text => - { - if (!string.IsNullOrEmpty(text.NewValue)) - { - // force searching in players until searching for friends is supported - Header.Tabs.Current.Value = SocialTab.AllPlayers; - - if (Filter.Tabs.Current.Value != SocialSortCriteria.Rank) - Filter.Tabs.Current.Value = SocialSortCriteria.Rank; - } - }; - - Header.Tabs.Current.ValueChanged += _ => queueUpdate(); - - Filter.Tabs.Current.ValueChanged += _ => queueUpdate(); - - Filter.DisplayStyleControl.DisplayStyle.ValueChanged += style => recreatePanels(style.NewValue); - Filter.DisplayStyleControl.Dropdown.Current.ValueChanged += _ => queueUpdate(); - - currentQuery.BindTo(Filter.Search.Current); - currentQuery.ValueChanged += query => - { - queryChangedDebounce?.Cancel(); - - if (string.IsNullOrEmpty(query.NewValue)) - queueUpdate(); - else - queryChangedDebounce = Scheduler.AddDelayed(updateSearch, 500); - }; - } - - private APIRequest getUsersRequest; - - private readonly Bindable<string> currentQuery = new Bindable<string>(); - - private ScheduledDelegate queryChangedDebounce; - - private void queueUpdate() => Scheduler.AddOnce(updateSearch); - - private void updateSearch() - { - queryChangedDebounce?.Cancel(); - - if (!IsLoaded) - return; - - Users = null; - clearPanels(); - loading.Hide(); - getUsersRequest?.Cancel(); - - if (API?.IsLoggedIn != true) - return; - - switch (Header.Tabs.Current.Value) - { - case SocialTab.Friends: - var friendRequest = new GetFriendsRequest(); // TODO filter arguments? - friendRequest.Success += updateUsers; - API.Queue(getUsersRequest = friendRequest); - break; - - default: - var userRequest = new GetUsersRequest(); // TODO filter arguments! - userRequest.Success += response => updateUsers(response.Select(r => r.User)); - API.Queue(getUsersRequest = userRequest); - break; - } - - loading.Show(); - } - - private void recreatePanels(PanelDisplayStyle displayStyle) - { - clearPanels(); - - if (Users == null) - return; - - var newPanels = new FillFlowContainer<SocialPanel> - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10f), - Margin = new MarginPadding { Top = 10 }, - ChildrenEnumerable = Users.Select(u => - { - SocialPanel panel; - - switch (displayStyle) - { - case PanelDisplayStyle.Grid: - panel = new SocialGridPanel(u) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - }; - break; - - default: - panel = new SocialListPanel(u); - break; - } - - panel.Status.BindTo(u.Status); - panel.Activity.BindTo(u.Activity); - return panel; - }) - }; - - LoadComponentAsync(newPanels, f => - { - if (panels != null) - ScrollFlow.Remove(panels); - - ScrollFlow.Add(panels = newPanels); - }); - } - - private void updateUsers(IEnumerable<User> newUsers) - { - Users = newUsers; - loading.Hide(); - recreatePanels(Filter.DisplayStyleControl.DisplayStyle.Value); - } - - private void clearPanels() - { - if (panels != null) - { - panels.Expire(); - panels = null; - } - } - - public override void APIStateChanged(IAPIProvider api, APIState state) - { - switch (state) - { - case APIState.Online: - queueUpdate(); - break; - - default: - Users = null; - clearPanels(); - break; - } - } - } - - public enum SortDirection - { - Ascending, - Descending - } -} diff --git a/osu.Game/Overlays/SortDirection.cs b/osu.Game/Overlays/SortDirection.cs new file mode 100644 index 0000000000..3af9614972 --- /dev/null +++ b/osu.Game/Overlays/SortDirection.cs @@ -0,0 +1,11 @@ +// 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. + +namespace osu.Game.Overlays +{ + public enum SortDirection + { + Ascending, + Descending + } +} diff --git a/osu.Game/Overlays/TabControlOverlayHeader.cs b/osu.Game/Overlays/TabControlOverlayHeader.cs new file mode 100644 index 0000000000..7798dfa576 --- /dev/null +++ b/osu.Game/Overlays/TabControlOverlayHeader.cs @@ -0,0 +1,117 @@ +// 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. + +using System; +using JetBrains.Annotations; +using osu.Framework.Allocation; +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.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays +{ + /// <summary> + /// An overlay header which contains a <see cref="OsuTabControl{T}"/>. + /// </summary> + /// <typeparam name="T">The type of item to be represented by tabs.</typeparam> + public abstract class TabControlOverlayHeader<T> : OverlayHeader, IHasCurrentValue<T> + { + protected OsuTabControl<T> TabControl; + + private readonly Box controlBackground; + private readonly Container tabControlContainer; + private readonly BindableWithCurrent<T> current = new BindableWithCurrent<T>(); + + public Bindable<T> Current + { + get => current.Current; + set => current.Current = value; + } + + protected new float ContentSidePadding + { + get => base.ContentSidePadding; + set + { + base.ContentSidePadding = value; + tabControlContainer.Padding = new MarginPadding { Horizontal = value }; + } + } + + protected TabControlOverlayHeader() + { + HeaderInfo.Add(new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + controlBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + tabControlContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = ContentSidePadding }, + Child = TabControl = CreateTabControl().With(control => + { + control.Current = Current; + }) + } + } + }); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + controlBackground.Colour = colourProvider.Dark4; + } + + [NotNull] + protected virtual OsuTabControl<T> CreateTabControl() => new OverlayHeaderTabControl(); + + public class OverlayHeaderTabControl : OverlayTabControl<T> + { + private const float bar_height = 1; + + public OverlayHeaderTabControl() + { + RelativeSizeAxes = Axes.None; + AutoSizeAxes = Axes.X; + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + Height = 47; + BarHeight = bar_height; + } + + protected override TabItem<T> CreateTabItem(T value) => new OverlayHeaderTabItem(value); + + protected override TabFillFlowContainer CreateTabFlow() => new TabFillFlowContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + }; + + private class OverlayHeaderTabItem : OverlayTabItem + { + public OverlayHeaderTabItem(T value) + : base(value) + { + Text.Text = ((Value as Enum)?.GetDescription() ?? Value.ToString()).ToLower(); + Text.Font = OsuFont.GetFont(size: 14); + Text.Margin = new MarginPadding { Vertical = 16.5f }; // 15px padding + 1.5px line-height difference compensation + Bar.Margin = new MarginPadding { Bottom = bar_height }; + } + } + } + } +} diff --git a/osu.Game/Overlays/Toolbar/Toolbar.cs b/osu.Game/Overlays/Toolbar/Toolbar.cs index 19038c3981..393e349bd0 100644 --- a/osu.Game/Overlays/Toolbar/Toolbar.cs +++ b/osu.Game/Overlays/Toolbar/Toolbar.cs @@ -16,7 +16,7 @@ using osu.Game.Rulesets; namespace osu.Game.Overlays.Toolbar { - public class Toolbar : OverlayContainer + public class Toolbar : VisibilityContainer { public const float HEIGHT = 40; public const float TOOLTIP_HEIGHT = 30; @@ -26,14 +26,12 @@ namespace osu.Game.Overlays.Toolbar private ToolbarUserButton userButton; private ToolbarRulesetSelector rulesetSelector; - protected override bool BlockPositionalInput => false; - private const double transition_time = 500; - private const float alpha_hovering = 0.8f; - private const float alpha_normal = 0.6f; + protected readonly IBindable<OverlayActivation> OverlayActivationMode = new Bindable<OverlayActivation>(OverlayActivation.All); - private readonly Bindable<OverlayActivation> overlayActivationMode = new Bindable<OverlayActivation>(OverlayActivation.All); + // Toolbar components like RulesetSelector should receive keyboard input events even when the toolbar is hidden. + public override bool PropagateNonPositionalInputSubTree => true; public Toolbar() { @@ -71,8 +69,10 @@ namespace osu.Game.Overlays.Toolbar AutoSizeAxes = Axes.X, Children = new Drawable[] { + new ToolbarNewsButton(), new ToolbarChangelogButton(), - new ToolbarDirectButton(), + new ToolbarRankingsButton(), + new ToolbarBeatmapListingButton(), new ToolbarChatButton(), new ToolbarSocialButton(), new ToolbarMusicButton(), @@ -89,19 +89,12 @@ namespace osu.Game.Overlays.Toolbar // Bound after the selector is added to the hierarchy to give it a chance to load the available rulesets rulesetSelector.Current.BindTo(parentRuleset); - State.ValueChanged += visibility => - { - if (overlayActivationMode.Value == OverlayActivation.Disabled) - Hide(); - }; - if (osuGame != null) - overlayActivationMode.BindTo(osuGame.OverlayActivationMode); + OverlayActivationMode.BindTo(osuGame.OverlayActivationMode); } public class ToolbarBackground : Container { - private readonly Box solidBackground; private readonly Box gradientBackground; public ToolbarBackground() @@ -109,50 +102,58 @@ namespace osu.Game.Overlays.Toolbar RelativeSizeAxes = Axes.Both; Children = new Drawable[] { - solidBackground = new Box + new Box { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(0.1f), - Alpha = alpha_normal, }, gradientBackground = new Box { RelativeSizeAxes = Axes.X, Anchor = Anchor.BottomLeft, Alpha = 0, - Height = 90, + Height = 100, Colour = ColourInfo.GradientVertical( - OsuColour.Gray(0.1f).Opacity(0.5f), OsuColour.Gray(0.1f).Opacity(0)), + OsuColour.Gray(0).Opacity(0.9f), OsuColour.Gray(0).Opacity(0)), }, }; } protected override bool OnHover(HoverEvent e) { - solidBackground.FadeTo(alpha_hovering, transition_time, Easing.OutQuint); gradientBackground.FadeIn(transition_time, Easing.OutQuint); return true; } protected override void OnHoverLost(HoverLostEvent e) { - solidBackground.FadeTo(alpha_normal, transition_time, Easing.OutQuint); gradientBackground.FadeOut(transition_time, Easing.OutQuint); } } + protected override void UpdateState(ValueChangedEvent<Visibility> state) + { + if (state.NewValue == Visibility.Visible && OverlayActivationMode.Value == OverlayActivation.Disabled) + { + State.Value = Visibility.Hidden; + return; + } + + base.UpdateState(state); + } + protected override void PopIn() { this.MoveToY(0, transition_time, Easing.OutQuint); - this.FadeIn(transition_time / 2, Easing.OutQuint); + this.FadeIn(transition_time / 4, Easing.OutQuint); } protected override void PopOut() { - userButton?.StateContainer.Hide(); + userButton.StateContainer?.Hide(); this.MoveToY(-DrawSize.Y, transition_time, Easing.OutQuint); - this.FadeOut(transition_time); + this.FadeOut(transition_time, Easing.InQuint); } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarDirectButton.cs b/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs similarity index 50% rename from osu.Game/Overlays/Toolbar/ToolbarDirectButton.cs rename to osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs index 1d07a3ae70..0363873326 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarDirectButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarBeatmapListingButton.cs @@ -2,21 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Game.Graphics; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { - public class ToolbarDirectButton : ToolbarOverlayToggleButton + public class ToolbarBeatmapListingButton : ToolbarOverlayToggleButton { - public ToolbarDirectButton() + public ToolbarBeatmapListingButton() { - SetIcon(OsuIcon.ChevronDownCircle); + Hotkey = GlobalAction.ToggleDirect; } [BackgroundDependencyLoader(true)] - private void load(DirectOverlay direct) + private void load(BeatmapListingOverlay beatmapListing) { - StateContainer = direct; + StateContainer = beatmapListing; } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 2b2b19b73a..49b9c62d85 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -1,26 +1,32 @@ // 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. +using osu.Framework.Allocation; +using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Containers; 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 osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Toolbar { - public class ToolbarButton : OsuClickableContainer + public abstract class ToolbarButton : OsuClickableContainer, IKeyBindingHandler<GlobalAction> { - public const float WIDTH = Toolbar.HEIGHT * 1.4f; + protected GlobalAction? Hotkey { get; set; } public void SetIcon(Drawable icon) { @@ -28,16 +34,14 @@ namespace osu.Game.Overlays.Toolbar IconContainer.Show(); } - public void SetIcon(IconUsage icon) => SetIcon(new SpriteIcon - { - Size = new Vector2(20), - Icon = icon - }); + [Resolved] + private TextureStore textures { get; set; } - public IconUsage Icon - { - set => SetIcon(value); - } + public void SetIcon(string texture) => + SetIcon(new Sprite + { + Texture = textures.Get(texture), + }); public string Text { @@ -62,15 +66,20 @@ namespace osu.Game.Overlays.Toolbar protected ConstrainedIconContainer IconContainer; protected SpriteText DrawableText; protected Box HoverBackground; + private readonly Box flashBackground; private readonly FillFlowContainer tooltipContainer; private readonly SpriteText tooltip1; private readonly SpriteText tooltip2; + private readonly SpriteText keyBindingTooltip; protected FillFlowContainer Flow; - public ToolbarButton() + [Resolved] + private KeyBindingStore keyBindings { get; set; } + + protected ToolbarButton() : base(HoverSampleSet.Loud) { - Width = WIDTH; + Width = Toolbar.HEIGHT; RelativeSizeAxes = Axes.Y; Children = new Drawable[] @@ -79,9 +88,16 @@ namespace osu.Game.Overlays.Toolbar { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(80).Opacity(180), - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Alpha = 0, }, + flashBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Color4.White.Opacity(100), + Blending = BlendingParameters.Additive, + }, Flow = new FillFlowContainer { Direction = FillDirection.Horizontal, @@ -97,7 +113,7 @@ namespace osu.Game.Overlays.Toolbar { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Size = new Vector2(20), + Size = new Vector2(26), Alpha = 0, }, DrawableText = new OsuSpriteText @@ -110,12 +126,12 @@ namespace osu.Game.Overlays.Toolbar tooltipContainer = new FillFlowContainer { Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.Both, //stops us being considered in parent's autosize + RelativeSizeAxes = Axes.Both, // stops us being considered in parent's autosize Anchor = TooltipAnchor.HasFlag(Anchor.x0) ? Anchor.BottomLeft : Anchor.BottomRight, Origin = TooltipAnchor, Position = new Vector2(TooltipAnchor.HasFlag(Anchor.x0) ? 5 : -5, 5), Alpha = 0, - Children = new[] + Children = new Drawable[] { tooltip1 = new OsuSpriteText { @@ -124,28 +140,57 @@ namespace osu.Game.Overlays.Toolbar Shadow = true, Font = OsuFont.GetFont(size: 22, weight: FontWeight.Bold), }, - tooltip2 = new OsuSpriteText + new FillFlowContainer { + AutoSizeAxes = Axes.Both, Anchor = TooltipAnchor, Origin = TooltipAnchor, - Shadow = true, + Direction = FillDirection.Horizontal, + Children = new[] + { + tooltip2 = new OsuSpriteText { Shadow = true }, + keyBindingTooltip = new OsuSpriteText { Shadow = true } + } } } } }; } + private readonly Cached tooltipKeyBinding = new Cached(); + + [BackgroundDependencyLoader] + private void load() + { + keyBindings.KeyBindingChanged += () => tooltipKeyBinding.Invalidate(); + updateKeyBindingTooltip(); + } + + private void updateKeyBindingTooltip() + { + if (tooltipKeyBinding.IsValid) + return; + + var binding = keyBindings.Query().Find(b => (GlobalAction)b.Action == Hotkey); + var keyBindingString = binding?.KeyCombination.ReadableString(); + keyBindingTooltip.Text = !string.IsNullOrEmpty(keyBindingString) ? $" ({keyBindingString})" : string.Empty; + + tooltipKeyBinding.Validate(); + } + protected override bool OnMouseDown(MouseDownEvent e) => true; protected override bool OnClick(ClickEvent e) { - HoverBackground.FlashColour(Color4.White.Opacity(100), 500, Easing.OutQuint); + flashBackground.FadeOutFromOne(800, Easing.OutQuint); tooltipContainer.FadeOut(100); return base.OnClick(e); } protected override bool OnHover(HoverEvent e) { + updateKeyBindingTooltip(); + HoverBackground.FadeIn(200); tooltipContainer.FadeIn(100); return base.OnHover(e); @@ -156,6 +201,21 @@ namespace osu.Game.Overlays.Toolbar HoverBackground.FadeOut(200); tooltipContainer.FadeOut(100); } + + public bool OnPressed(GlobalAction action) + { + if (action == Hotkey) + { + Click(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } } public class OpaqueBackground : Container diff --git a/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs b/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs index 84210e27a4..23f8b141b2 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarChangelogButton.cs @@ -2,17 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; namespace osu.Game.Overlays.Toolbar { public class ToolbarChangelogButton : ToolbarOverlayToggleButton { - public ToolbarChangelogButton() - { - SetIcon(FontAwesome.Solid.Bullhorn); - } - [BackgroundDependencyLoader(true)] private void load(ChangelogOverlay changelog) { diff --git a/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs b/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs index ad0e5be551..f9a66ae7bb 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarChatButton.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { @@ -10,7 +10,7 @@ namespace osu.Game.Overlays.Toolbar { public ToolbarChatButton() { - SetIcon(FontAwesome.Solid.Comments); + Hotkey = GlobalAction.ToggleChat; } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs index 6f5e703a66..76fbd40d66 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarHomeButton.cs @@ -1,7 +1,8 @@ // 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. -using osu.Framework.Graphics.Sprites; +using osu.Framework.Allocation; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { @@ -9,9 +10,16 @@ namespace osu.Game.Overlays.Toolbar { public ToolbarHomeButton() { - Icon = FontAwesome.Solid.Home; - TooltipMain = "Home"; - TooltipSub = "Return to the main menu"; + Width *= 1.4f; + Hotkey = GlobalAction.Home; + } + + [BackgroundDependencyLoader] + private void load() + { + TooltipMain = "home"; + TooltipSub = "return to the main menu"; + SetIcon("Icons/Hexacons/home"); } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs b/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs index f03df2ed93..0f5e8e5456 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarMusicButton.cs @@ -2,19 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { public class ToolbarMusicButton : ToolbarOverlayToggleButton { + protected override Anchor TooltipAnchor => Anchor.TopRight; + public ToolbarMusicButton() { - Icon = FontAwesome.Solid.Music; + Hotkey = GlobalAction.ToggleNowPlaying; } [BackgroundDependencyLoader(true)] - private void load(MusicController music) + private void load(NowPlayingOverlay music) { StateContainer = music; } diff --git a/osu.Game/Overlays/Toolbar/ToolbarNewsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarNewsButton.cs new file mode 100644 index 0000000000..0ba2935c80 --- /dev/null +++ b/osu.Game/Overlays/Toolbar/ToolbarNewsButton.cs @@ -0,0 +1,16 @@ +// 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. + +using osu.Framework.Allocation; + +namespace osu.Game.Overlays.Toolbar +{ + public class ToolbarNewsButton : ToolbarOverlayToggleButton + { + [BackgroundDependencyLoader(true)] + private void load(NewsOverlay news) + { + StateContainer = news; + } + } +} diff --git a/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs b/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs index dbd6c557d3..79d0fc74c1 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarNotificationButton.cs @@ -6,9 +6,9 @@ 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.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; @@ -24,9 +24,7 @@ namespace osu.Game.Overlays.Toolbar public ToolbarNotificationButton() { - Icon = FontAwesome.Solid.Bars; - TooltipMain = "Notifications"; - TooltipSub = "Waiting for 'ya"; + Hotkey = GlobalAction.ToggleNotifications; Add(countDisplay = new CountCircle { diff --git a/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs b/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs index b286cbfb1d..0dea71cc08 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarOverlayToggleButton.cs @@ -32,6 +32,13 @@ namespace osu.Game.Overlays.Toolbar Action = stateContainer.ToggleVisibility; overlayState.BindTo(stateContainer.State); } + + if (stateContainer is INamedOverlayComponent named) + { + TooltipMain = named.Title; + TooltipSub = named.Description; + SetIcon(named.IconTexture); + } } } @@ -41,7 +48,7 @@ namespace osu.Game.Overlays.Toolbar { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(150).Opacity(180), - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Depth = 2, Alpha = 0, }); diff --git a/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs new file mode 100644 index 0000000000..22a01bcdb5 --- /dev/null +++ b/osu.Game/Overlays/Toolbar/ToolbarRankingsButton.cs @@ -0,0 +1,16 @@ +// 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. + +using osu.Framework.Allocation; + +namespace osu.Game.Overlays.Toolbar +{ + public class ToolbarRankingsButton : ToolbarOverlayToggleButton + { + [BackgroundDependencyLoader(true)] + private void load(RankingsOverlay rankings) + { + StateContainer = rankings; + } + } +} diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs index 2c79f5bc0e..905d5b44c6 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetSelector.cs @@ -18,8 +18,6 @@ namespace osu.Game.Overlays.Toolbar { public class ToolbarRulesetSelector : RulesetSelector { - private const float padding = 10; - protected Drawable ModeButtonLine { get; private set; } public ToolbarRulesetSelector() @@ -39,7 +37,7 @@ namespace osu.Game.Overlays.Toolbar }, ModeButtonLine = new Container { - Size = new Vector2(padding * 2 + ToolbarButton.WIDTH, 3), + Size = new Vector2(Toolbar.HEIGHT, 3), Anchor = Anchor.BottomLeft, Origin = Anchor.TopLeft, Masking = true, @@ -91,7 +89,6 @@ namespace osu.Game.Overlays.Toolbar RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, Direction = FillDirection.Horizontal, - Padding = new MarginPadding { Left = padding, Right = padding }, }; protected override bool OnKeyDown(KeyDownEvent e) @@ -102,7 +99,7 @@ namespace osu.Game.Overlays.Toolbar { int requested = e.Key - Key.Number1; - RulesetInfo found = Rulesets.AvailableRulesets.Skip(requested).FirstOrDefault(); + RulesetInfo found = Rulesets.AvailableRulesets.ElementAtOrDefault(requested); if (found != null) Current.Value = found; return true; diff --git a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs index a5194ea752..564fd65719 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarRulesetTabButton.cs @@ -27,7 +27,7 @@ namespace osu.Game.Overlays.Toolbar var rInstance = value.CreateInstance(); ruleset.TooltipMain = rInstance.Description; - ruleset.TooltipSub = $"Play some {rInstance.Description}"; + ruleset.TooltipSub = $"play some {rInstance.Description}"; ruleset.SetIcon(rInstance.CreateIcon()); } @@ -65,12 +65,6 @@ namespace osu.Game.Overlays.Toolbar Parent.Click(); return base.OnClick(e); } - - protected override void LoadComplete() - { - base.LoadComplete(); - IconContainer.Scale *= 1.4f; - } } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs b/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs index 79942012f9..c53f4a55d9 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarSettingsButton.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { @@ -10,9 +10,8 @@ namespace osu.Game.Overlays.Toolbar { public ToolbarSettingsButton() { - Icon = FontAwesome.Solid.Cog; - TooltipMain = "Settings"; - TooltipSub = "Change your settings"; + Width *= 1.4f; + Hotkey = GlobalAction.ToggleSettings; } [BackgroundDependencyLoader(true)] diff --git a/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs b/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs index 5e353d3319..e62c7bc807 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics.Sprites; +using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Toolbar { @@ -10,13 +10,13 @@ namespace osu.Game.Overlays.Toolbar { public ToolbarSocialButton() { - Icon = FontAwesome.Solid.Users; + Hotkey = GlobalAction.ToggleSocial; } [BackgroundDependencyLoader(true)] - private void load(SocialOverlay chat) + private void load(DashboardOverlay dashboard) { - StateContainer = chat; + StateContainer = dashboard; } } } diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index bccef3d9fe..db4e491d9a 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.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.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; @@ -14,10 +15,15 @@ using osuTK.Graphics; namespace osu.Game.Overlays.Toolbar { - public class ToolbarUserButton : ToolbarOverlayToggleButton, IOnlineComponent + public class ToolbarUserButton : ToolbarOverlayToggleButton { private readonly UpdateableAvatar avatar; + [Resolved] + private IAPIProvider api { get; set; } + + private readonly IBindable<APIState> apiState = new Bindable<APIState>(); + public ToolbarUserButton() { AutoSizeAxes = Axes.X; @@ -44,16 +50,17 @@ namespace osu.Game.Overlays.Toolbar } [BackgroundDependencyLoader(true)] - private void load(IAPIProvider api, LoginOverlay login) + private void load(LoginOverlay login) { - api.Register(this); + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); StateContainer = login; } - public void APIStateChanged(IAPIProvider api, APIState state) + private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() => { - switch (state) + switch (state.NewValue) { default: Text = @"Guest"; @@ -65,6 +72,6 @@ namespace osu.Game.Overlays.Toolbar avatar.User = api.LocalUser.Value; break; } - } + }); } } diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs index b924b3302f..81027667fa 100644 --- a/osu.Game/Overlays/UserProfileOverlay.cs +++ b/osu.Game/Overlays/UserProfileOverlay.cs @@ -1,13 +1,14 @@ // 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. +using System; using System.Linq; 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; +using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Profile; @@ -17,18 +18,22 @@ using osuTK; namespace osu.Game.Overlays { - public class UserProfileOverlay : FullscreenOverlay + public class UserProfileOverlay : FullscreenOverlay<ProfileHeader> { private ProfileSection lastSection; private ProfileSection[] sections; private GetUserRequest userReq; - protected ProfileHeader Header; private ProfileSectionsContainer sectionsContainer; - private ProfileTabControl tabs; + private ProfileSectionTabControl tabs; public const float CONTENT_X_MARGIN = 70; - public void ShowUser(long userId) => ShowUser(new User { Id = userId }); + public UserProfileOverlay() + : base(OverlayColourScheme.Pink, new ProfileHeader()) + { + } + + public void ShowUser(int userId) => ShowUser(new User { Id = userId }); public void ShowUser(User user, bool fetchOnline = true) { @@ -40,42 +45,47 @@ namespace osu.Game.Overlays if (user.Id == Header?.User.Value?.Id) return; + if (sectionsContainer != null) + sectionsContainer.ExpandableHeader = null; + userReq?.Cancel(); Clear(); lastSection = null; - sections = new ProfileSection[] - { - //new AboutSection(), - new RecentSection(), - new RanksSection(), - //new MedalsSection(), - new HistoricalSection(), - new BeatmapsSection(), - new KudosuSection() - }; + sections = !user.IsBot + ? new ProfileSection[] + { + //new AboutSection(), + new RecentSection(), + new RanksSection(), + //new MedalsSection(), + new HistoricalSection(), + new BeatmapsSection(), + new KudosuSection() + } + : Array.Empty<ProfileSection>(); - tabs = new ProfileTabControl + tabs = new ProfileSectionTabControl { RelativeSizeAxes = Axes.X, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Height = 30 }; Add(new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.Gray(0.1f) + Colour = ColourProvider.Background6 }); Add(sectionsContainer = new ProfileSectionsContainer { - ExpandableHeader = Header = new ProfileHeader(), + ExpandableHeader = Header, FixedHeader = tabs, HeaderBackground = new Box { - Colour = OsuColour.Gray(34), + // this is only visible as the ProfileTabControl background + Colour = ColourProvider.Background5, RelativeSizeAxes = Axes.Both }, }); @@ -141,33 +151,46 @@ namespace osu.Game.Overlays } } - private class ProfileTabControl : OverlayTabControl<ProfileSection> + private class ProfileSectionTabControl : OverlayTabControl<ProfileSection> { - public ProfileTabControl() + private const float bar_height = 2; + + public ProfileSectionTabControl() { TabContainer.RelativeSizeAxes &= ~Axes.X; TabContainer.AutoSizeAxes |= Axes.X; TabContainer.Anchor |= Anchor.x1; TabContainer.Origin |= Anchor.x1; + + Height = 36 + bar_height; + BarHeight = bar_height; } - protected override TabItem<ProfileSection> CreateTabItem(ProfileSection value) => new ProfileTabItem(value) + protected override TabItem<ProfileSection> CreateTabItem(ProfileSection value) => new ProfileSectionTabItem(value) { - AccentColour = AccentColour + AccentColour = AccentColour, }; [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider) { - AccentColour = colours.Seafoam; + AccentColour = colourProvider.Highlight1; } - private class ProfileTabItem : OverlayTabItem<ProfileSection> + protected override bool OnClick(ClickEvent e) => true; + + protected override bool OnHover(HoverEvent e) => true; + + private class ProfileSectionTabItem : OverlayTabItem { - public ProfileTabItem(ProfileSection value) + public ProfileSectionTabItem(ProfileSection value) : base(value) { Text.Text = value.Title; + Text.Font = Text.Font.With(size: 16); + Text.Margin = new MarginPadding { Bottom = 10 + bar_height }; + Bar.ExpandedSize = 10; + Bar.Margin = new MarginPadding { Bottom = bar_height }; } } } @@ -179,6 +202,8 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both; } + protected override OsuScrollContainer CreateScrollContainer() => new OverlayScrollContainer(); + protected override FlowContainer<ProfileSection> CreateScrollContentContainer() => new FillFlowContainer<ProfileSection> { Direction = FillDirection.Vertical, diff --git a/osu.Game/Overlays/Volume/MuteButton.cs b/osu.Game/Overlays/Volume/MuteButton.cs index a4884dc2c1..bcc9394aba 100644 --- a/osu.Game/Overlays/Volume/MuteButton.cs +++ b/osu.Game/Overlays/Volume/MuteButton.cs @@ -43,6 +43,7 @@ namespace osu.Game.Overlays.Volume { Content.BorderThickness = 3; Content.CornerRadius = HEIGHT / 2; + Content.CornerExponent = 2; Size = new Vector2(width, HEIGHT); @@ -65,16 +66,15 @@ namespace osu.Game.Overlays.Volume { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(20), } }); - Current.ValueChanged += muted => + Current.BindValueChanged(muted => { icon.Icon = muted.NewValue ? FontAwesome.Solid.VolumeMute : FontAwesome.Solid.VolumeUp; - }; - - Current.TriggerChange(); + icon.Size = new Vector2(muted.NewValue ? 18 : 20); + icon.Margin = new MarginPadding { Right = muted.NewValue ? 2 : 0 }; + }, true); } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs index 26235fa280..3478f18a40 100644 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs @@ -9,13 +9,19 @@ using osu.Game.Input.Bindings; namespace osu.Game.Overlays.Volume { - public class VolumeControlReceptor : Container, IScrollBindingHandler<GlobalAction>, IHandleGlobalInput + public class VolumeControlReceptor : Container, IScrollBindingHandler<GlobalAction>, IHandleGlobalKeyboardInput { public Func<GlobalAction, bool> ActionRequested; public Func<GlobalAction, float, bool, bool> ScrollActionRequested; - public bool OnPressed(GlobalAction action) => ActionRequested?.Invoke(action) ?? false; - public bool OnScroll(GlobalAction action, float amount, bool isPrecise) => ScrollActionRequested?.Invoke(action, amount, isPrecise) ?? false; - public bool OnReleased(GlobalAction action) => false; + public bool OnPressed(GlobalAction action) => + ActionRequested?.Invoke(action) ?? false; + + public bool OnScroll(GlobalAction action, float amount, bool isPrecise) => + ScrollActionRequested?.Invoke(action, amount, isPrecise) ?? false; + + public void OnReleased(GlobalAction action) + { + } } } diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index da696e0fdd..07accf8820 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -11,16 +11,18 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Volume { - public class VolumeMeter : Container + public class VolumeMeter : Container, IKeyBindingHandler<GlobalAction> { private CircularProgress volumeCircle; private CircularProgress volumeCircleGlow; @@ -260,5 +262,28 @@ namespace osu.Game.Overlays.Volume { this.ScaleTo(1f, transition_length, Easing.OutExpo); } + + public bool OnPressed(GlobalAction action) + { + if (!IsHovered) + return false; + + switch (action) + { + case GlobalAction.SelectPrevious: + adjust(1, false); + return true; + + case GlobalAction.SelectNext: + adjust(-1, false); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } } } diff --git a/osu.Game/Overlays/VolumeOverlay.cs b/osu.Game/Overlays/VolumeOverlay.cs index e6204a3179..eb639431ae 100644 --- a/osu.Game/Overlays/VolumeOverlay.cs +++ b/osu.Game/Overlays/VolumeOverlay.cs @@ -19,7 +19,7 @@ using osuTK.Graphics; namespace osu.Game.Overlays { - public class VolumeOverlay : OverlayContainer + public class VolumeOverlay : VisibilityContainer { private const float offset = 10; @@ -28,10 +28,10 @@ namespace osu.Game.Overlays private VolumeMeter volumeMeterMusic; private MuteButton muteButton; - protected override bool BlockPositionalInput => false; - private readonly BindableDouble muteAdjustment = new BindableDouble(); + public Bindable<bool> IsMuted { get; } = new Bindable<bool>(); + [BackgroundDependencyLoader] private void load(AudioManager audio, OsuColour colours) { @@ -46,6 +46,13 @@ namespace osu.Game.Overlays Width = 300, Colour = ColourInfo.GradientHorizontal(Color4.Black.Opacity(0.75f), Color4.Black.Opacity(0)) }, + muteButton = new MuteButton + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Margin = new MarginPadding(10), + Current = { BindTarget = IsMuted } + }, new FillFlowContainer { Direction = FillDirection.Vertical, @@ -56,31 +63,24 @@ namespace osu.Game.Overlays Margin = new MarginPadding { Left = offset }, Children = new Drawable[] { - volumeMeterEffect = new VolumeMeter("EFFECTS", 125, colours.BlueDarker) - { - Margin = new MarginPadding { Top = 100 + MuteButton.HEIGHT } //to counter the mute button and re-center the volume meters - }, + volumeMeterEffect = new VolumeMeter("EFFECTS", 125, colours.BlueDarker), volumeMeterMaster = new VolumeMeter("MASTER", 150, colours.PinkDarker), volumeMeterMusic = new VolumeMeter("MUSIC", 125, colours.BlueDarker), - muteButton = new MuteButton - { - Margin = new MarginPadding { Top = 100 } - } } - }, + } }); volumeMeterMaster.Bindable.BindTo(audio.Volume); volumeMeterEffect.Bindable.BindTo(audio.VolumeSample); volumeMeterMusic.Bindable.BindTo(audio.VolumeTrack); - muteButton.Current.ValueChanged += muted => + IsMuted.BindValueChanged(muted => { if (muted.NewValue) audio.AddAdjustment(AdjustableProperty.Volume, muteAdjustment); else audio.RemoveAdjustment(AdjustableProperty.Volume, muteAdjustment); - }; + }); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/WaveOverlayContainer.cs b/osu.Game/Overlays/WaveOverlayContainer.cs index 5c87096dd4..d0fa9987d5 100644 --- a/osu.Game/Overlays/WaveOverlayContainer.cs +++ b/osu.Game/Overlays/WaveOverlayContainer.cs @@ -14,6 +14,8 @@ namespace osu.Game.Overlays protected override bool BlockNonPositionalInput => true; protected override Container<Drawable> Content => Waves; + public const float WIDTH_PADDING = 80; + protected override bool StartHidden => true; protected WaveOverlayContainer() diff --git a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs index c3cffa8699..74bacae9e1 100644 --- a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs +++ b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs @@ -1,6 +1,7 @@ // 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. +using Newtonsoft.Json; using osu.Game.Rulesets.Replays; using osuTK; @@ -8,17 +9,28 @@ namespace osu.Game.Replays.Legacy { public class LegacyReplayFrame : ReplayFrame { + [JsonIgnore] public Vector2 Position => new Vector2(MouseX ?? 0, MouseY ?? 0); public float? MouseX; public float? MouseY; + [JsonIgnore] public bool MouseLeft => MouseLeft1 || MouseLeft2; + + [JsonIgnore] public bool MouseRight => MouseRight1 || MouseRight2; + [JsonIgnore] public bool MouseLeft1 => ButtonState.HasFlag(ReplayButtonState.Left1); + + [JsonIgnore] public bool MouseRight1 => ButtonState.HasFlag(ReplayButtonState.Right1); + + [JsonIgnore] public bool MouseLeft2 => ButtonState.HasFlag(ReplayButtonState.Left2); + + [JsonIgnore] public bool MouseRight2 => ButtonState.HasFlag(ReplayButtonState.Right2); public ReplayButtonState ButtonState; diff --git a/osu.Game/Replays/Replay.cs b/osu.Game/Replays/Replay.cs index 31d2ed0d70..5430915394 100644 --- a/osu.Game/Replays/Replay.cs +++ b/osu.Game/Replays/Replay.cs @@ -8,6 +8,12 @@ namespace osu.Game.Replays { public class Replay { + /// <summary> + /// Whether all frames for this replay have been received. + /// If false, gameplay would be paused to wait for further data, for instance. + /// </summary> + public bool HasReceivedAllFrames = true; + public List<ReplayFrame> Frames = new List<ReplayFrame>(); } } diff --git a/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs b/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs index 179cd5e2dc..5a3ad5e786 100644 --- a/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs +++ b/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs @@ -1,11 +1,12 @@ // 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. +using System; using osu.Framework.Configuration.Tracking; namespace osu.Game.Rulesets.Configuration { - public interface IRulesetConfigManager : ITrackableConfigManager + public interface IRulesetConfigManager : ITrackableConfigManager, IDisposable { } } diff --git a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs index ed5fdf9809..0ff3455f00 100644 --- a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs +++ b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs @@ -1,12 +1,13 @@ // 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. +using System; using osu.Game.Configuration; namespace osu.Game.Rulesets.Configuration { - public abstract class RulesetConfigManager<T> : DatabasedConfigManager<T>, IRulesetConfigManager - where T : struct + public abstract class RulesetConfigManager<TLookup> : DatabasedConfigManager<TLookup>, IRulesetConfigManager + where TLookup : struct, Enum { protected RulesetConfigManager(SettingsStore settings, RulesetInfo ruleset, int? variant = null) : base(settings, ruleset, variant) diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index b4b4bb9cd1..732dc772b7 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -12,6 +12,7 @@ namespace osu.Game.Rulesets.Difficulty public Skill[] Skills; public double StarRating; + public int MaxCombo; public DifficultyAttributes() { diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index e31c963403..f15e5e1df0 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -4,8 +4,8 @@ using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; @@ -41,10 +41,10 @@ namespace osu.Game.Rulesets.Difficulty IBeatmap playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, mods); - var clock = new StopwatchClock(); - mods.OfType<IApplicableToClock>().ForEach(m => m.ApplyToClock(clock)); + var track = new TrackVirtual(10000); + mods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track)); - return calculate(playableBeatmap, mods, clock.Rate); + return calculate(playableBeatmap, mods, track.Rate); } /// <summary> @@ -69,7 +69,7 @@ namespace osu.Game.Rulesets.Difficulty if (!beatmap.HitObjects.Any()) return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); - var difficultyHitObjects = CreateDifficultyHitObjects(beatmap, clockRate).OrderBy(h => h.BaseObject.StartTime).ToList(); + var difficultyHitObjects = SortObjects(CreateDifficultyHitObjects(beatmap, clockRate)).ToList(); double sectionLength = SectionLength * clockRate; @@ -100,15 +100,24 @@ namespace osu.Game.Rulesets.Difficulty return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); } + /// <summary> + /// Sorts a given set of <see cref="DifficultyHitObject"/>s. + /// </summary> + /// <param name="input">The <see cref="DifficultyHitObject"/>s to sort.</param> + /// <returns>The sorted <see cref="DifficultyHitObject"/>s.</returns> + protected virtual IEnumerable<DifficultyHitObject> SortObjects(IEnumerable<DifficultyHitObject> input) + => input.OrderBy(h => h.BaseObject.StartTime); + /// <summary> /// Creates all <see cref="Mod"/> combinations which adjust the <see cref="Beatmap"/> difficulty. /// </summary> public Mod[] CreateDifficultyAdjustmentModCombinations() { - return createDifficultyAdjustmentModCombinations(Array.Empty<Mod>(), DifficultyAdjustmentMods).ToArray(); + return createDifficultyAdjustmentModCombinations(DifficultyAdjustmentMods, Array.Empty<Mod>()).ToArray(); - IEnumerable<Mod> createDifficultyAdjustmentModCombinations(IEnumerable<Mod> currentSet, Mod[] adjustmentSet, int currentSetCount = 0, int adjustmentSetStart = 0) + static IEnumerable<Mod> createDifficultyAdjustmentModCombinations(ReadOnlyMemory<Mod> remainingMods, IEnumerable<Mod> currentSet, int currentSetCount = 0) { + // Return the current set. switch (currentSetCount) { case 0: @@ -128,18 +137,43 @@ namespace osu.Game.Rulesets.Difficulty break; } - // Apply mods in the adjustment set recursively. Using the entire adjustment set would result in duplicate multi-mod mod - // combinations in further recursions, so a moving subset is used to eliminate this effect - for (int i = adjustmentSetStart; i < adjustmentSet.Length; i++) + // Apply the rest of the remaining mods recursively. + for (int i = 0; i < remainingMods.Length; i++) { - var adjustmentMod = adjustmentSet[i]; - if (currentSet.Any(c => c.IncompatibleMods.Any(m => m.IsInstanceOfType(adjustmentMod)))) + var (nextSet, nextCount) = flatten(remainingMods.Span[i]); + + // Check if any mods in the next set are incompatible with any of the current set. + if (currentSet.SelectMany(m => m.IncompatibleMods).Any(c => nextSet.Any(c.IsInstanceOfType))) continue; - foreach (var combo in createDifficultyAdjustmentModCombinations(currentSet.Append(adjustmentMod), adjustmentSet, currentSetCount + 1, i + 1)) + // Check if any mods in the next set are the same type as the current set. Mods of the exact same type are not incompatible with themselves. + if (currentSet.Any(c => nextSet.Any(n => c.GetType() == n.GetType()))) + continue; + + // If all's good, attach the next set to the current set and recurse further. + foreach (var combo in createDifficultyAdjustmentModCombinations(remainingMods.Slice(i + 1), currentSet.Concat(nextSet), currentSetCount + nextCount)) yield return combo; } } + + // Flattens a mod hierarchy (through MultiMod) as an IEnumerable<Mod> + static (IEnumerable<Mod> set, int count) flatten(Mod mod) + { + if (!(mod is MultiMod multi)) + return (mod.Yield(), 1); + + IEnumerable<Mod> set = Enumerable.Empty<Mod>(); + int count = 0; + + foreach (var nested in multi.Mods) + { + var (nestedSet, nestedCount) = flatten(nested); + set = set.Concat(nestedSet); + count += nestedCount; + } + + return (set, count); + } } /// <summary> diff --git a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs index 9ab81b9580..58427f6945 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs @@ -1,11 +1,11 @@ // 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. +using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Audio.Track; using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Timing; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; @@ -16,28 +16,25 @@ namespace osu.Game.Rulesets.Difficulty protected readonly DifficultyAttributes Attributes; protected readonly Ruleset Ruleset; - protected readonly IBeatmap Beatmap; protected readonly ScoreInfo Score; protected double TimeRate { get; private set; } = 1; - protected PerformanceCalculator(Ruleset ruleset, WorkingBeatmap beatmap, ScoreInfo score) + protected PerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) { Ruleset = ruleset; Score = score; - Beatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, score.Mods); - - Attributes = ruleset.CreateDifficultyCalculator(beatmap).Calculate(score.Mods); + Attributes = attributes ?? throw new ArgumentNullException(nameof(attributes)); ApplyMods(score.Mods); } protected virtual void ApplyMods(Mod[] mods) { - var clock = new StopwatchClock(); - mods.OfType<IApplicableToClock>().ForEach(m => m.ApplyToClock(clock)); - TimeRate = clock.Rate; + var track = new TrackVirtual(10000); + mods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track)); + TimeRate = track.Rate; } public abstract double Calculate(Dictionary<string, double> categoryDifficulty = null); diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs index 227f2f4018..1063a24b27 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -41,7 +41,11 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// </summary> protected readonly LimitedCapacityStack<DifficultyHitObject> Previous = new LimitedCapacityStack<DifficultyHitObject>(2); // Contained objects not used yet - private double currentStrain = 1; // We keep track of the strain level at all times throughout the beatmap. + /// <summary> + /// The current strain level. + /// </summary> + protected double CurrentStrain { get; private set; } = 1; + private double currentSectionPeak = 1; // We also keep track of the peak strain level in the current section. private readonly List<double> strainPeaks = new List<double>(); @@ -51,10 +55,10 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// </summary> public void Process(DifficultyHitObject current) { - currentStrain *= strainDecay(current.DeltaTime); - currentStrain += StrainValueOf(current) * SkillMultiplier; + CurrentStrain *= strainDecay(current.DeltaTime); + CurrentStrain += StrainValueOf(current) * SkillMultiplier; - currentSectionPeak = Math.Max(currentStrain, currentSectionPeak); + currentSectionPeak = Math.Max(CurrentStrain, currentSectionPeak); Previous.Push(current); } @@ -71,15 +75,22 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// <summary> /// Sets the initial strain level for a new section. /// </summary> - /// <param name="offset">The beginning of the new section in milliseconds.</param> - public void StartNewSectionFrom(double offset) + /// <param name="time">The beginning of the new section in milliseconds.</param> + 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 = currentStrain * strainDecay(offset - Previous[0].BaseObject.StartTime); + currentSectionPeak = GetPeakStrain(time); } + /// <summary> + /// Retrieves the peak strain at a point in time. + /// </summary> + /// <param name="time">The time to retrieve the peak strain at.</param> + /// <returns>The peak strain.</returns> + protected virtual double GetPeakStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].BaseObject.StartTime); + /// <summary> /// Returns the calculated difficulty value representing all processed <see cref="DifficultyHitObject"/>s. /// </summary> diff --git a/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityQueue.cs b/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityQueue.cs new file mode 100644 index 0000000000..bc0eb8af88 --- /dev/null +++ b/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityQueue.cs @@ -0,0 +1,123 @@ +// 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. + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace osu.Game.Rulesets.Difficulty.Utils +{ + /// <summary> + /// An indexed queue with limited capacity. + /// Respects first-in-first-out insertion order. + /// </summary> + public class LimitedCapacityQueue<T> : IEnumerable<T> + { + /// <summary> + /// The number of elements in the queue. + /// </summary> + public int Count { get; private set; } + + /// <summary> + /// Whether the queue is full (adding any new items will cause removing existing ones). + /// </summary> + public bool Full => Count == capacity; + + private readonly T[] array; + private readonly int capacity; + + // Markers tracking the queue's first and last element. + private int start, end; + + /// <summary> + /// Constructs a new <see cref="LimitedCapacityQueue{T}"/> + /// </summary> + /// <param name="capacity">The number of items the queue can hold.</param> + public LimitedCapacityQueue(int capacity) + { + if (capacity < 0) + throw new ArgumentOutOfRangeException(nameof(capacity)); + + this.capacity = capacity; + array = new T[capacity]; + Clear(); + } + + /// <summary> + /// Removes all elements from the <see cref="LimitedCapacityQueue{T}"/>. + /// </summary> + public void Clear() + { + start = 0; + end = -1; + Count = 0; + } + + /// <summary> + /// Removes an item from the front of the <see cref="LimitedCapacityQueue{T}"/>. + /// </summary> + /// <returns>The item removed from the front of the queue.</returns> + public T Dequeue() + { + if (Count == 0) + throw new InvalidOperationException("Queue is empty."); + + var result = array[start]; + start = (start + 1) % capacity; + Count--; + return result; + } + + /// <summary> + /// Adds an item to the back of the <see cref="LimitedCapacityQueue{T}"/>. + /// If the queue is holding <see cref="Count"/> elements at the point of addition, + /// the item at the front of the queue will be removed. + /// </summary> + /// <param name="item">The item to be added to the back of the queue.</param> + public void Enqueue(T item) + { + end = (end + 1) % capacity; + if (Count == capacity) + start = (start + 1) % capacity; + else + Count++; + array[end] = item; + } + + /// <summary> + /// Retrieves the item at the given index in the queue. + /// </summary> + /// <param name="index"> + /// The index of the item to retrieve. + /// The item with index 0 is at the front of the queue + /// (it was added the earliest). + /// </param> + public T this[int index] + { + get + { + if (index < 0 || index >= Count) + throw new ArgumentOutOfRangeException(nameof(index)); + + return array[(start + index) % capacity]; + } + } + + /// <summary> + /// Enumerates the queue from its start to its end. + /// </summary> + public IEnumerator<T> GetEnumerator() + { + if (Count == 0) + yield break; + + for (int i = 0; i < Count; i++) + yield return array[(start + i) % capacity]; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs b/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs index 95b7d9b19d..1fc5abce90 100644 --- a/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs +++ b/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Difficulty.Utils public LimitedCapacityStack(int capacity) { if (capacity < 0) - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(capacity)); this.capacity = capacity; array = new T[capacity]; @@ -44,7 +44,7 @@ namespace osu.Game.Rulesets.Difficulty.Utils get { if (i < 0 || i > Count - 1) - throw new IndexOutOfRangeException(); + throw new ArgumentOutOfRangeException(nameof(i)); i += marker; if (i > capacity - 1) @@ -81,8 +81,10 @@ namespace osu.Game.Rulesets.Difficulty.Utils 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/Edit/DrawableEditRuleset.cs b/osu.Game/Rulesets/Edit/DrawableEditRuleset.cs deleted file mode 100644 index 2200caeb20..0000000000 --- a/osu.Game/Rulesets/Edit/DrawableEditRuleset.cs +++ /dev/null @@ -1,110 +0,0 @@ -// 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. - -using System.Linq; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.UI; - -namespace osu.Game.Rulesets.Edit -{ - public abstract class DrawableEditRuleset : CompositeDrawable - { - /// <summary> - /// The <see cref="Playfield"/> contained by this <see cref="DrawableEditRuleset"/>. - /// </summary> - public abstract Playfield Playfield { get; } - - public abstract PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer(); - - internal DrawableEditRuleset() - { - RelativeSizeAxes = Axes.Both; - } - - /// <summary> - /// Adds a <see cref="HitObject"/> to the <see cref="Beatmap"/> and displays a visual representation of it. - /// </summary> - /// <param name="hitObject">The <see cref="HitObject"/> to add.</param> - /// <returns>The visual representation of <paramref name="hitObject"/>.</returns> - internal abstract DrawableHitObject Add(HitObject hitObject); - - /// <summary> - /// Removes a <see cref="HitObject"/> from the <see cref="Beatmap"/> and the display. - /// </summary> - /// <param name="hitObject">The <see cref="HitObject"/> to remove.</param> - /// <returns>The visual representation of the removed <paramref name="hitObject"/>.</returns> - internal abstract DrawableHitObject Remove(HitObject hitObject); - } - - public class DrawableEditRuleset<TObject> : DrawableEditRuleset - where TObject : HitObject - { - public override Playfield Playfield => drawableRuleset.Playfield; - - public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => drawableRuleset.CreatePlayfieldAdjustmentContainer(); - - private Ruleset ruleset => drawableRuleset.Ruleset; - private Beatmap<TObject> beatmap => drawableRuleset.Beatmap; - - private readonly DrawableRuleset<TObject> drawableRuleset; - - public DrawableEditRuleset(DrawableRuleset<TObject> drawableRuleset) - { - this.drawableRuleset = drawableRuleset; - - InternalChild = drawableRuleset; - - Playfield.DisplayJudgements.Value = false; - } - - internal override DrawableHitObject Add(HitObject hitObject) - { - var tObject = (TObject)hitObject; - - // Add to beatmap, preserving sorting order - var insertionIndex = beatmap.HitObjects.FindLastIndex(h => h.StartTime <= hitObject.StartTime); - beatmap.HitObjects.Insert(insertionIndex + 1, tObject); - - // Process object - var processor = ruleset.CreateBeatmapProcessor(beatmap); - - processor?.PreProcess(); - tObject.ApplyDefaults(beatmap.ControlPointInfo, beatmap.BeatmapInfo.BaseDifficulty); - processor?.PostProcess(); - - // Add visual representation - var drawableObject = drawableRuleset.CreateDrawableRepresentation(tObject); - - drawableRuleset.Playfield.Add(drawableObject); - drawableRuleset.Playfield.PostProcess(); - - return drawableObject; - } - - internal override DrawableHitObject Remove(HitObject hitObject) - { - var tObject = (TObject)hitObject; - - // Remove from beatmap - beatmap.HitObjects.Remove(tObject); - - // Process the beatmap - var processor = ruleset.CreateBeatmapProcessor(beatmap); - - processor?.PreProcess(); - processor?.PostProcess(); - - // Remove visual representation - var drawableObject = Playfield.AllHitObjects.Single(d => d.HitObject == hitObject); - - drawableRuleset.Playfield.Remove(drawableObject); - drawableRuleset.Playfield.PostProcess(); - - return drawableObject; - } - } -} diff --git a/osu.Game/Rulesets/Edit/DrawableEditRulesetWrapper.cs b/osu.Game/Rulesets/Edit/DrawableEditRulesetWrapper.cs new file mode 100644 index 0000000000..8ed7885101 --- /dev/null +++ b/osu.Game/Rulesets/Edit/DrawableEditRulesetWrapper.cs @@ -0,0 +1,99 @@ +// 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. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit; + +namespace osu.Game.Rulesets.Edit +{ + /// <summary> + /// A wrapper for a <see cref="DrawableRuleset{TObject}"/>. Handles adding visual representations of <see cref="HitObject"/>s to the underlying <see cref="DrawableRuleset{TObject}"/>. + /// </summary> + internal class DrawableEditRulesetWrapper<TObject> : CompositeDrawable + where TObject : HitObject + { + public Playfield Playfield => drawableRuleset.Playfield; + + private readonly DrawableRuleset<TObject> drawableRuleset; + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + public DrawableEditRulesetWrapper(DrawableRuleset<TObject> drawableRuleset) + { + this.drawableRuleset = drawableRuleset; + + RelativeSizeAxes = Axes.Both; + + InternalChild = drawableRuleset; + } + + [BackgroundDependencyLoader] + private void load() + { + drawableRuleset.FrameStablePlayback = false; + Playfield.DisplayJudgements.Value = false; + } + + [Resolved(canBeNull: true)] + private IEditorChangeHandler changeHandler { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.HitObjectAdded += addHitObject; + beatmap.HitObjectRemoved += removeHitObject; + + if (changeHandler != null) + { + // for now only regenerate replay on a finalised state change, not HitObjectUpdated. + changeHandler.OnStateChange += updateReplay; + } + else + { + beatmap.HitObjectUpdated += _ => updateReplay(); + } + } + + private void updateReplay() => drawableRuleset.RegenerateAutoplay(); + + private void addHitObject(HitObject hitObject) + { + var drawableObject = drawableRuleset.CreateDrawableRepresentation((TObject)hitObject); + + drawableRuleset.Playfield.Add(drawableObject); + drawableRuleset.Playfield.PostProcess(); + } + + private void removeHitObject(HitObject hitObject) + { + var drawableObject = Playfield.AllHitObjects.Single(d => d.HitObject == hitObject); + + drawableRuleset.Playfield.Remove(drawableObject); + drawableRuleset.Playfield.PostProcess(); + } + + public override bool PropagatePositionalInputSubTree => false; + + public override bool PropagateNonPositionalInputSubTree => false; + + public PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => drawableRuleset.CreatePlayfieldAdjustmentContainer(); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (beatmap != null) + { + beatmap.HitObjectAdded -= addHitObject; + beatmap.HitObjectRemoved -= removeHitObject; + } + } + } +} diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index 38ec09535d..c9dd061b48 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -3,119 +3,148 @@ using System; 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.Graphics.Containers; using osu.Framework.Input; +using osu.Framework.Input.Events; using osu.Framework.Logging; -using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.RadioButtons; +using osu.Game.Screens.Edit.Components.TernaryButtons; +using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Edit { - public abstract class HitObjectComposer : CompositeDrawable + /// <summary> + /// Top level container for editor compose mode. + /// Responsible for providing snapping and generally gluing components together. + /// </summary> + /// <typeparam name="TObject">The base type of supported objects.</typeparam> + [Cached(Type = typeof(IPlacementHandler))] + public abstract class HitObjectComposer<TObject> : HitObjectComposer, IPlacementHandler + where TObject : HitObject { - public IEnumerable<DrawableHitObject> HitObjects => DrawableRuleset.Playfield.AllHitObjects; + protected IRulesetConfigManager Config { get; private set; } protected readonly Ruleset Ruleset; - protected readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>(); + [Resolved] + protected EditorClock EditorClock { get; private set; } - protected IRulesetConfigManager Config { get; private set; } + [Resolved] + protected EditorBeatmap EditorBeatmap { get; private set; } - private readonly List<Container> layerContainers = new List<Container>(); + [Resolved] + protected IBeatSnapProvider BeatSnapProvider { get; private set; } - protected DrawableEditRuleset DrawableRuleset { get; private set; } + protected ComposeBlueprintContainer BlueprintContainer { get; private set; } - private BlueprintContainer blueprintContainer; + private DrawableEditRulesetWrapper<TObject> drawableRulesetWrapper; + + protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both }; private InputManager inputManager; - internal HitObjectComposer(Ruleset ruleset) + private RadioButtonCollection toolboxCollection; + + private FillFlowContainer togglesCollection; + + protected HitObjectComposer(Ruleset ruleset) { Ruleset = ruleset; - - RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] - private void load(IBindable<WorkingBeatmap> beatmap, IFrameBasedClock framedClock) + private void load() { - Beatmap.BindTo(beatmap); + Config = Dependencies.Get<RulesetConfigCache>().GetConfigFor(Ruleset); try { - DrawableRuleset = CreateDrawableRuleset(); - DrawableRuleset.Clock = framedClock; + drawableRulesetWrapper = new DrawableEditRulesetWrapper<TObject>(CreateDrawableRuleset(Ruleset, EditorBeatmap.PlayableBeatmap, new[] { Ruleset.GetAutoplayMod() })) + { + Clock = EditorClock, + ProcessCustomClock = false + }; } catch (Exception e) { - Logger.Error(e, "Could not load beatmap sucessfully!"); + Logger.Error(e, "Could not load beatmap successfully!"); return; } - var layerBelowRuleset = DrawableRuleset.CreatePlayfieldAdjustmentContainer(); - layerBelowRuleset.Child = new EditorPlayfieldBorder { RelativeSizeAxes = Axes.Both }; + const float toolbar_width = 200; - var layerAboveRuleset = DrawableRuleset.CreatePlayfieldAdjustmentContainer(); - layerAboveRuleset.Child = blueprintContainer = new BlueprintContainer(); - - layerContainers.Add(layerBelowRuleset); - layerContainers.Add(layerAboveRuleset); - - RadioButtonCollection toolboxCollection; - InternalChild = new GridContainer + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Content = new[] + new Container { - new Drawable[] + Name = "Content", + Padding = new MarginPadding { Left = toolbar_width }, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - new FillFlowContainer - { - Name = "Sidebar", - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 10 }, - Children = new Drawable[] - { - new ToolboxGroup { Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X } } - } - }, - new Container - { - Name = "Content", - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - layerBelowRuleset, - DrawableRuleset, - layerAboveRuleset - } - } - }, + // layers below playfield + drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer().WithChild(LayerBelowRuleset), + drawableRulesetWrapper, + // layers above playfield + drawableRulesetWrapper.CreatePlayfieldAdjustmentContainer() + .WithChild(BlueprintContainer = CreateBlueprintContainer(HitObjects)) + } }, - ColumnDimensions = new[] + new FillFlowContainer { - new Dimension(GridSizeMode.Absolute, 200), - } + Name = "Sidebar", + RelativeSizeAxes = Axes.Y, + Width = toolbar_width, + Padding = new MarginPadding { Right = 10 }, + Spacing = new Vector2(10), + Children = new Drawable[] + { + new ToolboxGroup("toolbox (1-9)") + { + Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X } + }, + new ToolboxGroup("toggles (Q~P)") + { + Child = togglesCollection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + }, + } + } + }, }; - toolboxCollection.Items = - CompositionTools.Select(t => new RadioButton(t.Name, () => blueprintContainer.CurrentTool = t)) - .Prepend(new RadioButton("Select", () => blueprintContainer.CurrentTool = null)) - .ToList(); + toolboxCollection.Items = CompositionTools + .Prepend(new SelectTool()) + .Select(t => new RadioButton(t.Name, () => toolSelected(t), t.CreateIcon)) + .ToList(); - toolboxCollection.Items[0].Select(); + TernaryStates = CreateTernaryButtons().ToArray(); + togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b))); + + setSelectTool(); + + EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; } protected override void LoadComplete() @@ -125,69 +154,305 @@ namespace osu.Game.Rulesets.Edit inputManager = GetContainingInputManager(); } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + public override Playfield Playfield => drawableRulesetWrapper.Playfield; + + public override IEnumerable<DrawableHitObject> HitObjects => drawableRulesetWrapper.Playfield.AllHitObjects; + + public override bool CursorInPlacementArea => drawableRulesetWrapper.Playfield.ReceivePositionalInputAt(inputManager.CurrentState.Mouse.Position); + + /// <summary> + /// Defines all available composition tools, listed on the left side of the editor screen as button controls. + /// This should usually define one tool for each <see cref="HitObject"/> type used in the target ruleset. + /// </summary> + /// <remarks> + /// A "select" tool is automatically added as the first tool. + /// </remarks> + protected abstract IReadOnlyList<HitObjectCompositionTool> CompositionTools { get; } + + /// <summary> + /// A collection of states which will be displayed to the user in the toolbox. + /// </summary> + public TernaryButton[] TernaryStates { get; private set; } + + /// <summary> + /// Create all ternary states required to be displayed to the user. + /// </summary> + protected virtual IEnumerable<TernaryButton> CreateTernaryButtons() => BlueprintContainer.TernaryStates; + + /// <summary> + /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. + /// </summary> + /// <param name="hitObjects">A live collection of all <see cref="DrawableHitObject"/>s in the editor beatmap.</param> + protected virtual ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable<DrawableHitObject> hitObjects) + => new ComposeBlueprintContainer(hitObjects); + + /// <summary> + /// Construct a drawable ruleset for the provided ruleset. + /// </summary> + /// <remarks> + /// Can be overridden to add editor-specific logical changes to a <see cref="Ruleset"/>'s standard <see cref="DrawableRuleset{TObject}"/>. + /// For example, hit animations or judgement logic may be changed to give a better editor user experience. + /// </remarks> + /// <param name="ruleset">The ruleset used to construct its drawable counterpart.</param> + /// <param name="beatmap">The loaded beatmap.</param> + /// <param name="mods">The mods to be applied.</param> + /// <returns>An editor-relevant <see cref="DrawableRuleset{TObject}"/>.</returns> + protected virtual DrawableRuleset<TObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null) + => (DrawableRuleset<TObject>)ruleset.CreateDrawableRulesetWith(beatmap, mods); + + #region Tool selection logic + + protected override bool OnKeyDown(KeyDownEvent e) { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + if (e.ControlPressed || e.AltPressed || e.SuperPressed) + return false; - dependencies.CacheAs(this); - Config = dependencies.Get<RulesetConfigCache>().GetConfigFor(Ruleset); - - return dependencies; - } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - layerContainers.ForEach(l => + if (checkLeftToggleFromKey(e.Key, out var leftIndex)) { - l.Anchor = DrawableRuleset.Playfield.Anchor; - l.Origin = DrawableRuleset.Playfield.Origin; - l.Position = DrawableRuleset.Playfield.Position; - l.Size = DrawableRuleset.Playfield.Size; - }); + var item = toolboxCollection.Items.ElementAtOrDefault(leftIndex); + + if (item != null) + { + item.Select(); + return true; + } + } + + if (checkRightToggleFromKey(e.Key, out var rightIndex)) + { + var item = togglesCollection.ElementAtOrDefault(rightIndex); + + if (item is DrawableTernaryButton button) + { + button.Button.Toggle(); + return true; + } + } + + return base.OnKeyDown(e); } + private bool checkLeftToggleFromKey(Key key, out int index) + { + if (key < Key.Number1 || key > Key.Number9) + { + index = -1; + return false; + } + + index = key - Key.Number1; + return true; + } + + private bool checkRightToggleFromKey(Key key, out int index) + { + switch (key) + { + case Key.Q: + index = 0; + break; + + case Key.W: + index = 1; + break; + + case Key.E: + index = 2; + break; + + case Key.R: + index = 3; + break; + + case Key.T: + index = 4; + break; + + case Key.Y: + index = 5; + break; + + case Key.U: + index = 6; + break; + + case Key.I: + index = 7; + break; + + case Key.O: + index = 8; + break; + + case Key.P: + index = 9; + break; + + default: + index = -1; + break; + } + + return index >= 0; + } + + private void selectionChanged(object sender, NotifyCollectionChangedEventArgs changedArgs) + { + if (EditorBeatmap.SelectedHitObjects.Any()) + { + // ensure in selection mode if a selection is made. + setSelectTool(); + } + } + + private void setSelectTool() => toolboxCollection.Items.First().Select(); + + private void toolSelected(HitObjectCompositionTool tool) + { + BlueprintContainer.CurrentTool = tool; + + if (!(tool is SelectTool)) + EditorBeatmap.SelectedHitObjects.Clear(); + } + + #endregion + + #region IPlacementHandler + + public void BeginPlacement(HitObject hitObject) + { + EditorBeatmap.PlacementObject.Value = hitObject; + } + + public void EndPlacement(HitObject hitObject, bool commit) + { + EditorBeatmap.PlacementObject.Value = null; + + if (commit) + { + EditorBeatmap.Add(hitObject); + + if (EditorClock.CurrentTime < hitObject.StartTime) + EditorClock.SeekTo(hitObject.StartTime); + } + } + + public void Delete(HitObject hitObject) => EditorBeatmap.Remove(hitObject); + + #endregion + + #region IPositionSnapProvider + + /// <summary> + /// Retrieve the relevant <see cref="Playfield"/> at a specified screen-space position. + /// In cases where a ruleset doesn't require custom logic (due to nested playfields, for example) + /// this will return the ruleset's main playfield. + /// </summary> + /// <param name="screenSpacePosition">The screen-space position to query.</param> + /// <returns>The most relevant <see cref="Playfield"/>.</returns> + protected virtual Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => drawableRulesetWrapper.Playfield; + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + { + var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); + double? targetTime = null; + + if (playfield is ScrollingPlayfield scrollingPlayfield) + { + targetTime = scrollingPlayfield.TimeAtScreenSpacePosition(screenSpacePosition); + + // apply beat snapping + targetTime = BeatSnapProvider.SnapTime(targetTime.Value); + + // convert back to screen space + screenSpacePosition = scrollingPlayfield.ScreenSpacePositionAtTime(targetTime.Value); + } + + return new SnapResult(screenSpacePosition, targetTime, playfield); + } + + public override float GetBeatSnapDistanceAt(double referenceTime) + { + DifficultyControlPoint difficultyPoint = EditorBeatmap.ControlPointInfo.DifficultyPointAt(referenceTime); + return (float)(100 * EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier / BeatSnapProvider.BeatDivisor); + } + + public override float DurationToDistance(double referenceTime, double duration) + { + double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceTime); + return (float)(duration / beatLength * GetBeatSnapDistanceAt(referenceTime)); + } + + public override double DistanceToDuration(double referenceTime, float distance) + { + double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceTime); + return distance / GetBeatSnapDistanceAt(referenceTime) * beatLength; + } + + public override double GetSnappedDurationFromDistance(double referenceTime, float distance) + => BeatSnapProvider.SnapTime(referenceTime + DistanceToDuration(referenceTime, distance), referenceTime) - referenceTime; + + public override float GetSnappedDistanceFromDistance(double referenceTime, float distance) + { + double actualDuration = referenceTime + DistanceToDuration(referenceTime, distance); + + double snappedEndTime = BeatSnapProvider.SnapTime(actualDuration, referenceTime); + + double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceTime); + + // we don't want to exceed the actual duration and snap to a point in the future. + // as we are snapping to beat length via SnapTime (which will round-to-nearest), check for snapping in the forward direction and reverse it. + if (snappedEndTime > actualDuration + 1) + snappedEndTime -= beatLength; + + return DurationToDistance(referenceTime, snappedEndTime - referenceTime); + } + + #endregion + } + + /// <summary> + /// A non-generic definition of a HitObject composer class. + /// Generally used to access certain methods without requiring a generic type for <see cref="HitObjectComposer{T}" />. + /// </summary> + [Cached(typeof(HitObjectComposer))] + [Cached(typeof(IPositionSnapProvider))] + public abstract class HitObjectComposer : CompositeDrawable, IPositionSnapProvider + { + protected HitObjectComposer() + { + RelativeSizeAxes = Axes.Both; + } + + /// <summary> + /// The target ruleset's playfield. + /// </summary> + public abstract Playfield Playfield { get; } + + /// <summary> + /// All <see cref="DrawableHitObject"/>s in currently loaded beatmap. + /// </summary> + public abstract IEnumerable<DrawableHitObject> HitObjects { get; } + /// <summary> /// Whether the user's cursor is currently in an area of the <see cref="HitObjectComposer"/> that is valid for placement. /// </summary> - public virtual bool CursorInPlacementArea => DrawableRuleset.Playfield.ReceivePositionalInputAt(inputManager.CurrentState.Mouse.Position); + public abstract bool CursorInPlacementArea { get; } - /// <summary> - /// Adds a <see cref="HitObject"/> to the <see cref="Beatmaps.Beatmap"/> and visualises it. - /// </summary> - /// <param name="hitObject">The <see cref="HitObject"/> to add.</param> - public void Add(HitObject hitObject) => blueprintContainer.AddBlueprintFor(DrawableRuleset.Add(hitObject)); + #region IPositionSnapProvider - public void Remove(HitObject hitObject) => blueprintContainer.RemoveBlueprintFor(DrawableRuleset.Remove(hitObject)); + public abstract SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition); - internal abstract DrawableEditRuleset CreateDrawableRuleset(); + public abstract float GetBeatSnapDistanceAt(double referenceTime); - protected abstract IReadOnlyList<HitObjectCompositionTool> CompositionTools { get; } + public abstract float DurationToDistance(double referenceTime, double duration); - /// <summary> - /// Creates a <see cref="SelectionBlueprint"/> for a specific <see cref="DrawableHitObject"/>. - /// </summary> - /// <param name="hitObject">The <see cref="DrawableHitObject"/> to create the overlay for.</param> - public virtual SelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => null; + public abstract double DistanceToDuration(double referenceTime, float distance); - /// <summary> - /// Creates a <see cref="SelectionHandler"/> which outlines <see cref="DrawableHitObject"/>s and handles movement of selections. - /// </summary> - public virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler(); - } + public abstract double GetSnappedDurationFromDistance(double referenceTime, float distance); - public abstract class HitObjectComposer<TObject> : HitObjectComposer - where TObject : HitObject - { - protected HitObjectComposer(Ruleset ruleset) - : base(ruleset) - { - } + public abstract float GetSnappedDistanceFromDistance(double referenceTime, float distance); - internal override DrawableEditRuleset CreateDrawableRuleset() - => new DrawableEditRuleset<TObject>(CreateDrawableRuleset(Ruleset, Beatmap.Value, Array.Empty<Mod>())); - - protected abstract DrawableRuleset<TObject> CreateDrawableRuleset(Ruleset ruleset, WorkingBeatmap beatmap, IReadOnlyList<Mod> mods); + #endregion } } diff --git a/osu.Game/Rulesets/Edit/IBeatSnapProvider.cs b/osu.Game/Rulesets/Edit/IBeatSnapProvider.cs new file mode 100644 index 0000000000..616f854cd7 --- /dev/null +++ b/osu.Game/Rulesets/Edit/IBeatSnapProvider.cs @@ -0,0 +1,28 @@ +// 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. + +namespace osu.Game.Rulesets.Edit +{ + public interface IBeatSnapProvider + { + /// <summary> + /// Snaps a duration to the closest beat of a timing point applicable at the reference time. + /// </summary> + /// <param name="time">The time to snap.</param> + /// <param name="referenceTime">An optional reference point to use for timing point lookup.</param> + /// <returns>A value that represents <paramref name="time"/> snapped to the closest beat of the timing point.</returns> + double SnapTime(double time, double? referenceTime = null); + + /// <summary> + /// Get the most appropriate beat length at a given time. + /// </summary> + /// <param name="referenceTime">A reference time used for lookup.</param> + /// <returns>The most appropriate beat length.</returns> + double GetBeatLengthAtTime(double referenceTime); + + /// <summary> + /// Returns the current beat divisor. + /// </summary> + int BeatDivisor { get; } + } +} diff --git a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs new file mode 100644 index 0000000000..cce631464f --- /dev/null +++ b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs @@ -0,0 +1,57 @@ +// 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. + +using osuTK; + +namespace osu.Game.Rulesets.Edit +{ + public interface IPositionSnapProvider + { + /// <summary> + /// Given a position, find a valid time snap. + /// </summary> + /// <param name="screenSpacePosition">The screen-space position to be snapped.</param> + /// <returns>The time and position post-snapping.</returns> + SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition); + + /// <summary> + /// Retrieves the distance between two points within a timing point that are one beat length apart. + /// </summary> + /// <param name="referenceTime">The time of the timing point.</param> + /// <returns>The distance between two points residing in the timing point that are one beat length apart.</returns> + float GetBeatSnapDistanceAt(double referenceTime); + + /// <summary> + /// Converts a duration to a distance. + /// </summary> + /// <param name="referenceTime">The time of the timing point which <paramref name="duration"/> resides in.</param> + /// <param name="duration">The duration to convert.</param> + /// <returns>A value that represents <paramref name="duration"/> as a distance in the timing point.</returns> + float DurationToDistance(double referenceTime, double duration); + + /// <summary> + /// Converts a distance to a duration. + /// </summary> + /// <param name="referenceTime">The time of the timing point which <paramref name="distance"/> resides in.</param> + /// <param name="distance">The distance to convert.</param> + /// <returns>A value that represents <paramref name="distance"/> as a duration in the timing point.</returns> + double DistanceToDuration(double referenceTime, float distance); + + /// <summary> + /// Converts a distance to a snapped duration. + /// </summary> + /// <param name="referenceTime">The time of the timing point which <paramref name="distance"/> resides in.</param> + /// <param name="distance">The distance to convert.</param> + /// <returns>A value that represents <paramref name="distance"/> as a duration snapped to the closest beat of the timing point.</returns> + double GetSnappedDurationFromDistance(double referenceTime, float distance); + + /// <summary> + /// Converts an unsnapped distance to a snapped distance. + /// The returned distance will always be floored (as to never exceed the provided <paramref name="distance"/>. + /// </summary> + /// <param name="referenceTime">The time of the timing point which <paramref name="distance"/> resides in.</param> + /// <param name="distance">The distance to convert.</param> + /// <returns>A value that represents <paramref name="distance"/> snapped to the closest beat of the timing point.</returns> + float GetSnappedDistanceFromDistance(double referenceTime, float distance); + } +} diff --git a/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs b/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs new file mode 100644 index 0000000000..75200e3027 --- /dev/null +++ b/osu.Game/Rulesets/Edit/OverlaySelectionBlueprint.cs @@ -0,0 +1,39 @@ +// 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. + +using osu.Framework.Graphics.Primitives; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK; + +namespace osu.Game.Rulesets.Edit +{ + public abstract class OverlaySelectionBlueprint : SelectionBlueprint + { + /// <summary> + /// The <see cref="DrawableHitObject"/> which this <see cref="OverlaySelectionBlueprint"/> applies to. + /// </summary> + public readonly DrawableHitObject DrawableObject; + + /// <summary> + /// Whether the blueprint should be shown even when the <see cref="DrawableObject"/> is not alive. + /// </summary> + protected virtual bool AlwaysShowWhenSelected => false; + + protected override bool ShouldBeAlive => (DrawableObject.IsAlive && DrawableObject.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected); + + protected OverlaySelectionBlueprint(DrawableHitObject drawableObject) + : base(drawableObject.HitObject) + { + DrawableObject = drawableObject; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => DrawableObject.ReceivePositionalInputAt(screenSpacePos); + + 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; + } +} diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index 757c269358..d986b71380 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -1,17 +1,16 @@ // 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. -using System; -using osu.Framework; +using System.Threading; 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.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; using osuTK; @@ -20,27 +19,25 @@ namespace osu.Game.Rulesets.Edit /// <summary> /// A blueprint which governs the creation of a new <see cref="HitObject"/> to actualisation. /// </summary> - public abstract class PlacementBlueprint : CompositeDrawable, IStateful<PlacementState> + public abstract class PlacementBlueprint : CompositeDrawable { /// <summary> - /// Invoked when <see cref="State"/> has changed. + /// Whether the <see cref="HitObject"/> is currently mid-placement, but has not necessarily finished being placed. /// </summary> - public event Action<PlacementState> StateChanged; - - /// <summary> - /// Whether the <see cref="HitObject"/> is currently being placed, but has not necessarily finished being placed. - /// </summary> - public bool PlacementBegun { get; private set; } + public bool PlacementActive { get; private set; } /// <summary> /// The <see cref="HitObject"/> that is being placed. /// </summary> - protected readonly HitObject HitObject; + public readonly HitObject HitObject; - protected IClock EditorClock { get; private set; } + [Resolved(canBeNull: true)] + protected EditorClock EditorClock { get; private set; } private readonly IBindable<WorkingBeatmap> beatmap = new Bindable<WorkingBeatmap>(); + private Bindable<double> startTimeBindable; + [Resolved] private IPlacementHandler placementHandler { get; set; } @@ -53,63 +50,52 @@ namespace osu.Game.Rulesets.Edit // This is required to allow the blueprint's position to be updated via OnMouseMove/Handle // on the same frame it is made visible via a PlacementState change. AlwaysPresent = true; - - Alpha = 0; } [BackgroundDependencyLoader] - private void load(IBindable<WorkingBeatmap> beatmap, IAdjustableClock clock) + private void load(IBindable<WorkingBeatmap> beatmap) { this.beatmap.BindTo(beatmap); - EditorClock = clock; - - ApplyDefaultsToHitObject(); - } - - private PlacementState state; - - public PlacementState State - { - get => state; - set - { - if (state == value) - return; - - state = value; - - if (state == PlacementState.Shown) - Show(); - else - Hide(); - - StateChanged?.Invoke(value); - } + startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy(); + startTimeBindable.BindValueChanged(_ => ApplyDefaultsToHitObject(), true); } /// <summary> /// Signals that the placement of <see cref="HitObject"/> has started. /// </summary> - protected void BeginPlacement() + /// <param name="commitStart">Whether this call is committing a value for HitObject.StartTime and continuing with further adjustments.</param> + protected void BeginPlacement(bool commitStart = false) { placementHandler.BeginPlacement(HitObject); - PlacementBegun = true; + PlacementActive |= commitStart; } /// <summary> /// Signals that the placement of <see cref="HitObject"/> has finished. - /// This will destroy this <see cref="PlacementBlueprint"/>, and add the <see cref="HitObject"/> to the <see cref="Beatmap"/>. + /// This will destroy this <see cref="PlacementBlueprint"/>, and add the HitObject.StartTime to the <see cref="Beatmap"/>. /// </summary> - protected void EndPlacement() + /// <param name="commit">Whether the object should be committed.</param> + public void EndPlacement(bool commit) { - if (!PlacementBegun) + if (!PlacementActive) BeginPlacement(); - placementHandler.EndPlacement(HitObject); + placementHandler.EndPlacement(HitObject, commit); + PlacementActive = false; } /// <summary> - /// Invokes <see cref="Objects.HitObject.ApplyDefaults(ControlPointInfo,BeatmapDifficulty)"/>, + /// Updates the position of this <see cref="PlacementBlueprint"/> to a new screen-space position. + /// </summary> + /// <param name="result">The snap result information.</param> + public virtual void UpdatePosition(SnapResult result) + { + if (!PlacementActive) + HitObject.StartTime = result.Time ?? EditorClock?.CurrentTime ?? Time.Current; + } + + /// <summary> + /// Invokes <see cref="Objects.HitObject.ApplyDefaults(ControlPointInfo,BeatmapDifficulty, CancellationToken)"/>, /// refreshing <see cref="Objects.HitObject.NestedHitObjects"/> and parameters for the <see cref="HitObject"/>. /// </summary> protected void ApplyDefaultsToHitObject() => HitObject.ApplyDefaults(beatmap.Value.Beatmap.ControlPointInfo, beatmap.Value.Beatmap.BeatmapInfo.BaseDifficulty); @@ -125,7 +111,10 @@ namespace osu.Game.Rulesets.Edit case ScrollEvent _: return false; - case MouseEvent _: + case DoubleClickEvent _: + return false; + + case MouseButtonEvent _: return true; default: @@ -133,10 +122,4 @@ namespace osu.Game.Rulesets.Edit } } } - - public enum PlacementState - { - Hidden, - Shown, - } } diff --git a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs index 0f77b8d584..99cdca045b 100644 --- a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs @@ -1,14 +1,15 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. 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.Input.Events; -using osu.Framework.Input.States; +using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osuTK; @@ -19,6 +20,8 @@ namespace osu.Game.Rulesets.Edit /// </summary> public abstract class SelectionBlueprint : CompositeDrawable, IStateful<SelectionState> { + public readonly HitObject HitObject; + /// <summary> /// Invoked when this <see cref="SelectionBlueprint"/> has been selected. /// </summary> @@ -29,34 +32,24 @@ namespace osu.Game.Rulesets.Edit /// </summary> public event Action<SelectionBlueprint> Deselected; - /// <summary> - /// Invoked when this <see cref="SelectionBlueprint"/> has requested selection. - /// Will fire even if already selected. Does not actually perform selection. - /// </summary> - public event Action<SelectionBlueprint, InputState> SelectionRequested; - - /// <summary> - /// Invoked when this <see cref="SelectionBlueprint"/> has requested drag. - /// </summary> - public event Action<SelectionBlueprint, DragEvent> DragRequested; - - /// <summary> - /// The <see cref="DrawableHitObject"/> which this <see cref="SelectionBlueprint"/> applies to. - /// </summary> - public readonly DrawableHitObject HitObject; - - protected override bool ShouldBeAlive => (HitObject.IsAlive && HitObject.IsPresent) || State == SelectionState.Selected; public override bool HandlePositionalInput => ShouldBeAlive; public override bool RemoveWhenNotAlive => false; - protected SelectionBlueprint(DrawableHitObject hitObject) + [Resolved(CanBeNull = true)] + private HitObjectComposer composer { get; set; } + + protected SelectionBlueprint(HitObject hitObject) { HitObject = hitObject; RelativeSizeAxes = Axes.Both; - AlwaysPresent = true; - Alpha = 0; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); } private SelectionState state; @@ -73,80 +66,88 @@ namespace osu.Game.Rulesets.Edit state = value; - switch (state) - { - case SelectionState.Selected: - Show(); - Selected?.Invoke(this); - break; - - case SelectionState.NotSelected: - Hide(); - Deselected?.Invoke(this); - break; - } + if (IsLoaded) + updateState(); StateChanged?.Invoke(state); } } + private void updateState() + { + switch (state) + { + case SelectionState.Selected: + OnSelected(); + Selected?.Invoke(this); + break; + + case SelectionState.NotSelected: + OnDeselected(); + Deselected?.Invoke(this); + break; + } + } + + protected virtual void OnDeselected() + { + // selection blueprints are AlwaysPresent while the related DrawableHitObject 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(); + + Hide(); + } + + protected virtual void OnSelected() + { + foreach (var d in InternalChildren) + d.Show(); + + Show(); + } + + // When not selected, input is only required for the blueprint itself to receive IsHovering + protected override bool ShouldBeConsideredForInput(Drawable child) => State == SelectionState.Selected; + /// <summary> - /// Selects this <see cref="SelectionBlueprint"/>, causing it to become visible. + /// Selects this <see cref="OverlaySelectionBlueprint"/>, causing it to become visible. /// </summary> public void Select() => State = SelectionState.Selected; /// <summary> - /// Deselects this <see cref="SelectionBlueprint"/>, causing it to become invisible. + /// Deselects this <see cref="OverlaySelectionBlueprint"/>, causing it to become invisible. /// </summary> public void Deselect() => State = SelectionState.NotSelected; + /// <summary> + /// Toggles the selection state of this <see cref="OverlaySelectionBlueprint"/>. + /// </summary> + public void ToggleSelection() => State = IsSelected ? SelectionState.NotSelected : SelectionState.Selected; + public bool IsSelected => State == SelectionState.Selected; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => HitObject.ReceivePositionalInputAt(screenSpacePos); - - private bool selectionRequested; - - protected override bool OnMouseDown(MouseDownEvent e) - { - selectionRequested = false; - - if (State == SelectionState.NotSelected) - { - SelectionRequested?.Invoke(this, e.CurrentState); - selectionRequested = true; - } - - return IsSelected; - } - - protected override bool OnClick(ClickEvent e) - { - if (State == SelectionState.Selected && !selectionRequested) - { - selectionRequested = true; - SelectionRequested?.Invoke(this, e.CurrentState); - return true; - } - - return base.OnClick(e); - } - - protected override bool OnDragStart(DragStartEvent e) => true; - - protected override bool OnDrag(DragEvent e) - { - DragRequested?.Invoke(this, e); - return true; - } + /// <summary> + /// The <see cref="MenuItem"/>s to be displayed in the context menu for this <see cref="OverlaySelectionBlueprint"/>. + /// </summary> + public virtual MenuItem[] ContextMenuItems => Array.Empty<MenuItem>(); /// <summary> - /// The screen-space point that causes this <see cref="SelectionBlueprint"/> to be selected. + /// The screen-space point that causes this <see cref="OverlaySelectionBlueprint"/> to be selected. /// </summary> - public virtual Vector2 SelectionPoint => HitObject.ScreenSpaceDrawQuad.Centre; + public virtual Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.Centre; /// <summary> - /// The screen-space quad that outlines this <see cref="SelectionBlueprint"/> for selections. + /// The screen-space quad that outlines this <see cref="OverlaySelectionBlueprint"/> for selections. /// </summary> - public virtual Quad SelectionQuad => HitObject.ScreenSpaceDrawQuad; + public virtual Quad SelectionQuad => ScreenSpaceDrawQuad; + + public virtual Vector2 GetInstantDelta(Vector2 screenSpacePosition) => Parent.ToLocalSpace(screenSpacePosition) - Position; + + /// <summary> + /// Handle to perform a partial deletion when the user requests a quick delete (Shift+Right Click). + /// </summary> + /// <returns>True if the deletion operation was handled by this blueprint. Returning false will delete the full blueprint.</returns> + public virtual bool HandleQuickDeletion() => false; } } diff --git a/osu.Game/Rulesets/Edit/SnapResult.cs b/osu.Game/Rulesets/Edit/SnapResult.cs new file mode 100644 index 0000000000..31dd2b9496 --- /dev/null +++ b/osu.Game/Rulesets/Edit/SnapResult.cs @@ -0,0 +1,33 @@ +// 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. + +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Rulesets.Edit +{ + /// <summary> + /// The result of a position/time snapping process. + /// </summary> + public class SnapResult + { + /// <summary> + /// The screen space position, potentially altered for snapping. + /// </summary> + public Vector2 ScreenSpacePosition; + + /// <summary> + /// The resultant time for snapping, if a value could be attained. + /// </summary> + public double? Time; + + public readonly Playfield Playfield; + + public SnapResult(Vector2 screenSpacePosition, double? time, Playfield playfield = null) + { + ScreenSpacePosition = screenSpacePosition; + Time = time; + Playfield = playfield; + } + } +} diff --git a/osu.Game/Rulesets/Edit/ToolboxGroup.cs b/osu.Game/Rulesets/Edit/ToolboxGroup.cs index eabb834616..22b2b05657 100644 --- a/osu.Game/Rulesets/Edit/ToolboxGroup.cs +++ b/osu.Game/Rulesets/Edit/ToolboxGroup.cs @@ -8,9 +8,8 @@ namespace osu.Game.Rulesets.Edit { public class ToolboxGroup : PlayerSettingsGroup { - protected override string Title => "toolbox"; - - public ToolboxGroup() + public ToolboxGroup(string title) + : base(title) { RelativeSizeAxes = Axes.X; Width = 1; diff --git a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs b/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs index 825c63e6ee..0a01ac4320 100644 --- a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs +++ b/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs @@ -1,6 +1,8 @@ // 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. +using osu.Framework.Graphics; + namespace osu.Game.Rulesets.Edit.Tools { public abstract class HitObjectCompositionTool @@ -13,5 +15,9 @@ namespace osu.Game.Rulesets.Edit.Tools } public abstract PlacementBlueprint CreatePlacementBlueprint(); + + public virtual Drawable CreateIcon() => null; + + public override string ToString() => Name; } } diff --git a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs new file mode 100644 index 0000000000..c050766b23 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs @@ -0,0 +1,20 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; + +namespace osu.Game.Rulesets.Edit.Tools +{ + public class SelectTool : HitObjectCompositionTool + { + public SelectTool() + : base("Select") + { + } + + public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.MousePointer }; + + public override PlacementBlueprint CreatePlacementBlueprint() => null; + } +} diff --git a/osu.Game/Rulesets/ILegacyRuleset.cs b/osu.Game/Rulesets/ILegacyRuleset.cs new file mode 100644 index 0000000000..06a85b5261 --- /dev/null +++ b/osu.Game/Rulesets/ILegacyRuleset.cs @@ -0,0 +1,13 @@ +// 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. + +namespace osu.Game.Rulesets +{ + public interface ILegacyRuleset + { + /// <summary> + /// Identifies the server-side ID of a legacy ruleset. + /// </summary> + int LegacyID { get; } + } +} diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index 2150726a42..d24c81536e 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -1,74 +1,72 @@ // 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. +using System.Diagnostics; +using JetBrains.Annotations; using osuTK; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; -using osuTK.Graphics; namespace osu.Game.Rulesets.Judgements { /// <summary> /// A drawable object which visualises the hit result of a <see cref="Judgements.Judgement"/>. /// </summary> - public class DrawableJudgement : CompositeDrawable + public class DrawableJudgement : PoolableDrawable { - private const float judgement_size = 80; + private const float judgement_size = 128; - private OsuColour colours; + [Resolved] + private OsuColour colours { get; set; } - protected readonly JudgementResult Result; + public JudgementResult Result { get; private set; } + public DrawableHitObject JudgedObject { get; private set; } - public readonly DrawableHitObject JudgedObject; + protected Container JudgementBody { get; private set; } + protected SpriteText JudgementText { get; private set; } - protected Container JudgementBody; - protected SpriteText JudgementText; + private SkinnableDrawable bodyDrawable; /// <summary> /// Duration of initial fade in. - /// Default fade out will start immediately after this duration. /// </summary> protected virtual double FadeInDuration => 100; + /// <summary> + /// Duration to wait until fade out begins. Defaults to <see cref="FadeInDuration"/>. + /// </summary> + protected virtual double FadeOutDelay => FadeInDuration; + /// <summary> /// Creates a drawable which visualises a <see cref="Judgements.Judgement"/>. /// </summary> /// <param name="result">The judgement to visualise.</param> /// <param name="judgedObject">The object which was judged.</param> public DrawableJudgement(JudgementResult result, DrawableHitObject judgedObject) + : this() { - Result = result; - JudgedObject = judgedObject; + Apply(result, judgedObject); + } + public DrawableJudgement() + { Size = new Vector2(judgement_size); + Origin = Anchor.Centre; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - this.colours = colours; - - InternalChild = JudgementBody = new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Child = new SkinnableDrawable($"Play/{Result.Type}", _ => JudgementText = new OsuSpriteText - { - Text = Result.Type.GetDescription().ToUpperInvariant(), - Font = OsuFont.Numeric.With(size: 12), - Colour = judgementColour(Result.Type), - Scale = new Vector2(0.85f, 1), - }, restrictSize: false) - }; + prepareDrawables(); } protected virtual void ApplyHitAnimations() @@ -76,14 +74,29 @@ namespace osu.Game.Rulesets.Judgements JudgementBody.ScaleTo(0.9f); JudgementBody.ScaleTo(1, 500, Easing.OutElastic); - this.Delay(FadeInDuration).FadeOut(400); + this.Delay(FadeOutDelay).FadeOut(400); } - protected override void LoadComplete() + public virtual void Apply([NotNull] JudgementResult result, [CanBeNull] DrawableHitObject judgedObject) { - base.LoadComplete(); + Result = result; + JudgedObject = judgedObject; + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Result != null); + + prepareDrawables(); + + bodyDrawable.ResetAnimation(); this.FadeInFromZero(FadeInDuration, Easing.OutQuint); + JudgementBody.ScaleTo(1); + JudgementBody.RotateTo(0); + JudgementBody.MoveTo(Vector2.Zero); switch (Result.Type) { @@ -108,27 +121,34 @@ namespace osu.Game.Rulesets.Judgements Expire(true); } - private Color4 judgementColour(HitResult judgement) + private HitResult? currentDrawableType; + + private void prepareDrawables() { - switch (judgement) + var type = Result?.Type ?? HitResult.Perfect; //TODO: better default type from ruleset + + if (type == currentDrawableType) + return; + + // sub-classes might have added their own children that would be removed here if .InternalChild was used. + if (JudgementBody != null) + RemoveInternal(JudgementBody); + + AddInternal(JudgementBody = new Container { - case HitResult.Perfect: - case HitResult.Great: - return colours.Blue; + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Child = bodyDrawable = new SkinnableDrawable(new GameplaySkinComponent<HitResult>(type), _ => JudgementText = new OsuSpriteText + { + Text = type.GetDescription().ToUpperInvariant(), + Font = OsuFont.Numeric.With(size: 20), + Colour = colours.ForHitResult(type), + Scale = new Vector2(0.85f, 1), + }, confineMode: ConfineMode.NoScaling) + }); - case HitResult.Ok: - case HitResult.Good: - return colours.Green; - - case HitResult.Meh: - return colours.Yellow; - - case HitResult.Miss: - return colours.Red; - - default: - return Color4.White; - } + currentDrawableType = type; } } } diff --git a/osu.Game/Rulesets/Judgements/IgnoreJudgement.cs b/osu.Game/Rulesets/Judgements/IgnoreJudgement.cs new file mode 100644 index 0000000000..d2a434058d --- /dev/null +++ b/osu.Game/Rulesets/Judgements/IgnoreJudgement.cs @@ -0,0 +1,12 @@ +// 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. + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Judgements +{ + public class IgnoreJudgement : Judgement + { + public override HitResult MaxResult => HitResult.IgnoreHit; + } +} diff --git a/osu.Game/Rulesets/Judgements/Judgement.cs b/osu.Game/Rulesets/Judgements/Judgement.cs index f07f76a2b8..89a3a2b855 100644 --- a/osu.Game/Rulesets/Judgements/Judgement.cs +++ b/osu.Game/Rulesets/Judgements/Judgement.cs @@ -1,6 +1,7 @@ // 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. +using System; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -12,24 +13,68 @@ namespace osu.Game.Rulesets.Judgements public class Judgement { /// <summary> - /// The maximum <see cref="HitResult"/> that can be achieved. + /// The score awarded for a small bonus. /// </summary> - public virtual HitResult MaxResult => HitResult.Perfect; + public const int SMALL_BONUS_SCORE = 10; + + /// <summary> + /// The score awarded for a large bonus. + /// </summary> + public const int LARGE_BONUS_SCORE = 50; + + /// <summary> + /// The default health increase for a maximum judgement, as a proportion of total health. + /// By default, each maximum judgement restores 5% of total health. + /// </summary> + protected const double DEFAULT_MAX_HEALTH_INCREASE = 0.05; /// <summary> /// Whether this <see cref="Judgement"/> should affect the current combo. /// </summary> + [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; /// <summary> /// Whether this <see cref="Judgement"/> should be counted as base (combo) or bonus score. /// </summary> + [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; + /// <summary> + /// The maximum <see cref="HitResult"/> that can be achieved. + /// </summary> + public virtual HitResult MaxResult => HitResult.Perfect; + + /// <summary> + /// The minimum <see cref="HitResult"/> that can be achieved - the inverse of <see cref="MaxResult"/>. + /// </summary> + public HitResult MinResult + { + get + { + switch (MaxResult) + { + case HitResult.SmallBonus: + case HitResult.LargeBonus: + case HitResult.IgnoreHit: + return HitResult.IgnoreMiss; + + case HitResult.SmallTickHit: + return HitResult.SmallTickMiss; + + case HitResult.LargeTickHit: + return HitResult.LargeTickMiss; + + default: + return HitResult.Miss; + } + } + } + /// <summary> /// The numeric score representation for the maximum achievable result. /// </summary> - public int MaxNumericResult => NumericResultFor(MaxResult); + public int MaxNumericResult => ToNumericResult(MaxResult); /// <summary> /// The health increase for the maximum achievable result. @@ -41,21 +86,65 @@ namespace osu.Game.Rulesets.Judgements /// </summary> /// <param name="result">The <see cref="HitResult"/> to find the numeric score representation for.</param> /// <returns>The numeric score representation of <paramref name="result"/>.</returns> - protected virtual int NumericResultFor(HitResult result) => result > HitResult.Miss ? 1 : 0; + [Obsolete("Has no effect. Use ToNumericResult(HitResult) (standardised across all rulesets).")] // Can be made non-virtual 20210328 + protected virtual int NumericResultFor(HitResult result) => ToNumericResult(result); /// <summary> /// Retrieves the numeric score representation of a <see cref="JudgementResult"/>. /// </summary> /// <param name="result">The <see cref="JudgementResult"/> to find the numeric score representation for.</param> /// <returns>The numeric score representation of <paramref name="result"/>.</returns> - public int NumericResultFor(JudgementResult result) => NumericResultFor(result.Type); + public int NumericResultFor(JudgementResult result) => ToNumericResult(result.Type); /// <summary> /// Retrieves the numeric health increase of a <see cref="HitResult"/>. /// </summary> /// <param name="result">The <see cref="HitResult"/> to find the numeric health increase for.</param> /// <returns>The numeric health increase of <paramref name="result"/>.</returns> - protected virtual double HealthIncreaseFor(HitResult result) => 0; + protected virtual double HealthIncreaseFor(HitResult result) + { + switch (result) + { + default: + return 0; + + case HitResult.SmallTickHit: + return DEFAULT_MAX_HEALTH_INCREASE * 0.5; + + case HitResult.SmallTickMiss: + return -DEFAULT_MAX_HEALTH_INCREASE * 0.5; + + case HitResult.LargeTickHit: + return DEFAULT_MAX_HEALTH_INCREASE; + + case HitResult.LargeTickMiss: + return -DEFAULT_MAX_HEALTH_INCREASE; + + case HitResult.Miss: + return -DEFAULT_MAX_HEALTH_INCREASE; + + case HitResult.Meh: + return -DEFAULT_MAX_HEALTH_INCREASE * 0.05; + + case HitResult.Ok: + return DEFAULT_MAX_HEALTH_INCREASE * 0.5; + + case HitResult.Good: + return DEFAULT_MAX_HEALTH_INCREASE * 0.75; + + case HitResult.Great: + return DEFAULT_MAX_HEALTH_INCREASE; + + case HitResult.Perfect: + return DEFAULT_MAX_HEALTH_INCREASE * 1.05; + + case HitResult.SmallBonus: + return DEFAULT_MAX_HEALTH_INCREASE * 0.5; + + case HitResult.LargeBonus: + return DEFAULT_MAX_HEALTH_INCREASE; + } + } /// <summary> /// Retrieves the numeric health increase of a <see cref="JudgementResult"/>. @@ -64,6 +153,42 @@ namespace osu.Game.Rulesets.Judgements /// <returns>The numeric health increase of <paramref name="result"/>.</returns> public double HealthIncreaseFor(JudgementResult result) => HealthIncreaseFor(result.Type); - public override string ToString() => $"AffectsCombo:{AffectsCombo} MaxResult:{MaxResult} MaxScore:{MaxNumericResult}"; + public override string ToString() => $"MaxResult:{MaxResult} MaxScore:{MaxNumericResult}"; + + public static int ToNumericResult(HitResult result) + { + switch (result) + { + default: + return 0; + + case HitResult.SmallTickHit: + return 10; + + case HitResult.LargeTickHit: + return 30; + + case HitResult.Meh: + return 50; + + case HitResult.Ok: + return 100; + + case HitResult.Good: + return 200; + + case HitResult.Great: + return 300; + + case HitResult.Perfect: + return 350; + + case HitResult.SmallBonus: + return SMALL_BONUS_SCORE; + + case HitResult.LargeBonus: + return LARGE_BONUS_SCORE; + } + } } } diff --git a/osu.Game/Rulesets/Judgements/JudgementResult.cs b/osu.Game/Rulesets/Judgements/JudgementResult.cs index 195fe316ac..e3b2501cdc 100644 --- a/osu.Game/Rulesets/Judgements/JudgementResult.cs +++ b/osu.Game/Rulesets/Judgements/JudgementResult.cs @@ -1,6 +1,8 @@ // 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. +using JetBrains.Annotations; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; @@ -16,9 +18,16 @@ namespace osu.Game.Rulesets.Judgements /// </summary> public HitResult Type; + /// <summary> + /// The <see cref="HitObject"/> which was judged. + /// </summary> + [NotNull] + public readonly HitObject HitObject; + /// <summary> /// The <see cref="Judgement"/> which this <see cref="JudgementResult"/> applies for. /// </summary> + [NotNull] public readonly Judgement Judgement; /// <summary> @@ -27,6 +36,12 @@ namespace osu.Game.Rulesets.Judgements /// </summary> public double TimeOffset { get; internal set; } + /// <summary> + /// The absolute time at which this <see cref="JudgementResult"/> occurred. + /// Equal to the (end) time of the <see cref="HitObject"/> + <see cref="TimeOffset"/>. + /// </summary> + public double TimeAbsolute => HitObject.GetEndTime() + TimeOffset; + /// <summary> /// The combo prior to this <see cref="JudgementResult"/> occurring. /// </summary> @@ -42,6 +57,11 @@ namespace osu.Game.Rulesets.Judgements /// </summary> public double HealthAtJudgement { get; internal set; } + /// <summary> + /// Whether the user was in a failed state prior to this <see cref="JudgementResult"/> occurring. + /// </summary> + public bool FailedAtJudgement { get; internal set; } + /// <summary> /// Whether a miss or hit occurred. /// </summary> @@ -50,14 +70,16 @@ namespace osu.Game.Rulesets.Judgements /// <summary> /// Whether a successful hit occurred. /// </summary> - public bool IsHit => Type > HitResult.Miss; + public bool IsHit => Type.IsHit(); /// <summary> /// Creates a new <see cref="JudgementResult"/>. /// </summary> + /// <param name="hitObject">The <see cref="HitObject"/> which was judged.</param> /// <param name="judgement">The <see cref="Judgement"/> to refer to for scoring information.</param> - public JudgementResult(Judgement judgement) + public JudgementResult([NotNull] HitObject hitObject, [NotNull] Judgement judgement) { + HitObject = hitObject; Judgement = judgement; } diff --git a/osu.Game/Rulesets/Mods/IApplicableAfterBeatmapConversion.cs b/osu.Game/Rulesets/Mods/IApplicableAfterBeatmapConversion.cs new file mode 100644 index 0000000000..d45311675d --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableAfterBeatmapConversion.cs @@ -0,0 +1,19 @@ +// 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. + +using osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Mods +{ + /// <summary> + /// Interface for a <see cref="Mod"/> that applies changes to the <see cref="IBeatmap"/> generated by the <see cref="BeatmapConverter{TObject}"/>. + /// </summary> + public interface IApplicableAfterBeatmapConversion : IApplicableMod + { + /// <summary> + /// Applies this <see cref="Mod"/> to the <see cref="IBeatmap"/> after conversion has taken place. + /// </summary> + /// <param name="beatmap">The converted <see cref="IBeatmap"/>.</param> + void ApplyToBeatmap(IBeatmap beatmap); + } +} diff --git a/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs b/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs index c0262b783b..8c99d739cb 100644 --- a/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs +++ b/osu.Game/Rulesets/Mods/IApplicableFailOverride.cs @@ -11,6 +11,12 @@ namespace osu.Game.Rulesets.Mods /// <summary> /// Whether we should allow failing at the current point in time. /// </summary> - bool AllowFail { get; } + /// <returns>Whether the fail should be allowed to proceed. Return false to block.</returns> + bool PerformFail(); + + /// <summary> + /// Whether we want to restart on fail. Only used if <see cref="PerformFail"/> returns true. + /// </summary> + bool RestartOnFail { get; } } } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderCircle.cs b/osu.Game/Rulesets/Mods/IApplicableToAudio.cs similarity index 59% rename from osu.Game.Rulesets.Osu/Objects/SliderCircle.cs rename to osu.Game/Rulesets/Mods/IApplicableToAudio.cs index 151902a752..901da7af55 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderCircle.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToAudio.cs @@ -1,9 +1,9 @@ // 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. -namespace osu.Game.Rulesets.Osu.Objects +namespace osu.Game.Rulesets.Mods { - public class SliderCircle : HitCircle + public interface IApplicableToAudio : IApplicableToTrack, IApplicableToSample { } } diff --git a/osu.Game/Rulesets/Mods/IApplicableToBeatmap.cs b/osu.Game/Rulesets/Mods/IApplicableToBeatmap.cs index a634976b3c..cff669bf53 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToBeatmap.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToBeatmap.cs @@ -2,21 +2,18 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Beatmaps; -using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Mods { /// <summary> - /// Interface for a <see cref="Mod"/> that applies changes to a <see cref="Beatmap"/> - /// after conversion and post-processing has completed. + /// Interface for a <see cref="Mod"/> that applies changes to a <see cref="Beatmap"/> after conversion and post-processing has completed. /// </summary> - public interface IApplicableToBeatmap<TObject> : IApplicableMod - where TObject : HitObject + public interface IApplicableToBeatmap : IApplicableMod { /// <summary> - /// Applies this <see cref="IApplicableToBeatmap{TObject}"/> to a <see cref="Beatmap{TObject}"/>. + /// Applies this <see cref="IApplicableToBeatmap"/> to an <see cref="IBeatmap"/>. /// </summary> - /// <param name="beatmap">The <see cref="Beatmap{TObject}"/> to apply to.</param> - void ApplyToBeatmap(Beatmap<TObject> beatmap); + /// <param name="beatmap">The <see cref="IBeatmap"/> to apply to.</param> + void ApplyToBeatmap(IBeatmap beatmap); } } diff --git a/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs b/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs index 4d4cd75434..34198da722 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs @@ -10,6 +10,17 @@ namespace osu.Game.Rulesets.Mods /// </summary> public interface IApplicableToDifficulty : IApplicableMod { + /// <summary> + /// Called when a beatmap is changed. Can be used to read default values. + /// Any changes made will not be preserved. + /// </summary> + /// <param name="difficulty">The difficulty to read from.</param> + void ReadFromDifficulty(BeatmapDifficulty difficulty); + + /// <summary> + /// Called post beatmap conversion. Can be used to apply changes to difficulty attributes. + /// </summary> + /// <param name="difficulty">The difficulty to mutate.</param> void ApplyToDifficulty(BeatmapDifficulty difficulty); } } diff --git a/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs new file mode 100644 index 0000000000..a181955653 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs @@ -0,0 +1,15 @@ +// 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. + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mods +{ + public interface IApplicableToHealthProcessor : IApplicableMod + { + /// <summary> + /// Provide a <see cref="HealthProcessor"/> to a mod. Called once on initialisation of a play instance. + /// </summary> + void ApplyToHealthProcessor(HealthProcessor healthProcessor); + } +} diff --git a/osu.Game/Rulesets/Mods/IApplicableToPlayer.cs b/osu.Game/Rulesets/Mods/IApplicableToPlayer.cs new file mode 100644 index 0000000000..bf78428470 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableToPlayer.cs @@ -0,0 +1,15 @@ +// 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. + +using osu.Game.Screens.Play; + +namespace osu.Game.Rulesets.Mods +{ + /// <summary> + /// An interface for a mod which can temporarily override the <see cref="Player"/> settings. + /// </summary> + public interface IApplicableToPlayer : IApplicableMod + { + void ApplyToPlayer(Player player); + } +} diff --git a/osu.Game/Rulesets/Mods/IApplicableToSample.cs b/osu.Game/Rulesets/Mods/IApplicableToSample.cs new file mode 100644 index 0000000000..50a6d501b6 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableToSample.cs @@ -0,0 +1,15 @@ +// 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. + +using osu.Framework.Graphics.Audio; + +namespace osu.Game.Rulesets.Mods +{ + /// <summary> + /// An interface for mods that make adjustments to a sample. + /// </summary> + public interface IApplicableToSample : IApplicableMod + { + void ApplyToSample(DrawableSample sample); + } +} diff --git a/osu.Game/Rulesets/Mods/IApplicableToClock.cs b/osu.Game/Rulesets/Mods/IApplicableToTrack.cs similarity index 69% rename from osu.Game/Rulesets/Mods/IApplicableToClock.cs rename to osu.Game/Rulesets/Mods/IApplicableToTrack.cs index e5767b5fbf..9b840cea08 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToClock.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToTrack.cs @@ -1,15 +1,15 @@ // 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. -using osu.Framework.Timing; +using osu.Framework.Audio.Track; namespace osu.Game.Rulesets.Mods { /// <summary> /// An interface for mods that make adjustments to the track. /// </summary> - public interface IApplicableToClock : IApplicableMod + public interface IApplicableToTrack : IApplicableMod { - void ApplyToClock(IAdjustableClock clock); + void ApplyToTrack(ITrack track); } } diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 023d37497a..b8dc7a2661 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -2,15 +2,24 @@ // 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.Reflection; using Newtonsoft.Json; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.IO.Serialization; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mods { /// <summary> /// The base class for gameplay modifiers. /// </summary> + [ExcludeFromDynamicCompile] public abstract class Mod : IMod, IJsonSerializable { /// <summary> @@ -28,7 +37,7 @@ namespace osu.Game.Rulesets.Mods /// The icon of this mod. /// </summary> [JsonIgnore] - public virtual IconUsage Icon => FontAwesome.Solid.Question; + public virtual IconUsage? Icon => null; /// <summary> /// The type of this mod. @@ -42,6 +51,51 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual string Description => string.Empty; + /// <summary> + /// The tooltip to display for this mod when used in a <see cref="ModIcon"/>. + /// </summary> + /// <remarks> + /// Differs from <see cref="Name"/>, as the value of attributes (AR, CS, etc) changeable via the mod + /// are displayed in the tooltip. + /// </remarks> + [JsonIgnore] + public string IconTooltip + { + get + { + string description = SettingDescription; + + return string.IsNullOrEmpty(description) ? Name : $"{Name} ({description})"; + } + } + + /// <summary> + /// The description of editable settings of a mod to use in the <see cref="IconTooltip"/>. + /// </summary> + /// <remarks> + /// Parentheses are added to the tooltip, surrounding the value of this property. If this property is <c>string.Empty</c>, + /// the tooltip will not have parentheses. + /// </remarks> + public virtual string SettingDescription + { + get + { + var tooltipTexts = new List<string>(); + + foreach ((SettingSourceAttribute attr, PropertyInfo property) in this.GetOrderedSettingsSourceProperties()) + { + object bindableObj = property.GetValue(this); + + if ((bindableObj as IHasDefaultValue)?.IsDefault == true) + continue; + + tooltipTexts.Add($"{attr.Label} {bindableObj}"); + } + + return string.Join(", ", tooltipTexts.Where(s => !string.IsNullOrEmpty(s))); + } + } + /// <summary> /// The score multiplier of this mod. /// </summary> @@ -60,16 +114,40 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual bool Ranked => false; + /// <summary> + /// Whether this mod requires configuration to apply changes to the game. + /// </summary> + [JsonIgnore] + public virtual bool RequiresConfiguration => false; + /// <summary> /// The mods this mod cannot be enabled with. /// </summary> [JsonIgnore] - public virtual Type[] IncompatibleMods => new Type[] { }; + public virtual Type[] IncompatibleMods => Array.Empty<Type>(); /// <summary> /// Creates a copy of this <see cref="Mod"/> initialised to a default state. /// </summary> - public virtual Mod CreateCopy() => (Mod)Activator.CreateInstance(GetType()); + public virtual Mod CreateCopy() + { + var copy = (Mod)Activator.CreateInstance(GetType()); + + // Copy bindable values across + foreach (var (_, prop) in this.GetSettingsSourceProperties()) + { + var origBindable = prop.GetValue(this); + var copyBindable = prop.GetValue(copy); + + // The bindables themselves are readonly, so the value must be transferred through the Bindable<T>.Value property. + var valueProperty = origBindable.GetType().GetProperty(nameof(Bindable<object>.Value), BindingFlags.Public | BindingFlags.Instance); + Debug.Assert(valueProperty != null); + + valueProperty.SetValue(copyBindable, valueProperty.GetValue(origBindable)); + } + + return copy; + } public bool Equals(IMod other) => GetType() == other?.GetType(); } diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index e70d58acea..945dd444be 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -22,11 +22,15 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Autoplay"; public override string Acronym => "AT"; - public override IconUsage Icon => OsuIcon.ModAuto; + public override IconUsage? Icon => OsuIcon.ModAuto; public override ModType Type => ModType.Automation; public override string Description => "Watch a perfect automated play through the song."; public override double ScoreMultiplier => 1; - public bool AllowFail => false; + + public bool PerformFail() => false; + + public bool RestartOnFail => false; + public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModSuddenDeath), typeof(ModNoFail) }; public override bool HasImplementation => GetType().GenericTypeArguments.Length == 0; diff --git a/osu.Game/Rulesets/Mods/ModBlockFail.cs b/osu.Game/Rulesets/Mods/ModBlockFail.cs index 26efc3932d..1fde5abad4 100644 --- a/osu.Game/Rulesets/Mods/ModBlockFail.cs +++ b/osu.Game/Rulesets/Mods/ModBlockFail.cs @@ -14,7 +14,9 @@ namespace osu.Game.Rulesets.Mods /// <summary> /// We never fail, 'yo. /// </summary> - public bool AllowFail => false; + public bool PerformFail() => false; + + public bool RestartOnFail => false; public void ReadFromConfig(OsuConfigManager config) { diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index 3c6a3a54aa..cf8128301c 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -3,15 +3,45 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Play; namespace osu.Game.Rulesets.Mods { - public class ModCinema : ModAutoplay + public abstract class ModCinema<T> : ModCinema, IApplicableToDrawableRuleset<T> + where T : HitObject + { + public virtual void ApplyToDrawableRuleset(DrawableRuleset<T> drawableRuleset) + { + drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap)); + + // AlwaysPresent required for hitsounds + drawableRuleset.Playfield.AlwaysPresent = true; + drawableRuleset.Playfield.Hide(); + } + } + + public class ModCinema : ModAutoplay, IApplicableToHUD, IApplicableToPlayer { public override string Name => "Cinema"; public override string Acronym => "CN"; - public override bool HasImplementation => false; - public override IconUsage Icon => OsuIcon.ModCinema; + public override IconUsage? Icon => OsuIcon.ModCinema; public override string Description => "Watch the video without visual distractions."; + + public void ApplyToHUD(HUDOverlay overlay) + { + overlay.ShowHud.Value = false; + overlay.ShowHud.Disabled = true; + } + + public void ApplyToPlayer(Player player) + { + player.Background.EnableUserDim.Value = false; + + player.DimmableStoryboard.IgnoreUserSettings.Value = true; + + player.BreakOverlay.Hide(); + } } } diff --git a/osu.Game/Rulesets/Mods/ModDaycore.cs b/osu.Game/Rulesets/Mods/ModDaycore.cs index 7e6d959119..61ad7db706 100644 --- a/osu.Game/Rulesets/Mods/ModDaycore.cs +++ b/osu.Game/Rulesets/Mods/ModDaycore.cs @@ -2,8 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Framework.Timing; namespace osu.Game.Rulesets.Mods { @@ -11,15 +12,26 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Daycore"; public override string Acronym => "DC"; - public override IconUsage Icon => FontAwesome.Solid.Question; + public override IconUsage? Icon => null; public override string Description => "Whoaaaaa..."; - public override void ApplyToClock(IAdjustableClock clock) + private readonly BindableNumber<double> tempoAdjust = new BindableDouble(1); + private readonly BindableNumber<double> freqAdjust = new BindableDouble(1); + + protected ModDaycore() { - if (clock is IHasPitchAdjust pitchAdjust) - pitchAdjust.PitchAdjust *= RateAdjust; - else - base.ApplyToClock(clock); + SpeedChange.BindValueChanged(val => + { + freqAdjust.Value = SpeedChange.Default; + tempoAdjust.Value = val.NewValue / SpeedChange.Default; + }, true); + } + + public override void ApplyToTrack(ITrack track) + { + // base.ApplyToTrack() intentionally not called (different tempo adjustment is applied) + track.AddAdjustment(AdjustableProperty.Frequency, freqAdjust); + track.AddAdjustment(AdjustableProperty.Tempo, tempoAdjust); } } } diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs new file mode 100644 index 0000000000..165644edbe --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -0,0 +1,127 @@ +// 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. + +using osu.Game.Beatmaps; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Sprites; +using System; +using System.Collections.Generic; +using osu.Game.Configuration; +using System.Linq; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModDifficultyAdjust : Mod, IApplicableToDifficulty + { + public override string Name => @"Difficulty Adjust"; + + public override string Description => @"Override a beatmap's difficulty settings."; + + public override string Acronym => "DA"; + + public override ModType Type => ModType.Conversion; + + public override IconUsage? Icon => FontAwesome.Solid.Hammer; + + public override double ScoreMultiplier => 1.0; + + public override bool RequiresConfiguration => true; + + public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModHardRock) }; + + protected const int FIRST_SETTING_ORDER = 1; + + protected const int LAST_SETTING_ORDER = 2; + + [SettingSource("HP Drain", "Override a beatmap's set HP.", FIRST_SETTING_ORDER)] + public BindableNumber<float> DrainRate { get; } = new BindableFloat + { + Precision = 0.1f, + MinValue = 0, + MaxValue = 10, + Default = 5, + Value = 5, + }; + + [SettingSource("Accuracy", "Override a beatmap's set OD.", LAST_SETTING_ORDER)] + public BindableNumber<float> OverallDifficulty { get; } = new BindableFloat + { + Precision = 0.1f, + MinValue = 0, + MaxValue = 10, + Default = 5, + Value = 5, + }; + + public override string SettingDescription + { + get + { + string drainRate = DrainRate.IsDefault ? string.Empty : $"HP {DrainRate.Value:N1}"; + string overallDifficulty = OverallDifficulty.IsDefault ? string.Empty : $"OD {OverallDifficulty.Value:N1}"; + + return string.Join(", ", new[] + { + drainRate, + overallDifficulty + }.Where(s => !string.IsNullOrEmpty(s))); + } + } + + private BeatmapDifficulty difficulty; + + public void ReadFromDifficulty(BeatmapDifficulty difficulty) + { + if (this.difficulty == null || this.difficulty.ID != difficulty.ID) + { + TransferSettings(difficulty); + this.difficulty = difficulty; + } + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) => ApplySettings(difficulty); + + /// <summary> + /// Transfer initial settings from the beatmap to settings. + /// </summary> + /// <param name="difficulty">The beatmap's initial values.</param> + protected virtual void TransferSettings(BeatmapDifficulty difficulty) + { + TransferSetting(DrainRate, difficulty.DrainRate); + TransferSetting(OverallDifficulty, difficulty.OverallDifficulty); + } + + private readonly Dictionary<IBindable, bool> userChangedSettings = new Dictionary<IBindable, bool>(); + + /// <summary> + /// Transfer a setting from <see cref="BeatmapDifficulty"/> to a configuration bindable. + /// Only performs the transfer if the user is not currently overriding. + /// </summary> + protected void TransferSetting<T>(BindableNumber<T> bindable, T beatmapDefault) + where T : struct, IComparable<T>, IConvertible, IEquatable<T> + { + bindable.UnbindEvents(); + + userChangedSettings.TryAdd(bindable, false); + + bindable.Default = beatmapDefault; + + // users generally choose a difficulty setting and want it to stick across multiple beatmap changes. + // we only want to value transfer if the user hasn't changed the value previously. + if (!userChangedSettings[bindable]) + bindable.Value = beatmapDefault; + + bindable.ValueChanged += _ => userChangedSettings[bindable] = !bindable.IsDefault; + } + + /// <summary> + /// Apply all custom settings to the provided beatmap. + /// </summary> + /// <param name="difficulty">The beatmap to have settings applied.</param> + protected virtual void ApplySettings(BeatmapDifficulty difficulty) + { + difficulty.DrainRate = DrainRate.Value; + difficulty.OverallDifficulty = OverallDifficulty.Value; + } + } +} diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index a5e76e32b1..152657da33 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -3,22 +3,32 @@ using System; using System.Linq; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods { - public abstract class ModDoubleTime : ModTimeAdjust, IApplicableToClock + public abstract class ModDoubleTime : ModRateAdjust { public override string Name => "Double Time"; public override string Acronym => "DT"; - public override IconUsage Icon => OsuIcon.ModDoubletime; + public override IconUsage? Icon => OsuIcon.ModDoubletime; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Zoooooooooom..."; public override bool Ranked => true; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModHalfTime)).ToArray(); - protected override double RateAdjust => 1.5; + [SettingSource("Speed increase", "The actual increase to apply")] + public override BindableNumber<double> SpeedChange { get; } = new BindableDouble + { + MinValue = 1.01, + MaxValue = 2, + Default = 1.5, + Value = 1.5, + Precision = 0.01, + }; } } diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index 56ec0bec06..c6f3930029 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.cs @@ -2,21 +2,42 @@ // See the LICENCE file in the repository root for full licence text. using System; +using Humanizer; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mods { - public abstract class ModEasy : Mod, IApplicableToDifficulty + public abstract class ModEasy : Mod, IApplicableToDifficulty, IApplicableFailOverride, IApplicableToHealthProcessor { public override string Name => "Easy"; public override string Acronym => "EZ"; - public override IconUsage Icon => OsuIcon.ModEasy; + public override IconUsage? Icon => OsuIcon.ModEasy; public override ModType Type => ModType.DifficultyReduction; public override double ScoreMultiplier => 0.5; public override bool Ranked => true; - public override Type[] IncompatibleMods => new[] { typeof(ModHardRock) }; + public override Type[] IncompatibleMods => new[] { typeof(ModHardRock), typeof(ModDifficultyAdjust) }; + + [SettingSource("Extra Lives", "Number of extra lives")] + public Bindable<int> Retries { get; } = new BindableInt(2) + { + MinValue = 0, + MaxValue = 10 + }; + + public override string SettingDescription => Retries.IsDefault ? string.Empty : $"{"lives".ToQuantity(Retries.Value)}"; + + private int retries; + + private BindableNumber<double> health; + + public void ReadFromDifficulty(BeatmapDifficulty difficulty) + { + } public void ApplyToDifficulty(BeatmapDifficulty difficulty) { @@ -25,6 +46,25 @@ namespace osu.Game.Rulesets.Mods difficulty.ApproachRate *= ratio; difficulty.DrainRate *= ratio; difficulty.OverallDifficulty *= ratio; + + retries = Retries.Value; + } + + public bool PerformFail() + { + if (retries == 0) return true; + + health.Value = health.MaxValue; + retries--; + + return false; + } + + public bool RestartOnFail => false; + + public void ApplyToHealthProcessor(HealthProcessor healthProcessor) + { + health = healthProcessor.Health.GetBoundCopy(); } } } diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index cb0c2fafe5..08f2ccb75c 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Flashlight"; public override string Acronym => "FL"; - public override IconUsage Icon => OsuIcon.ModFlashlight; + public override IconUsage? Icon => OsuIcon.ModFlashlight; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Restricted view area."; public override bool Ranked => true; @@ -47,9 +47,25 @@ namespace osu.Game.Rulesets.Mods public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { Combo.BindTo(scoreProcessor.Combo); + + // Default value of ScoreProcessor's Rank in Flashlight Mod should be SS+ + scoreProcessor.Rank.Value = ScoreRank.XH; } - public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) + { + switch (rank) + { + case ScoreRank.X: + return ScoreRank.XH; + + case ScoreRank.S: + return ScoreRank.SH; + + default: + return rank; + } + } public virtual void ApplyToDrawableRuleset(DrawableRuleset<T> drawableRuleset) { @@ -91,6 +107,9 @@ namespace osu.Game.Rulesets.Mods { foreach (var breakPeriod in Breaks) { + if (!breakPeriod.HasEffect) + continue; + if (breakPeriod.Duration < FLASHLIGHT_FADE_DURATION * 2) continue; this.Delay(breakPeriod.StartTime + FLASHLIGHT_FADE_DURATION).FadeOutFromOne(FLASHLIGHT_FADE_DURATION); @@ -193,6 +212,12 @@ namespace osu.Game.Rulesets.Mods shader.Unbind(); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + quadBatch?.Dispose(); + } } } } diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index 27369f4c30..203b88951c 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.cs @@ -3,22 +3,32 @@ using System; using System.Linq; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods { - public abstract class ModHalfTime : ModTimeAdjust, IApplicableToClock + public abstract class ModHalfTime : ModRateAdjust { public override string Name => "Half Time"; public override string Acronym => "HT"; - public override IconUsage Icon => OsuIcon.ModHalftime; + public override IconUsage? Icon => OsuIcon.ModHalftime; public override ModType Type => ModType.DifficultyReduction; public override string Description => "Less zoom..."; public override bool Ranked => true; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModDoubleTime)).ToArray(); - protected override double RateAdjust => 0.75; + [SettingSource("Speed decrease", "The actual decrease to apply")] + public override BindableNumber<double> SpeedChange { get; } = new BindableDouble + { + MinValue = 0.5, + MaxValue = 0.99, + Default = 0.75, + Value = 0.75, + Precision = 0.01, + }; } } diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index 2044cbeae2..0e589735c1 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -12,10 +12,14 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Hard Rock"; public override string Acronym => "HR"; - public override IconUsage Icon => OsuIcon.ModHardrock; + public override IconUsage? Icon => OsuIcon.ModHardrock; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Everything just got a bit harder..."; - public override Type[] IncompatibleMods => new[] { typeof(ModEasy) }; + public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) }; + + public void ReadFromDifficulty(BeatmapDifficulty difficulty) + { + } public void ApplyToDifficulty(BeatmapDifficulty difficulty) { diff --git a/osu.Game/Rulesets/Mods/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index 0934992f55..df421adbe5 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.cs @@ -1,38 +1,30 @@ // 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. -using osu.Game.Configuration; +using System; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; namespace osu.Game.Rulesets.Mods { - public abstract class ModHidden : Mod, IReadFromConfig, IApplicableToDrawableHitObjects, IApplicableToScoreProcessor + public abstract class ModHidden : ModWithVisibilityAdjustment, IApplicableToScoreProcessor { public override string Name => "Hidden"; public override string Acronym => "HD"; - public override IconUsage Icon => OsuIcon.ModHidden; + public override IconUsage? Icon => OsuIcon.ModHidden; public override ModType Type => ModType.DifficultyIncrease; public override bool Ranked => true; - protected Bindable<bool> IncreaseFirstObjectVisibility = new Bindable<bool>(); - - public void ReadFromConfig(OsuConfigManager config) - { - IncreaseFirstObjectVisibility = config.GetBindable<bool>(OsuSetting.IncreaseFirstObjectVisibility); - } - - public virtual void ApplyToDrawableHitObjects(IEnumerable<DrawableHitObject> drawables) - { - foreach (var d in drawables.Skip(IncreaseFirstObjectVisibility.Value ? 1 : 0)) - d.ApplyCustomUpdateState += ApplyHiddenState; - } + /// <summary> + /// Check whether the provided hitobject should be considered the "first" hideable object. + /// Can be used to skip spinners, for instance. + /// </summary> + /// <param name="hitObject">The hitobject to check.</param> + [Obsolete("Use IsFirstAdjustableObject() instead.")] // Can be removed 20210506 + protected virtual bool IsFirstHideableObject(DrawableHitObject hitObject) => true; public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { @@ -55,6 +47,36 @@ namespace osu.Game.Rulesets.Mods } } + 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 + } + + /// <summary> + /// 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. + /// </summary> + /// <param name="hitObject">The hit object to apply the state change to.</param> + /// <param name="state">The state of the hit object.</param> + [Obsolete("Use ApplyIncreasedVisibilityState() instead.")] // Can be removed 20210506 + protected virtual void ApplyFirstObjectIncreaseVisibilityState(DrawableHitObject hitObject, ArmedState state) + { + } + + /// <summary> + /// Apply a hidden state to the provided object. + /// </summary> + /// <param name="hitObject">The hit object to apply the state change to.</param> + /// <param name="state">The state of the hit object.</param> + [Obsolete("Use ApplyNormalVisibilityState() instead.")] // Can be removed 20210506 protected virtual void ApplyHiddenState(DrawableHitObject hitObject, ArmedState state) { } diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index dc0fc33088..e8b051b4d9 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -1,26 +1,148 @@ // 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. +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.Sprites; -using osu.Framework.Timing; +using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Mods { - public abstract class ModNightcore : ModDoubleTime + public abstract class ModNightcore<TObject> : ModDoubleTime, IApplicableToDrawableRuleset<TObject> + where TObject : HitObject { public override string Name => "Nightcore"; public override string Acronym => "NC"; - public override IconUsage Icon => OsuIcon.ModNightcore; + public override IconUsage? Icon => OsuIcon.ModNightcore; public override string Description => "Uguuuuuuuu..."; - public override void ApplyToClock(IAdjustableClock clock) + private readonly BindableNumber<double> tempoAdjust = new BindableDouble(1); + private readonly BindableNumber<double> freqAdjust = new BindableDouble(1); + + protected ModNightcore() { - if (clock is IHasPitchAdjust pitchAdjust) - pitchAdjust.PitchAdjust *= RateAdjust; - else - base.ApplyToClock(clock); + SpeedChange.BindValueChanged(val => + { + freqAdjust.Value = SpeedChange.Default; + tempoAdjust.Value = val.NewValue / SpeedChange.Default; + }, true); + } + + public override void ApplyToTrack(ITrack track) + { + // base.ApplyToTrack() intentionally not called (different tempo adjustment is applied) + track.AddAdjustment(AdjustableProperty.Frequency, freqAdjust); + track.AddAdjustment(AdjustableProperty.Tempo, tempoAdjust); + } + + public void ApplyToDrawableRuleset(DrawableRuleset<TObject> drawableRuleset) + { + drawableRuleset.Overlays.Add(new NightcoreBeatContainer()); + } + + public class NightcoreBeatContainer : BeatSyncedContainer + { + private PausableSkinnableSound hatSample; + private PausableSkinnableSound clapSample; + private PausableSkinnableSound kickSample; + private PausableSkinnableSound finishSample; + + private int? firstBeat; + + public NightcoreBeatContainer() + { + Divisor = 2; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + hatSample = new PausableSkinnableSound(new SampleInfo("Gameplay/nightcore-hat")), + clapSample = new PausableSkinnableSound(new SampleInfo("Gameplay/nightcore-clap")), + kickSample = new PausableSkinnableSound(new SampleInfo("Gameplay/nightcore-kick")), + finishSample = new PausableSkinnableSound(new SampleInfo("Gameplay/nightcore-finish")), + }; + } + + private const int bars_per_segment = 4; + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + + int beatsPerBar = (int)timingPoint.TimeSignature; + int segmentLength = beatsPerBar * Divisor * bars_per_segment; + + if (!IsBeatSyncedWithTrack) + { + firstBeat = null; + return; + } + + if (!firstBeat.HasValue || beatIndex < firstBeat) + // decide on a good starting beat index if once has not yet been decided. + firstBeat = beatIndex < 0 ? 0 : (beatIndex / segmentLength + 1) * segmentLength; + + if (beatIndex >= firstBeat) + playBeatFor(beatIndex % segmentLength, timingPoint.TimeSignature); + } + + private void playBeatFor(int beatIndex, TimeSignatures signature) + { + if (beatIndex == 0) + finishSample?.Play(); + + switch (signature) + { + case TimeSignatures.SimpleTriple: + switch (beatIndex % 6) + { + case 0: + kickSample?.Play(); + break; + + case 3: + clapSample?.Play(); + break; + + default: + hatSample?.Play(); + break; + } + + break; + + case TimeSignatures.SimpleQuadruple: + switch (beatIndex % 4) + { + case 0: + kickSample?.Play(); + break; + + case 2: + clapSample?.Play(); + break; + + default: + hatSample?.Play(); + break; + } + + break; + } + } } } } diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index 49ee3354c3..b95ec7490e 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "No Fail"; public override string Acronym => "NF"; - public override IconUsage Icon => OsuIcon.ModNofail; + public override IconUsage? Icon => OsuIcon.ModNofail; public override ModType Type => ModType.DifficultyReduction; public override string Description => "You can't fail, no matter what."; public override double ScoreMultiplier => 0.5; diff --git a/osu.Game/Rulesets/Mods/ModNoMod.cs b/osu.Game/Rulesets/Mods/ModNoMod.cs index 0ddbd2a8cb..379a2122f2 100644 --- a/osu.Game/Rulesets/Mods/ModNoMod.cs +++ b/osu.Game/Rulesets/Mods/ModNoMod.cs @@ -1,6 +1,8 @@ // 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. +using osu.Framework.Graphics.Sprites; + namespace osu.Game.Rulesets.Mods { /// <summary> @@ -11,5 +13,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "No Mod"; public override string Acronym => "NM"; 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 0994d1f7d3..df0fc9c4b6 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -12,9 +12,11 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Perfect"; public override string Acronym => "PF"; - public override IconUsage Icon => OsuIcon.ModPerfect; + public override IconUsage? Icon => OsuIcon.ModPerfect; public override string Description => "SS or quit."; - protected override bool FailCondition(ScoreProcessor scoreProcessor, JudgementResult result) => scoreProcessor.Accuracy.Value != 1; + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + => result.Type.AffectsAccuracy() + && result.Type != result.Judgement.MaxResult; } } diff --git a/osu.Game/Rulesets/Mods/ModRandom.cs b/osu.Game/Rulesets/Mods/ModRandom.cs new file mode 100644 index 0000000000..da55ab3fbf --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModRandom.cs @@ -0,0 +1,17 @@ +// 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. + +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModRandom : Mod + { + public override string Name => "Random"; + public override string Acronym => "RD"; + public override ModType Type => ModType.Conversion; + public override IconUsage? Icon => OsuIcon.Dice; + public override double ScoreMultiplier => 1; + } +} diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs new file mode 100644 index 0000000000..2150b0fb68 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -0,0 +1,27 @@ +// 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. + +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Audio; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModRateAdjust : Mod, IApplicableToAudio + { + public abstract BindableNumber<double> SpeedChange { get; } + + public virtual void ApplyToTrack(ITrack track) + { + track.AddAdjustment(AdjustableProperty.Tempo, SpeedChange); + } + + public virtual void ApplyToSample(DrawableSample sample) + { + sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); + } + + 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 7c355577d4..b6fec42f43 100644 --- a/osu.Game/Rulesets/Mods/ModRelax.cs +++ b/osu.Game/Rulesets/Mods/ModRelax.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Relax"; public override string Acronym => "RX"; - public override IconUsage Icon => OsuIcon.ModRelax; + 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) }; diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index e332abd914..ae71041a64 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -6,28 +6,31 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; namespace osu.Game.Rulesets.Mods { - public abstract class ModSuddenDeath : Mod, IApplicableToScoreProcessor + public abstract class ModSuddenDeath : Mod, IApplicableToHealthProcessor, IApplicableFailOverride { public override string Name => "Sudden Death"; public override string Acronym => "SD"; - public override IconUsage Icon => OsuIcon.ModSuddendeath; + public override IconUsage? Icon => OsuIcon.ModSuddendeath; public override ModType Type => ModType.DifficultyIncrease; 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 void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + public bool PerformFail() => true; + + public bool RestartOnFail => true; + + public void ApplyToHealthProcessor(HealthProcessor healthProcessor) { - scoreProcessor.FailConditions += FailCondition; + healthProcessor.FailConditions += FailCondition; } - public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; - - protected virtual bool FailCondition(ScoreProcessor scoreProcessor, JudgementResult result) => scoreProcessor.Combo.Value == 0 && result.Judgement.AffectsCombo; + protected virtual bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + => result.Type.AffectsCombo() + && !result.IsHit; } } diff --git a/osu.Game/Rulesets/Mods/ModTimeAdjust.cs b/osu.Game/Rulesets/Mods/ModTimeAdjust.cs deleted file mode 100644 index 513883f552..0000000000 --- a/osu.Game/Rulesets/Mods/ModTimeAdjust.cs +++ /dev/null @@ -1,24 +0,0 @@ -// 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. - -using System; -using osu.Framework.Audio; -using osu.Framework.Timing; - -namespace osu.Game.Rulesets.Mods -{ - public abstract class ModTimeAdjust : Mod - { - public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp) }; - - protected abstract double RateAdjust { get; } - - public virtual void ApplyToClock(IAdjustableClock clock) - { - if (clock is IHasTempoAdjust tempo) - tempo.TempoAdjust *= RateAdjust; - else - clock.Rate *= RateAdjust; - } - } -} diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index a5f96087c0..4d43ae73d3 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -4,88 +4,97 @@ using System; using System.Linq; using osu.Framework.Audio; -using osu.Framework.Timing; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Audio; using osu.Game.Beatmaps; -using osu.Game.Rulesets.UI; +using osu.Game.Configuration; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; -using osuTK; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mods { - public abstract class ModTimeRamp : Mod + public abstract class ModTimeRamp : Mod, IUpdatableByPlayfield, IApplicableToBeatmap, IApplicableToAudio { - public override Type[] IncompatibleMods => new[] { typeof(ModTimeAdjust) }; - - protected abstract double FinalRateAdjustment { get; } - } - - public abstract class ModTimeRamp<T> : ModTimeRamp, IUpdatableByPlayfield, IApplicableToClock, IApplicableToBeatmap<T> - where T : HitObject - { - private double finalRateTime; - - private double beginRampTime; - - private IAdjustableClock clock; - /// <summary> /// The point in the beatmap at which the final ramping rate should be reached. /// </summary> private const double final_rate_progress = 0.75f; - public virtual void ApplyToClock(IAdjustableClock clock) + [SettingSource("Initial rate", "The starting speed of the track")] + public abstract BindableNumber<double> InitialRate { get; } + + [SettingSource("Final rate", "The final speed to ramp to")] + public abstract BindableNumber<double> FinalRate { get; } + + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public abstract BindableBool AdjustPitch { get; } + + public override string SettingDescription => $"{InitialRate.Value:N2}x to {FinalRate.Value:N2}x"; + + private double finalRateTime; + private double beginRampTime; + + public BindableNumber<double> SpeedChange { get; } = new BindableDouble { - this.clock = clock; + Default = 1, + Value = 1, + Precision = 0.01, + }; - lastAdjust = 1; + private ITrack track; - // for preview purposes. during gameplay, Update will overwrite this setting. - applyAdjustment(1); + protected ModTimeRamp() + { + // for preview purpose at song select. eventually we'll want to be able to update every frame. + FinalRate.BindValueChanged(val => applyRateAdjustment(1), true); + AdjustPitch.BindValueChanged(applyPitchAdjustment); } - public virtual void ApplyToBeatmap(Beatmap<T> beatmap) + public void ApplyToTrack(ITrack track) + { + this.track = track; + + FinalRate.TriggerChange(); + AdjustPitch.TriggerChange(); + } + + public void ApplyToSample(DrawableSample sample) + { + sample.AddAdjustment(AdjustableProperty.Frequency, SpeedChange); + } + + public virtual void ApplyToBeatmap(IBeatmap beatmap) { HitObject lastObject = beatmap.HitObjects.LastOrDefault(); + SpeedChange.SetDefault(); + beginRampTime = beatmap.HitObjects.FirstOrDefault()?.StartTime ?? 0; - finalRateTime = final_rate_progress * ((lastObject as IHasEndTime)?.EndTime ?? lastObject?.StartTime ?? 0); + finalRateTime = final_rate_progress * (lastObject?.GetEndTime() ?? 0); } public virtual void Update(Playfield playfield) { - applyAdjustment((clock.CurrentTime - beginRampTime) / finalRateTime); + applyRateAdjustment((track.CurrentTime - beginRampTime) / finalRateTime); } - private double lastAdjust = 1; - /// <summary> /// Adjust the rate along the specified ramp /// </summary> /// <param name="amount">The amount of adjustment to apply (from 0..1).</param> - private void applyAdjustment(double amount) + private void applyRateAdjustment(double amount) => + SpeedChange.Value = InitialRate.Value + (FinalRate.Value - InitialRate.Value) * Math.Clamp(amount, 0, 1); + + private void applyPitchAdjustment(ValueChangedEvent<bool> adjustPitchSetting) { - double adjust = 1 + (Math.Sign(FinalRateAdjustment) * MathHelper.Clamp(amount, 0, 1) * Math.Abs(FinalRateAdjustment)); + // remove existing old adjustment + track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange); - switch (clock) - { - case IHasPitchAdjust pitch: - pitch.PitchAdjust /= lastAdjust; - pitch.PitchAdjust *= adjust; - break; - - case IHasTempoAdjust tempo: - tempo.TempoAdjust /= lastAdjust; - tempo.TempoAdjust *= adjust; - break; - - default: - clock.Rate /= lastAdjust; - clock.Rate *= adjust; - break; - } - - lastAdjust = adjust; + track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange); } + + private AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue) + => adjustPitchSettingValue ? AdjustableProperty.Frequency : AdjustableProperty.Tempo; } } diff --git a/osu.Game/Rulesets/Mods/ModWindDown.cs b/osu.Game/Rulesets/Mods/ModWindDown.cs index 5d71c8950b..679b50057b 100644 --- a/osu.Game/Rulesets/Mods/ModWindDown.cs +++ b/osu.Game/Rulesets/Mods/ModWindDown.cs @@ -3,22 +3,47 @@ using System; using System.Linq; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Game.Rulesets.Objects; +using osu.Game.Configuration; namespace osu.Game.Rulesets.Mods { - public class ModWindDown<T> : ModTimeRamp<T> - where T : HitObject + public class ModWindDown : ModTimeRamp { public override string Name => "Wind Down"; public override string Acronym => "WD"; public override string Description => "Sloooow doooown..."; - public override IconUsage Icon => FontAwesome.Solid.ChevronCircleDown; + public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleDown; public override double ScoreMultiplier => 1.0; - protected override double FinalRateAdjustment => -0.25; + [SettingSource("Initial rate", "The starting speed of the track")] + public override BindableNumber<double> InitialRate { get; } = new BindableDouble + { + MinValue = 1, + MaxValue = 2, + Default = 1, + Value = 1, + Precision = 0.01, + }; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindUp<T>)).ToArray(); + [SettingSource("Final rate", "The speed increase to ramp towards")] + public override BindableNumber<double> FinalRate { get; } = new BindableDouble + { + MinValue = 0.5, + MaxValue = 0.99, + Default = 0.75, + Value = 0.75, + Precision = 0.01, + }; + + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public override BindableBool AdjustPitch { get; } = new BindableBool + { + Default = true, + Value = true + }; + + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindUp)).ToArray(); } } diff --git a/osu.Game/Rulesets/Mods/ModWindUp.cs b/osu.Game/Rulesets/Mods/ModWindUp.cs index aae85cec19..b733bf423e 100644 --- a/osu.Game/Rulesets/Mods/ModWindUp.cs +++ b/osu.Game/Rulesets/Mods/ModWindUp.cs @@ -3,22 +3,47 @@ using System; using System.Linq; +using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using osu.Game.Rulesets.Objects; +using osu.Game.Configuration; namespace osu.Game.Rulesets.Mods { - public class ModWindUp<T> : ModTimeRamp<T> - where T : HitObject + public class ModWindUp : ModTimeRamp { public override string Name => "Wind Up"; public override string Acronym => "WU"; public override string Description => "Can you keep up?"; - public override IconUsage Icon => FontAwesome.Solid.ChevronCircleUp; + public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleUp; public override double ScoreMultiplier => 1.0; - protected override double FinalRateAdjustment => 0.5; + [SettingSource("Initial rate", "The starting speed of the track")] + public override BindableNumber<double> InitialRate { get; } = new BindableDouble + { + MinValue = 0.5, + MaxValue = 1, + Default = 1, + Value = 1, + Precision = 0.01, + }; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindDown<T>)).ToArray(); + [SettingSource("Final rate", "The speed increase to ramp towards")] + public override BindableNumber<double> FinalRate { get; } = new BindableDouble + { + MinValue = 1.01, + MaxValue = 2, + Default = 1.5, + Value = 1.5, + Precision = 0.01, + }; + + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public override BindableBool AdjustPitch { get; } = new BindableBool + { + Default = true, + Value = true + }; + + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModWindDown)).ToArray(); } } diff --git a/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs b/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs new file mode 100644 index 0000000000..5b119b5e46 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs @@ -0,0 +1,114 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.Mods +{ + /// <summary> + /// A <see cref="Mod"/> which applies visibility adjustments to <see cref="DrawableHitObject"/>s + /// with an optional increased visibility adjustment depending on the user's "increase first object visibility" setting. + /// </summary> + public abstract class ModWithVisibilityAdjustment : Mod, IReadFromConfig, IApplicableToBeatmap, IApplicableToDrawableHitObjects + { + /// <summary> + /// The first adjustable object. + /// </summary> + protected HitObject FirstObject { get; private set; } + + /// <summary> + /// Whether the visibility of <see cref="FirstObject"/> should be increased. + /// </summary> + protected readonly Bindable<bool> IncreaseFirstObjectVisibility = new Bindable<bool>(); + + /// <summary> + /// Check whether the provided hitobject should be considered the "first" adjustable object. + /// Can be used to skip spinners, for instance. + /// </summary> + /// <param name="hitObject">The hitobject to check.</param> + protected virtual bool IsFirstAdjustableObject(HitObject hitObject) => true; + + /// <summary> + /// Apply a special increased-visibility state to the first adjustable object. + /// Only applicable if the user chooses to turn on the "increase first object visibility" setting. + /// </summary> + /// <param name="hitObject">The hit object to apply the state change to.</param> + /// <param name="state">The state of the hitobject.</param> + protected abstract void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state); + + /// <summary> + /// Apply a normal visibility state adjustment to an object. + /// </summary> + /// <param name="hitObject">The hit object to apply the state change to.</param> + /// <param name="state">The state of the hitobject.</param> + protected abstract void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state); + + public virtual void ReadFromConfig(OsuConfigManager config) + { + config.BindWith(OsuSetting.IncreaseFirstObjectVisibility, IncreaseFirstObjectVisibility); + } + + public virtual void ApplyToBeatmap(IBeatmap beatmap) + { + FirstObject = getFirstAdjustableObjectRecursive(beatmap.HitObjects); + + HitObject getFirstAdjustableObjectRecursive(IReadOnlyList<HitObject> hitObjects) + { + foreach (var h in hitObjects) + { + if (IsFirstAdjustableObject(h)) + return h; + + var nestedResult = getFirstAdjustableObjectRecursive(h.NestedHitObjects); + if (nestedResult != null) + return nestedResult; + } + + return null; + } + } + + public virtual void ApplyToDrawableHitObjects(IEnumerable<DrawableHitObject> drawables) + { + foreach (var dho in drawables) + { + dho.ApplyCustomUpdateState += (o, state) => + { + // Increased visibility is applied to the entire first object, including all of its nested hitobjects. + if (IncreaseFirstObjectVisibility.Value && isObjectEqualToOrNestedIn(o.HitObject, FirstObject)) + ApplyIncreasedVisibilityState(o, state); + else + ApplyNormalVisibilityState(o, state); + }; + } + } + + /// <summary> + /// Checks whether a given object is nested within a target. + /// </summary> + /// <param name="toCheck">The <see cref="HitObject"/> to check.</param> + /// <param name="target">The <see cref="HitObject"/> which may be equal to or contain <paramref name="toCheck"/> as a nested object.</param> + /// <returns>Whether <paramref name="toCheck"/> is equal to or nested within <paramref name="target"/>.</returns> + private bool isObjectEqualToOrNestedIn(HitObject toCheck, HitObject target) + { + if (target == null) + return false; + + if (toCheck == target) + return true; + + foreach (var h in target.NestedHitObjects) + { + if (isObjectEqualToOrNestedIn(toCheck, h)) + return true; + } + + return false; + } + } +} diff --git a/osu.Game/Rulesets/Mods/MultiMod.cs b/osu.Game/Rulesets/Mods/MultiMod.cs index f7d574d3c7..2107009dbb 100644 --- a/osu.Game/Rulesets/Mods/MultiMod.cs +++ b/osu.Game/Rulesets/Mods/MultiMod.cs @@ -6,7 +6,7 @@ using System.Linq; namespace osu.Game.Rulesets.Mods { - public class MultiMod : Mod + public sealed class MultiMod : Mod { public override string Name => string.Empty; public override string Acronym => string.Empty; @@ -20,6 +20,8 @@ namespace osu.Game.Rulesets.Mods Mods = mods; } + public override Mod CreateCopy() => new MultiMod(Mods.Select(m => m.CreateCopy()).ToArray()); + public override Type[] IncompatibleMods => Mods.SelectMany(m => m.IncompatibleMods).ToArray(); } } diff --git a/osu.Game/Rulesets/Objects/BarLineGenerator.cs b/osu.Game/Rulesets/Objects/BarLineGenerator.cs new file mode 100644 index 0000000000..9556b52735 --- /dev/null +++ b/osu.Game/Rulesets/Objects/BarLineGenerator.cs @@ -0,0 +1,69 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Rulesets.Objects +{ + public class BarLineGenerator<TBarLine> + where TBarLine : class, IBarLine, new() + { + /// <summary> + /// The generated bar lines. + /// </summary> + public readonly List<TBarLine> BarLines = new List<TBarLine>(); + + /// <summary> + /// Constructs and generates bar lines for provided beatmap. + /// </summary> + /// <param name="beatmap">The beatmap to generate bar lines for.</param> + public BarLineGenerator(IBeatmap beatmap) + { + if (beatmap.HitObjects.Count == 0) + return; + + HitObject lastObject = beatmap.HitObjects.Last(); + double lastHitTime = 1 + lastObject.GetEndTime(); + + var timingPoints = beatmap.ControlPointInfo.TimingPoints; + + if (timingPoints.Count == 0) + return; + + for (int i = 0; i < timingPoints.Count; i++) + { + TimingControlPoint currentTimingPoint = timingPoints[i]; + int currentBeat = 0; + + // Stop on the beat before the next timing point, or if there is no next timing point stop slightly past the last object + double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time - currentTimingPoint.BeatLength : lastHitTime + currentTimingPoint.BeatLength * (int)currentTimingPoint.TimeSignature; + + double barLength = currentTimingPoint.BeatLength * (int)currentTimingPoint.TimeSignature; + + for (double t = currentTimingPoint.Time; Precision.DefinitelyBigger(endTime, t); t += barLength, currentBeat++) + { + var roundedTime = Math.Round(t, MidpointRounding.AwayFromZero); + + // in the case of some bar lengths, rounding errors can cause t to be slightly less than + // the expected whole number value due to floating point inaccuracies. + // if this is the case, apply rounding. + if (Precision.AlmostEquals(t, roundedTime)) + { + t = roundedTime; + } + + BarLines.Add(new TBarLine + { + StartTime = t, + Major = currentBeat % (int)currentTimingPoint.TimeSignature == 0 + }); + } + } + } + } +} diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 181ae37a8b..7a4e136553 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -3,46 +3,64 @@ 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.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.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; +using osu.Game.Configuration; using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Drawables { + [Cached(typeof(DrawableHitObject))] public abstract class DrawableHitObject : SkinReloadableDrawable { - public readonly HitObject HitObject; + public event Action<DrawableHitObject> DefaultsApplied; + + /// <summary> + /// The <see cref="HitObject"/> currently represented by this <see cref="DrawableHitObject"/>. + /// </summary> + public HitObject HitObject { get; private set; } /// <summary> /// The colour used for various elements of this DrawableHitObject. /// </summary> public readonly Bindable<Color4> AccentColour = new Bindable<Color4>(Color4.Gray); - // Todo: Rulesets should be overriding the resources instead, but we need to figure out where/when to apply overrides first - protected virtual string SampleNamespace => null; + protected PausableSkinnableSound Samples { get; private set; } - protected SkinnableSound Samples; - - protected virtual IEnumerable<HitSampleInfo> GetSamples() => HitObject.Samples; + public virtual IEnumerable<HitSampleInfo> GetSamples() => HitObject.Samples; private readonly Lazy<List<DrawableHitObject>> nestedHitObjects = new Lazy<List<DrawableHitObject>>(); - public IEnumerable<DrawableHitObject> NestedHitObjects => nestedHitObjects.IsValueCreated ? nestedHitObjects.Value : Enumerable.Empty<DrawableHitObject>(); + public IReadOnlyList<DrawableHitObject> NestedHitObjects => nestedHitObjects.IsValueCreated ? nestedHitObjects.Value : (IReadOnlyList<DrawableHitObject>)Array.Empty<DrawableHitObject>(); /// <summary> - /// Invoked when a <see cref="JudgementResult"/> has been applied by this <see cref="DrawableHitObject"/> or a nested <see cref="DrawableHitObject"/>. + /// Whether this object should handle any user input events. + /// </summary> + public bool HandleUserInput { get; set; } = true; + + public override bool PropagatePositionalInputSubTree => HandleUserInput; + + public override bool PropagateNonPositionalInputSubTree => HandleUserInput; + + /// <summary> + /// Invoked by this or a nested <see cref="DrawableHitObject"/> after a <see cref="JudgementResult"/> has been applied. /// </summary> public event Action<DrawableHitObject, JudgementResult> OnNewResult; /// <summary> - /// Invoked when a <see cref="JudgementResult"/> is being reverted by this <see cref="DrawableHitObject"/> or a nested <see cref="DrawableHitObject"/>. + /// Invoked by this or a nested <see cref="DrawableHitObject"/> prior to a <see cref="JudgementResult"/> being reverted. /// </summary> public event Action<DrawableHitObject, JudgementResult> OnRevertResult; @@ -73,7 +91,19 @@ namespace osu.Game.Rulesets.Objects.Drawables /// </summary> public JudgementResult Result { get; private set; } - private bool judgementOccurred; + /// <summary> + /// The relative X position of this hit object for sample playback balance adjustment. + /// </summary> + /// <remarks> + /// This is a range of 0..1 (0 for far-left, 0.5 for centre, 1 for far-right). + /// Dampening is post-applied to ensure the effect is not too intense. + /// </remarks> + protected virtual float SamplePlaybackPosition => 0.5f; + + private readonly Bindable<double> startTimeBindable = new Bindable<double>(); + private readonly BindableList<HitSampleInfo> samplesBindable = new BindableList<HitSampleInfo>(); + private readonly Bindable<bool> userPositionalHitSounds = new Bindable<bool>(); + private readonly Bindable<int> comboIndexBindable = new Bindable<int>(); public override bool RemoveWhenNotAlive => false; public override bool RemoveCompletedTransforms => false; @@ -85,61 +115,226 @@ namespace osu.Game.Rulesets.Objects.Drawables public IBindable<ArmedState> State => state; - protected DrawableHitObject(HitObject hitObject) + /// <summary> + /// Whether <see cref="HitObject"/> is currently applied. + /// </summary> + private bool hasHitObjectApplied; + + /// <summary> + /// Creates a new <see cref="DrawableHitObject"/>. + /// </summary> + /// <param name="initialHitObject"> + /// The <see cref="HitObject"/> to be initially applied to this <see cref="DrawableHitObject"/>. + /// If <c>null</c>, a hitobject is expected to be later applied via <see cref="Apply"/> (or automatically via pooling). + /// </param> + protected DrawableHitObject([CanBeNull] HitObject initialHitObject = null) { - HitObject = hitObject; + HitObject = initialHitObject; } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { - var judgement = HitObject.CreateJudgement(); + config.BindWith(OsuSetting.PositionalHitSounds, userPositionalHitSounds); + } - if (judgement != null) - { - Result = CreateResult(judgement); - if (Result == null) - throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); - } + protected override void LoadAsyncComplete() + { + base.LoadAsyncComplete(); - var samples = GetSamples().ToArray(); - - if (samples.Length > 0) - { - if (HitObject.SampleControlPoint == null) - throw new ArgumentNullException(nameof(HitObject.SampleControlPoint), $"{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.Select(s => HitObject.SampleControlPoint.ApplyTo(s)).ToArray(); - foreach (var s in samples) - s.Namespace = SampleNamespace; - - AddInternal(Samples = new SkinnableSound(samples)); - } + if (HitObject != null) + Apply(HitObject); } protected override void LoadComplete() { base.LoadComplete(); + + startTimeBindable.BindValueChanged(_ => updateState(State.Value, true)); + comboIndexBindable.BindValueChanged(_ => updateComboColour(), true); + updateState(ArmedState.Idle, true); } + /// <summary> + /// Applies a new <see cref="HitObject"/> to be represented by this <see cref="DrawableHitObject"/>. + /// </summary> + /// <param name="hitObject">The <see cref="HitObject"/> to apply.</param> + public void Apply(HitObject hitObject) + { + free(); + + HitObject = hitObject ?? throw new InvalidOperationException($"Cannot apply a null {nameof(HitObject)}."); + + // Ensure this DHO has a result. + Result ??= CreateResult(HitObject.CreateJudgement()) + ?? throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); + + foreach (var h in HitObject.NestedHitObjects) + { + var drawableNested = CreateNestedHitObject(h) ?? throw new InvalidOperationException($"{nameof(CreateNestedHitObject)} returned null for {h.GetType().ReadableName()}."); + + drawableNested.OnNewResult += onNewResult; + drawableNested.OnRevertResult += onRevertResult; + drawableNested.ApplyCustomUpdateState += onApplyCustomUpdateState; + + nestedHitObjects.Value.Add(drawableNested); + AddNestedHitObject(drawableNested); + } + + startTimeBindable.BindTo(HitObject.StartTimeBindable); + if (HitObject is IHasComboInformation combo) + comboIndexBindable.BindTo(combo.ComboIndexBindable); + + samplesBindable.BindTo(HitObject.SamplesBindable); + samplesBindable.BindCollectionChanged(onSamplesChanged, true); + + HitObject.DefaultsApplied += onDefaultsApplied; + + OnApply(hitObject); + + // If not loaded, the state update happens in LoadComplete(). Otherwise, the update is scheduled to allow for lifetime updates. + if (IsLoaded) + Schedule(() => updateState(ArmedState.Idle, true)); + + hasHitObjectApplied = true; + } + + /// <summary> + /// Removes the currently applied <see cref="HitObject"/> + /// </summary> + private void free() + { + if (!hasHitObjectApplied) + return; + + startTimeBindable.UnbindFrom(HitObject.StartTimeBindable); + if (HitObject is IHasComboInformation combo) + comboIndexBindable.UnbindFrom(combo.ComboIndexBindable); + + samplesBindable.UnbindFrom(HitObject.SamplesBindable); + + // When a new hitobject is applied, the samples will be cleared before re-populating. + // In order to stop this needless update, the event is unbound and re-bound as late as possible in Apply(). + samplesBindable.CollectionChanged -= onSamplesChanged; + + if (nestedHitObjects.IsValueCreated) + { + foreach (var obj in nestedHitObjects.Value) + { + obj.OnNewResult -= onNewResult; + obj.OnRevertResult -= onRevertResult; + obj.ApplyCustomUpdateState -= onApplyCustomUpdateState; + } + + nestedHitObjects.Value.Clear(); + ClearNestedHitObjects(); + } + + HitObject.DefaultsApplied -= onDefaultsApplied; + + OnFree(HitObject); + + HitObject = null; + 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(); + } + + /// <summary> + /// Invoked for this <see cref="DrawableHitObject"/> to take on any values from a newly-applied <see cref="HitObject"/>. + /// </summary> + /// <param name="hitObject">The <see cref="HitObject"/> being applied.</param> + protected virtual void OnApply(HitObject hitObject) + { + } + + /// <summary> + /// Invoked for this <see cref="DrawableHitObject"/> to revert any values previously taken on from the currently-applied <see cref="HitObject"/>. + /// </summary> + /// <param name="hitObject">The currently-applied <see cref="HitObject"/>.</param> + protected virtual void OnFree(HitObject hitObject) + { + } + + /// <summary> + /// Invoked by the base <see cref="DrawableHitObject"/> to populate samples, once on initial load and potentially again on any change to the samples collection. + /// </summary> + protected virtual void LoadSamples() + { + if (Samples != null) + { + RemoveInternal(Samples); + Samples = null; + } + + var samples = GetSamples().ToArray(); + + if (samples.Length <= 0) + return; + + if (HitObject.SampleControlPoint == null) + { + 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 = new PausableSkinnableSound(samples.Select(s => HitObject.SampleControlPoint.ApplyTo(s))); + AddInternal(Samples); + } + + private void onSamplesChanged(object sender, NotifyCollectionChangedEventArgs e) => LoadSamples(); + + private void onNewResult(DrawableHitObject drawableHitObject, JudgementResult result) => OnNewResult?.Invoke(drawableHitObject, result); + + private void onRevertResult(DrawableHitObject drawableHitObject, JudgementResult result) => OnRevertResult?.Invoke(drawableHitObject, result); + + private void onApplyCustomUpdateState(DrawableHitObject drawableHitObject, ArmedState state) => ApplyCustomUpdateState?.Invoke(drawableHitObject, state); + + private void onDefaultsApplied(HitObject hitObject) + { + Apply(hitObject); + DefaultsApplied?.Invoke(this); + } + + /// <summary> + /// Invoked by the base <see cref="DrawableHitObject"/> to add nested <see cref="DrawableHitObject"/>s to the hierarchy. + /// </summary> + /// <param name="hitObject">The <see cref="DrawableHitObject"/> to be added.</param> + protected virtual void AddNestedHitObject(DrawableHitObject hitObject) + { + } + + /// <summary> + /// Invoked by the base <see cref="DrawableHitObject"/> to remove all previously-added nested <see cref="DrawableHitObject"/>s. + /// </summary> + protected virtual void ClearNestedHitObjects() + { + } + + /// <summary> + /// Creates the drawable representation for a nested <see cref="HitObject"/>. + /// </summary> + /// <param name="hitObject">The <see cref="HitObject"/>.</param> + /// <returns>The drawable representation for <paramref name="hitObject"/>.</returns> + protected virtual DrawableHitObject CreateNestedHitObject(HitObject hitObject) => null; + #region State / Transform Management /// <summary> - /// Bind to apply a custom state which can override the default implementation. + /// Invoked by this or a nested <see cref="DrawableHitObject"/> to apply a custom state that can override the default implementation. /// </summary> public event Action<DrawableHitObject, ArmedState> ApplyCustomUpdateState; - /// <summary> - /// Enables automatic transform management of this hitobject. Implementation of transforms should be done in <see cref="UpdateInitialTransforms"/> and <see cref="UpdateStateTransforms"/> only. Rewinding and removing previous states is done automatically. - /// </summary> - /// <remarks> - /// Going forward, this is the preferred way of implementing <see cref="DrawableHitObject"/>s. Previous functionality - /// is offered as a compatibility layer until all rulesets have been migrated across. - /// </remarks> - protected virtual bool UseTransformStateManagement => true; - protected override void ClearInternal(bool disposeChildren = true) => throw new InvalidOperationException($"Should never clear a {nameof(DrawableHitObject)}"); private void updateState(ArmedState newState, bool force = false) @@ -147,41 +342,44 @@ namespace osu.Game.Rulesets.Objects.Drawables if (State.Value == newState && !force) return; - if (UseTransformStateManagement) - { - double transformTime = HitObject.StartTime - InitialLifetimeOffset; + LifetimeEnd = double.MaxValue; - base.ApplyTransformsAt(transformTime, true); - base.ClearTransformsAfter(transformTime, true); + double transformTime = HitObject.StartTime - InitialLifetimeOffset; - using (BeginAbsoluteSequence(transformTime, true)) - { - UpdateInitialTransforms(); + base.ApplyTransformsAt(double.MinValue, true); + base.ClearTransformsAfter(double.MinValue, true); - var judgementOffset = Math.Min(HitObject.HitWindows?.HalfWindowFor(HitResult.Miss) ?? double.MaxValue, Result?.TimeOffset ?? 0); + using (BeginAbsoluteSequence(transformTime, true)) + UpdateInitialTransforms(); - using (BeginDelayedSequence(InitialLifetimeOffset + judgementOffset, true)) - { - UpdateStateTransforms(newState); - state.Value = newState; - } - } - } - else - state.Value = newState; + using (BeginAbsoluteSequence(StateUpdateTime, true)) + UpdateStartTimeStateTransforms(); - UpdateState(newState); +#pragma warning disable 618 + using (BeginAbsoluteSequence(StateUpdateTime + (Result?.TimeOffset ?? 0), true)) + UpdateStateTransforms(newState); +#pragma warning restore 618 + + using (BeginAbsoluteSequence(HitStateUpdateTime, true)) + UpdateHitStateTransforms(newState); + + state.Value = newState; + + if (LifetimeEnd == double.MaxValue && (state.Value != ArmedState.Idle || HitObject.HitWindows == null)) + Expire(); // apply any custom state overrides ApplyCustomUpdateState?.Invoke(this, newState); - if (newState == ArmedState.Hit) + if (!force && newState == ArmedState.Hit) PlaySamples(); } /// <summary> /// Apply (generally fade-in) transforms leading into the <see cref="HitObject"/> start time. /// The local drawable hierarchy is recursively delayed to <see cref="LifetimeStart"/> for convenience. + /// + /// By default this will fade in the object from zero with no duration. /// </summary> /// <remarks> /// This is called once before every <see cref="UpdateStateTransforms"/>. This is to ensure a good state in the case @@ -189,54 +387,130 @@ namespace osu.Game.Rulesets.Objects.Drawables /// </remarks> protected virtual void UpdateInitialTransforms() { + this.FadeInFromZero(); } /// <summary> /// Apply transforms based on the current <see cref="ArmedState"/>. Previous states are automatically cleared. + /// In the case of a non-idle <see cref="ArmedState"/>, and if <see cref="Drawable.LifetimeEnd"/> was not set during this call, <see cref="Drawable.Expire"/> will be invoked. /// </summary> /// <param name="state">The new armed state.</param> + [Obsolete("Use UpdateStartTimeStateTransforms and UpdateHitStateTransforms instead")] // Can be removed 20210504 protected virtual void UpdateStateTransforms(ArmedState state) { } + /// <summary> + /// Apply passive transforms at the <see cref="HitObject"/>'s StartTime. + /// This is called each time <see cref="State"/> changes. + /// Previous states are automatically cleared. + /// </summary> + protected virtual void UpdateStartTimeStateTransforms() + { + } + + /// <summary> + /// Apply transforms based on the current <see cref="ArmedState"/>. This call is offset by <see cref="HitStateUpdateTime"/> (HitObject.EndTime + Result.Offset), equivalent to when the user hit the object. + /// If <see cref="Drawable.LifetimeEnd"/> was not set during this call, <see cref="Drawable.Expire"/> will be invoked. + /// Previous states are automatically cleared. + /// </summary> + /// <param name="state">The new armed state.</param> + protected virtual void UpdateHitStateTransforms(ArmedState state) + { + } + public override void ClearTransformsAfter(double time, bool propagateChildren = false, string targetMember = null) { - // When we are using automatic state management, parent calls to this should be blocked for safety. - if (!UseTransformStateManagement) - base.ClearTransformsAfter(time, propagateChildren, targetMember); + // Parent calls to this should be blocked for safety, as we are manually handling this in updateState. } public override void ApplyTransformsAt(double time, bool propagateChildren = false) { - // When we are using automatic state management, parent calls to this should be blocked for safety. - if (!UseTransformStateManagement) - base.ApplyTransformsAt(time, propagateChildren); - } - - /// <summary> - /// Legacy method to handle state changes. - /// Should generally not be used when <see cref="UseTransformStateManagement"/> is true; use <see cref="UpdateStateTransforms"/> instead. - /// </summary> - /// <param name="state">The new armed state.</param> - protected virtual void UpdateState(ArmedState state) - { + // Parent calls to this should be blocked for safety, as we are manually handling this in updateState. } #endregion - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected sealed override void SkinChanged(ISkinSource skin, bool allowFallback) { base.SkinChanged(skin, allowFallback); - if (HitObject is IHasComboInformation combo) - AccentColour.Value = skin.GetValue<SkinConfiguration, Color4?>(s => s.ComboColours.Count > 0 ? s.ComboColours[combo.ComboIndex % s.ComboColours.Count] : (Color4?)null) ?? Color4.White; + updateComboColour(); + + ApplySkin(skin, allowFallback); + + if (IsLoaded) + updateState(State.Value, true); + } + + private void updateComboColour() + { + if (!(HitObject is IHasComboInformation)) return; + + var comboColours = CurrentSkin.GetConfig<GlobalSkinColours, IReadOnlyList<Color4>>(GlobalSkinColours.ComboColours)?.Value; + + AccentColour.Value = GetComboColour(comboColours); + } + + /// <summary> + /// Called to retrieve the combo colour. Automatically assigned to <see cref="AccentColour"/>. + /// Defaults to using <see cref="IHasComboInformation.ComboIndex"/> to decide on a colour. + /// </summary> + /// <remarks> + /// This will only be called if the <see cref="HitObject"/> implements <see cref="IHasComboInformation"/>. + /// </remarks> + /// <param name="comboColours">A list of combo colours provided by the beatmap or skin. Can be null if not available.</param> + protected virtual Color4 GetComboColour(IReadOnlyList<Color4> comboColours) + { + if (!(HitObject is IHasComboInformation combo)) + throw new InvalidOperationException($"{nameof(HitObject)} must implement {nameof(IHasComboInformation)}"); + + return comboColours?.Count > 0 ? comboColours[combo.ComboIndex % comboColours.Count] : Color4.White; + } + + /// <summary> + /// Called when a change is made to the skin. + /// </summary> + /// <param name="skin">The new skin.</param> + /// <param name="allowFallback">Whether fallback to default skin should be allowed if the custom skin is missing this resource.</param> + protected virtual void ApplySkin(ISkinSource skin, bool allowFallback) + { + } + + /// <summary> + /// Calculate the position to be used for sample playback at a specified X position (0..1). + /// </summary> + /// <param name="position">The lookup X position. Generally should be <see cref="SamplePlaybackPosition"/>.</param> + /// <returns></returns> + protected double CalculateSamplePlaybackBalance(double position) + { + const float balance_adjust_amount = 0.4f; + + return balance_adjust_amount * (userPositionalHitSounds.Value ? position - 0.5f : 0); } /// <summary> /// Plays all the hit sounds for this <see cref="DrawableHitObject"/>. /// This is invoked automatically when this <see cref="DrawableHitObject"/> is hit. /// </summary> - public void PlaySamples() => Samples?.Play(); + public virtual void PlaySamples() + { + if (Samples != null) + { + Samples.Balance.Value = CalculateSamplePlaybackBalance(SamplePlaybackPosition); + Samples.Play(); + } + } + + /// <summary> + /// Stops playback of all relevant samples. Generally only looping samples should be stopped by this, and the rest let to play out. + /// Automatically called when <see cref="DrawableHitObject{TObject}"/>'s lifetime has been exceeded. + /// </summary> + public virtual void StopAllSamples() + { + if (Samples?.Looping == true) + Samples.Stop(); + } protected override void Update() { @@ -244,7 +518,7 @@ namespace osu.Game.Rulesets.Objects.Drawables if (Result != null && Result.HasResult) { - var endTime = (HitObject as IHasEndTime)?.EndTime ?? HitObject.StartTime; + var endTime = HitObject.GetEndTime(); if (Result.TimeOffset + endTime > Time.Current) { @@ -258,7 +532,7 @@ namespace osu.Game.Rulesets.Objects.Drawables } } - protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => AllJudged && base.ComputeIsMaskedAway(maskingBounds); + public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) => false; protected override void UpdateAfterChildren() { @@ -267,6 +541,14 @@ namespace osu.Game.Rulesets.Objects.Drawables UpdateResult(false); } + /// <summary> + /// Schedules an <see cref="Action"/> to this <see cref="DrawableHitObject"/>. + /// </summary> + /// <remarks> + /// Only provided temporarily until hitobject pooling is implemented. + /// </remarks> + protected internal new ScheduledDelegate Schedule(Action action) => base.Schedule(action); + private double? lifetimeStart; public override double LifetimeStart @@ -274,8 +556,8 @@ namespace osu.Game.Rulesets.Objects.Drawables get => lifetimeStart ?? (HitObject.StartTime - InitialLifetimeOffset); set { - base.LifetimeStart = value; lifetimeStart = value; + base.LifetimeStart = value; } } @@ -286,10 +568,22 @@ namespace osu.Game.Rulesets.Objects.Drawables /// <remarks> /// This is only used as an optimisation to delay the initial update of this <see cref="DrawableHitObject"/> and may be tuned more aggressively if required. /// It is indirectly used to decide the automatic transform offset provided to <see cref="UpdateInitialTransforms"/>. - /// A more accurate <see cref="LifetimeStart"/> should be set inside <see cref="UpdateState"/> for an <see cref="ArmedState.Idle"/> state. + /// A more accurate <see cref="LifetimeStart"/> should be set for further optimisation (in <see cref="LoadComplete"/>, for example). /// </remarks> protected virtual double InitialLifetimeOffset => 10000; + /// <summary> + /// The time at which state transforms should be applied that line up to <see cref="HitObject"/>'s StartTime. + /// This is used to offset calls to <see cref="UpdateStateTransforms"/>. + /// </summary> + public double StateUpdateTime => HitObject.StartTime; + + /// <summary> + /// The time at which judgement dependent state transforms should be applied. This is equivalent of the (end) time of the object, in addition to any judgement offset. + /// This is used to offset calls to <see cref="UpdateHitStateTransforms"/>. + /// </summary> + public double HitStateUpdateTime => Result?.TimeAbsolute ?? HitObject.GetEndTime(); + /// <summary> /// Will be called at least once after this <see cref="DrawableHitObject"/> has become not alive. /// </summary> @@ -298,18 +592,13 @@ namespace osu.Game.Rulesets.Objects.Drawables foreach (var nested in NestedHitObjects) nested.OnKilled(); + // failsafe to ensure looping samples don't get stuck in a playing state. + // this could occur in a non-frame-stable context where DrawableHitObjects get killed before a SkinnableSound has the chance to be stopped. + StopAllSamples(); + UpdateResult(false); } - protected virtual void AddNested(DrawableHitObject h) - { - h.OnNewResult += (d, r) => OnNewResult?.Invoke(d, r); - h.OnRevertResult += (d, r) => OnRevertResult?.Invoke(d, r); - h.ApplyCustomUpdateState += (d, j) => ApplyCustomUpdateState?.Invoke(d, j); - - nestedHitObjects.Value.Add(h); - } - /// <summary> /// Applies the <see cref="Result"/> of this <see cref="DrawableHitObject"/>, notifying responders such as /// the <see cref="ScoreProcessor"/> of the <see cref="JudgementResult"/>. @@ -317,30 +606,49 @@ namespace osu.Game.Rulesets.Objects.Drawables /// <param name="application">The callback that applies changes to the <see cref="JudgementResult"/>.</param> protected void ApplyResult(Action<JudgementResult> application) { + if (Result.HasResult) + throw new InvalidOperationException("Cannot apply result on a hitobject that already has a result."); + application?.Invoke(Result); if (!Result.HasResult) throw new InvalidOperationException($"{GetType().ReadableName()} applied a {nameof(JudgementResult)} but did not update {nameof(JudgementResult.Type)}."); - judgementOccurred = true; + // 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 2020-03-28 onwards.", level: LogLevel.Important); + } + } + + if (!Result.Type.IsValidHitResult(Result.Judgement.MinResult, Result.Judgement.MaxResult)) + { + throw new InvalidOperationException( + $"{GetType().ReadableName()} applied an invalid hit result (was: {Result.Type}, expected: [{Result.Judgement.MinResult} ... {Result.Judgement.MaxResult}])."); + } // Ensure that the judgement is given a valid time offset, because this may not get set by the caller - var endTime = (HitObject as IHasEndTime)?.EndTime ?? HitObject.StartTime; + var endTime = HitObject.GetEndTime(); + Result.TimeOffset = Time.Current - endTime; - switch (Result.Type) - { - case HitResult.None: - break; + double missWindow = HitObject.HitWindows.WindowFor(HitResult.Miss); + if (missWindow > 0) + Result.TimeOffset = Math.Min(Result.TimeOffset, missWindow); - case HitResult.Miss: - updateState(ArmedState.Miss); - break; - - default: - updateState(ArmedState.Hit); - break; - } + if (Result.HasResult) + updateState(Result.IsHit ? ArmedState.Hit : ArmedState.Miss); OnNewResult?.Invoke(this, Result); } @@ -356,21 +664,13 @@ namespace osu.Game.Rulesets.Objects.Drawables if (Time.Elapsed < 0) return false; - judgementOccurred = false; - - if (AllJudged) + if (Judged) return false; - foreach (var d in NestedHitObjects) - judgementOccurred |= d.UpdateResult(userTriggered); - - if (judgementOccurred || Judged) - return judgementOccurred; - - var endTime = (HitObject as IHasEndTime)?.EndTime ?? HitObject.StartTime; + var endTime = HitObject.GetEndTime(); CheckForResult(userTriggered, Time.Current - endTime); - return judgementOccurred; + return Judged; } /// <summary> @@ -390,18 +690,25 @@ namespace osu.Game.Rulesets.Objects.Drawables /// Creates the <see cref="JudgementResult"/> that represents the scoring result for this <see cref="DrawableHitObject"/>. /// </summary> /// <param name="judgement">The <see cref="Judgement"/> that provides the scoring information.</param> - protected virtual JudgementResult CreateResult(Judgement judgement) => new JudgementResult(judgement); + protected virtual JudgementResult CreateResult(Judgement judgement) => new JudgementResult(HitObject, judgement); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (HitObject != null) + HitObject.DefaultsApplied -= onDefaultsApplied; + } } public abstract class DrawableHitObject<TObject> : DrawableHitObject where TObject : HitObject { - public new readonly TObject HitObject; + public new TObject HitObject => (TObject)base.HitObject; protected DrawableHitObject(TObject hitObject) : base(hitObject) { - HitObject = hitObject; } } } diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index bf04963b76..826d411822 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -1,13 +1,19 @@ // 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. +using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; +using JetBrains.Annotations; using Newtonsoft.Json; +using osu.Framework.Bindables; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Objects { @@ -24,12 +30,23 @@ namespace osu.Game.Rulesets.Objects /// </summary> private const double control_point_leniency = 1; + /// <summary> + /// Invoked after <see cref="ApplyDefaults"/> has completed on this <see cref="HitObject"/>. + /// </summary> + public event Action<HitObject> DefaultsApplied; + + public readonly Bindable<double> StartTimeBindable = new BindableDouble(); + /// <summary> /// The time at which the HitObject starts. /// </summary> - public virtual double StartTime { get; set; } + public virtual double StartTime + { + get => StartTimeBindable.Value; + set => StartTimeBindable.Value = value; + } - private List<HitSampleInfo> samples; + public readonly BindableList<HitSampleInfo> SamplesBindable = new BindableList<HitSampleInfo>(); /// <summary> /// The samples to be played when this hit object is hit. @@ -38,10 +55,14 @@ namespace osu.Game.Rulesets.Objects /// and can be treated as the default samples for the hit object. /// </para> /// </summary> - public List<HitSampleInfo> Samples + public IList<HitSampleInfo> Samples { - get => samples ?? (samples = new List<HitSampleInfo>()); - set => samples = value; + get => SamplesBindable; + set + { + SamplesBindable.Clear(); + SamplesBindable.AddRange(value); + } } [JsonIgnore] @@ -56,6 +77,7 @@ namespace osu.Game.Rulesets.Objects /// <summary> /// The hit windows for this <see cref="HitObject"/>. /// </summary> + [JsonIgnore] public HitWindows HitWindows { get; set; } private readonly List<HitObject> nestedHitObjects = new List<HitObject>(); @@ -63,40 +85,68 @@ namespace osu.Game.Rulesets.Objects [JsonIgnore] public IReadOnlyList<HitObject> NestedHitObjects => nestedHitObjects; + public HitObject() + { + StartTimeBindable.ValueChanged += time => + { + double offset = time.NewValue - time.OldValue; + + foreach (var nested in NestedHitObjects) + nested.StartTime += offset; + }; + } + /// <summary> /// Applies default values to this HitObject. /// </summary> /// <param name="controlPointInfo">The control points.</param> /// <param name="difficulty">The difficulty settings to use.</param> - public void ApplyDefaults(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + /// <param name="cancellationToken">The cancellation token.</param> + public void ApplyDefaults(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty, CancellationToken cancellationToken = default) { ApplyDefaultsToSelf(controlPointInfo, difficulty); // This is done here since ApplyDefaultsToSelf may be used to determine the end time - SampleControlPoint = controlPointInfo.SamplePointAt(((this as IHasEndTime)?.EndTime ?? StartTime) + control_point_leniency); + SampleControlPoint = controlPointInfo.SamplePointAt(this.GetEndTime() + control_point_leniency); nestedHitObjects.Clear(); - CreateNestedHitObjects(); + CreateNestedHitObjects(cancellationToken); + + if (this is IHasComboInformation hasCombo) + { + foreach (var n in NestedHitObjects.OfType<IHasComboInformation>()) + { + n.ComboIndexBindable.BindTo(hasCombo.ComboIndexBindable); + n.IndexInCurrentComboBindable.BindTo(hasCombo.IndexInCurrentComboBindable); + } + } nestedHitObjects.Sort((h1, h2) => h1.StartTime.CompareTo(h2.StartTime)); foreach (var h in nestedHitObjects) - { - h.HitWindows = HitWindows; - h.ApplyDefaults(controlPointInfo, difficulty); - } + h.ApplyDefaults(controlPointInfo, difficulty, cancellationToken); + + DefaultsApplied?.Invoke(this); } protected virtual void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) { Kiai = controlPointInfo.EffectPointAt(StartTime + control_point_leniency).KiaiMode; - if (HitWindows == null) - HitWindows = CreateHitWindows(); + HitWindows ??= CreateHitWindows(); HitWindows?.SetDifficulty(difficulty.OverallDifficulty); } + 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() { } @@ -105,17 +155,31 @@ namespace osu.Game.Rulesets.Objects /// <summary> /// Creates the <see cref="Judgement"/> that represents the scoring information for this <see cref="HitObject"/>. - /// May be null. /// </summary> - public virtual Judgement CreateJudgement() => null; + [NotNull] + public virtual Judgement CreateJudgement() => new Judgement(); /// <summary> /// Creates the <see cref="HitWindows"/> for this <see cref="HitObject"/>. - /// This can be null to indicate that the <see cref="HitObject"/> has no <see cref="HitWindows"/>. + /// This can be null to indicate that the <see cref="HitObject"/> has no <see cref="HitWindows"/> and timing errors should not be displayed to the user. /// <para> /// This will only be invoked if <see cref="HitWindows"/> hasn't been set externally (e.g. from a <see cref="BeatmapConverter{T}"/>. /// </para> /// </summary> + [NotNull] protected virtual HitWindows CreateHitWindows() => new HitWindows(); } + + public static class HitObjectExtensions + { + /// <summary> + /// Returns the end time of this object. + /// </summary> + /// <remarks> + /// This returns the <see cref="IHasDuration.EndTime"/> where available, falling back to <see cref="HitObject.StartTime"/> otherwise. + /// </remarks> + /// <param name="hitObject">The object.</param> + /// <returns>The end time of this object.</returns> + public static double GetEndTime(this HitObject hitObject) => (hitObject as IHasDuration)?.EndTime ?? hitObject.StartTime; + } } diff --git a/osu.Game/Rulesets/Objects/HitWindows.cs b/osu.Game/Rulesets/Objects/HitWindows.cs deleted file mode 100644 index fe099aaee7..0000000000 --- a/osu.Game/Rulesets/Objects/HitWindows.cs +++ /dev/null @@ -1,159 +0,0 @@ -// 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. - -using System; -using System.Collections.Generic; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Objects -{ - public class HitWindows - { - private static readonly IReadOnlyDictionary<HitResult, (double od0, double od5, double od10)> base_ranges = new Dictionary<HitResult, (double, double, double)> - { - { HitResult.Perfect, (44.8, 38.8, 27.8) }, - { HitResult.Great, (128, 98, 68) }, - { HitResult.Good, (194, 164, 134) }, - { HitResult.Ok, (254, 224, 194) }, - { HitResult.Meh, (302, 272, 242) }, - { HitResult.Miss, (376, 346, 316) }, - }; - - /// <summary> - /// Hit window for a <see cref="HitResult.Perfect"/> result. - /// </summary> - public double Perfect { get; protected set; } - - /// <summary> - /// Hit window for a <see cref="HitResult.Great"/> result. - /// </summary> - public double Great { get; protected set; } - - /// <summary> - /// Hit window for a <see cref="HitResult.Good"/> result. - /// </summary> - public double Good { get; protected set; } - - /// <summary> - /// Hit window for an <see cref="HitResult.Ok"/> result. - /// </summary> - public double Ok { get; protected set; } - - /// <summary> - /// Hit window for a <see cref="HitResult.Meh"/> result. - /// </summary> - public double Meh { get; protected set; } - - /// <summary> - /// Hit window for a <see cref="HitResult.Miss"/> result. - /// </summary> - public double Miss { get; protected set; } - - /// <summary> - /// Retrieves the <see cref="HitResult"/> with the largest hit window that produces a successful hit. - /// </summary> - /// <returns>The lowest allowed successful <see cref="HitResult"/>.</returns> - protected HitResult LowestSuccessfulHitResult() - { - for (var result = HitResult.Meh; result <= HitResult.Perfect; ++result) - { - if (IsHitResultAllowed(result)) - return result; - } - - return HitResult.None; - } - - /// <summary> - /// Check whether it is possible to achieve the provided <see cref="HitResult"/>. - /// </summary> - /// <param name="result">The result type to check.</param> - /// <returns>Whether the <see cref="HitResult"/> can be achieved.</returns> - public virtual bool IsHitResultAllowed(HitResult result) - { - switch (result) - { - case HitResult.Perfect: - case HitResult.Ok: - return false; - - default: - return true; - } - } - - /// <summary> - /// Sets hit windows with values that correspond to a difficulty parameter. - /// </summary> - /// <param name="difficulty">The parameter.</param> - public virtual void SetDifficulty(double difficulty) - { - Perfect = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Perfect]); - Great = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Great]); - Good = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Good]); - Ok = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Ok]); - Meh = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Meh]); - Miss = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Miss]); - } - - /// <summary> - /// Retrieves the <see cref="HitResult"/> for a time offset. - /// </summary> - /// <param name="timeOffset">The time offset.</param> - /// <returns>The hit result, or <see cref="HitResult.None"/> if <paramref name="timeOffset"/> doesn't result in a judgement.</returns> - public HitResult ResultFor(double timeOffset) - { - timeOffset = Math.Abs(timeOffset); - - for (var result = HitResult.Perfect; result >= HitResult.Miss; --result) - { - if (IsHitResultAllowed(result) && timeOffset <= HalfWindowFor(result)) - return result; - } - - return HitResult.None; - } - - /// <summary> - /// Retrieves half the hit window for a <see cref="HitResult"/>. - /// This is useful if the hit window for one half of the hittable range of a <see cref="HitObject"/> is required. - /// </summary> - /// <param name="result">The expected <see cref="HitResult"/>.</param> - /// <returns>One half of the hit window for <paramref name="result"/>.</returns> - public double HalfWindowFor(HitResult result) - { - switch (result) - { - case HitResult.Perfect: - return Perfect / 2; - - case HitResult.Great: - return Great / 2; - - case HitResult.Good: - return Good / 2; - - case HitResult.Ok: - return Ok / 2; - - case HitResult.Meh: - return Meh / 2; - - case HitResult.Miss: - return Miss / 2; - - default: - throw new ArgumentException(nameof(result)); - } - } - - /// <summary> - /// Given a time offset, whether the <see cref="HitObject"/> can ever be hit in the future with a non-<see cref="HitResult.Miss"/> result. - /// This happens if <paramref name="timeOffset"/> is less than what is required for <see cref="LowestSuccessfulHitResult"/>. - /// </summary> - /// <param name="timeOffset">The time offset.</param> - /// <returns>Whether the <see cref="HitObject"/> can be hit at any point in the future from this time offset.</returns> - public bool CanBeHit(double timeOffset) => timeOffset <= HalfWindowFor(LowestSuccessfulHitResult()); - } -} diff --git a/osu.Game/Rulesets/Objects/IBarLine.cs b/osu.Game/Rulesets/Objects/IBarLine.cs new file mode 100644 index 0000000000..14df80e3b9 --- /dev/null +++ b/osu.Game/Rulesets/Objects/IBarLine.cs @@ -0,0 +1,22 @@ +// 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. + +namespace osu.Game.Rulesets.Objects +{ + /// <summary> + /// Interface for bar line hitobjects. + /// Used to decouple bar line generation from ruleset-specific rendering/drawing hierarchies. + /// </summary> + public interface IBarLine + { + /// <summary> + /// The time position of the bar. + /// </summary> + double StartTime { set; } + + /// <summary> + /// Whether this bar line is a prominent beat (based on time signature of beatmap). + /// </summary> + bool Major { set; } + } +} diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs index febfd3696c..19722fb796 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs @@ -8,7 +8,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch /// <summary> /// Legacy osu!catch Hit-type, used for parsing Beatmaps. /// </summary> - internal sealed class ConvertHit : HitObject, IHasCombo, IHasXPosition + internal sealed class ConvertHit : ConvertHitObject, IHasCombo, IHasXPosition { public float X { get; set; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs index 71e321f205..c10c8dc30f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs @@ -3,7 +3,6 @@ using osuTK; using osu.Game.Audio; -using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; namespace osu.Game.Rulesets.Objects.Legacy.Catch @@ -37,8 +36,8 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch }; } - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, Vector2[] controlPoints, double? length, PathType pathType, int repeatCount, - List<List<HitSampleInfo>> nodeSamples) + protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, + List<IList<HitSampleInfo>> nodeSamples) { newCombo |= forceNewCombo; comboOffset += extraComboOffset; @@ -51,13 +50,13 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch X = position.X, NewCombo = FirstObject || newCombo, ComboOffset = comboOffset, - Path = new SliderPath(pathType, controlPoints, length), + Path = new SliderPath(controlPoints, length), NodeSamples = nodeSamples, RepeatCount = repeatCount }; } - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) { // Convert spinners don't create the new combo themselves, but force the next non-spinner hitobject to create a new combo // Their combo offset is still added to that next hitobject's combo index @@ -66,11 +65,11 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch return new ConvertSpinner { - EndTime = endTime + Duration = duration }; } - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) { return null; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs index 0089d1eb88..014494ec54 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSpinner.cs @@ -8,11 +8,11 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch /// <summary> /// Legacy osu!catch Spinner-type, used for parsing Beatmaps. /// </summary> - internal sealed class ConvertSpinner : HitObject, IHasEndTime, IHasXPosition, IHasCombo + internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasXPosition, IHasCombo { - public double EndTime { get; set; } + public double EndTime => StartTime + Duration; - public double Duration => EndTime - StartTime; + public double Duration { get; set; } public float X => 256; // Required for CatchBeatmapConverter diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs new file mode 100644 index 0000000000..e3b0d8a498 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObject.cs @@ -0,0 +1,18 @@ +// 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. + +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Objects.Legacy +{ + /// <summary> + /// A hit object only used for conversion, not actual gameplay. + /// </summary> + internal abstract class ConvertHitObject : HitObject + { + public override Judgement CreateJudgement() => new IgnoreJudgement(); + + protected override HitWindows CreateHitWindows() => HitWindows.Empty; + } +} diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index d70c1bf7d3..44b22033dc 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -10,8 +10,9 @@ using osu.Game.Beatmaps.Formats; using osu.Game.Audio; using System.Linq; using JetBrains.Annotations; -using osu.Framework.Logging; -using osu.Framework.MathUtils; +using osu.Framework.Utils; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Skinning; namespace osu.Game.Rulesets.Objects.Legacy { @@ -41,208 +42,140 @@ namespace osu.Game.Rulesets.Objects.Legacy [CanBeNull] public override HitObject Parse(string text) { - try + string[] split = text.Split(','); + + Vector2 pos = new Vector2((int)Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE)); + + double startTime = Parsing.ParseDouble(split[2]) + Offset; + + LegacyHitObjectType type = (LegacyHitObjectType)Parsing.ParseInt(split[3]); + + int comboOffset = (int)(type & LegacyHitObjectType.ComboOffset) >> 4; + type &= ~LegacyHitObjectType.ComboOffset; + + bool combo = type.HasFlag(LegacyHitObjectType.NewCombo); + type &= ~LegacyHitObjectType.NewCombo; + + var soundType = (LegacyHitSoundType)Parsing.ParseInt(split[4]); + var bankInfo = new SampleBankInfo(); + + HitObject result = null; + + if (type.HasFlag(LegacyHitObjectType.Circle)) { - string[] split = text.Split(','); + result = CreateHit(pos, combo, comboOffset); - Vector2 pos = new Vector2((int)Parsing.ParseFloat(split[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE)); - - double startTime = Parsing.ParseDouble(split[2]) + Offset; - - ConvertHitObjectType type = (ConvertHitObjectType)Parsing.ParseInt(split[3]); - - int comboOffset = (int)(type & ConvertHitObjectType.ComboOffset) >> 4; - type &= ~ConvertHitObjectType.ComboOffset; - - bool combo = type.HasFlag(ConvertHitObjectType.NewCombo); - type &= ~ConvertHitObjectType.NewCombo; - - var soundType = (LegacySoundType)Parsing.ParseInt(split[4]); - var bankInfo = new SampleBankInfo(); - - HitObject result = null; - - if (type.HasFlag(ConvertHitObjectType.Circle)) - { - result = CreateHit(pos, combo, comboOffset); - - if (split.Length > 5) - readCustomSampleBanks(split[5], bankInfo); - } - else if (type.HasFlag(ConvertHitObjectType.Slider)) - { - PathType pathType = PathType.Catmull; - double? length = null; - - string[] pointSplit = split[5].Split('|'); - - int pointCount = 1; - foreach (var t in pointSplit) - if (t.Length > 1) - pointCount++; - - var points = new Vector2[pointCount]; - - int pointIndex = 1; - - foreach (string t in pointSplit) - { - if (t.Length == 1) - { - switch (t) - { - case @"C": - pathType = PathType.Catmull; - break; - - case @"B": - pathType = PathType.Bezier; - break; - - case @"L": - pathType = PathType.Linear; - break; - - case @"P": - pathType = PathType.PerfectCurve; - break; - } - - continue; - } - - string[] temp = t.Split(':'); - points[pointIndex++] = new Vector2((int)Parsing.ParseDouble(temp[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseDouble(temp[1], Parsing.MAX_COORDINATE_VALUE)) - pos; - } - - // osu-stable special-cased colinear perfect curves to a CurveType.Linear - bool isLinear(Vector2[] p) => Precision.AlmostEquals(0, (p[1].Y - p[0].Y) * (p[2].X - p[0].X) - (p[1].X - p[0].X) * (p[2].Y - p[0].Y)); - - if (points.Length == 3 && pathType == PathType.PerfectCurve && isLinear(points)) - pathType = PathType.Linear; - - int repeatCount = Parsing.ParseInt(split[6]); - - if (repeatCount > 9000) - throw new ArgumentOutOfRangeException(nameof(repeatCount), @"Repeat count is way too high"); - - // osu-stable treated the first span of the slider as a repeat, but no repeats are happening - repeatCount = Math.Max(0, repeatCount - 1); - - if (split.Length > 7) - { - length = Math.Max(0, Parsing.ParseDouble(split[7])); - if (length == 0) - length = null; - } - - if (split.Length > 10) - readCustomSampleBanks(split[10], bankInfo); - - // One node for each repeat + the start and end nodes - int nodes = repeatCount + 2; - - // Populate node sample bank infos with the default hit object sample bank - var nodeBankInfos = new List<SampleBankInfo>(); - for (int i = 0; i < nodes; i++) - nodeBankInfos.Add(bankInfo.Clone()); - - // Read any per-node sample banks - if (split.Length > 9 && split[9].Length > 0) - { - string[] sets = split[9].Split('|'); - - for (int i = 0; i < nodes; i++) - { - if (i >= sets.Length) - break; - - SampleBankInfo info = nodeBankInfos[i]; - readCustomSampleBanks(sets[i], info); - } - } - - // Populate node sound types with the default hit object sound type - var nodeSoundTypes = new List<LegacySoundType>(); - for (int i = 0; i < nodes; i++) - nodeSoundTypes.Add(soundType); - - // Read any per-node sound types - if (split.Length > 8 && split[8].Length > 0) - { - string[] adds = split[8].Split('|'); - - for (int i = 0; i < nodes; i++) - { - if (i >= adds.Length) - break; - - int sound; - int.TryParse(adds[i], out sound); - nodeSoundTypes[i] = (LegacySoundType)sound; - } - } - - // Generate the final per-node samples - var nodeSamples = new List<List<HitSampleInfo>>(nodes); - for (int i = 0; i < nodes; i++) - nodeSamples.Add(convertSoundType(nodeSoundTypes[i], nodeBankInfos[i])); - - result = CreateSlider(pos, combo, comboOffset, points, length, pathType, repeatCount, nodeSamples); - - // The samples are played when the slider ends, which is the last node - result.Samples = nodeSamples[nodeSamples.Count - 1]; - } - else if (type.HasFlag(ConvertHitObjectType.Spinner)) - { - double endTime = Math.Max(startTime, Parsing.ParseDouble(split[5]) + Offset); - - result = CreateSpinner(new Vector2(512, 384) / 2, combo, comboOffset, endTime); - - if (split.Length > 6) - readCustomSampleBanks(split[6], bankInfo); - } - else if (type.HasFlag(ConvertHitObjectType.Hold)) - { - // Note: Hold is generated by BMS converts - - double endTime = Math.Max(startTime, Parsing.ParseDouble(split[2])); - - if (split.Length > 5 && !string.IsNullOrEmpty(split[5])) - { - string[] ss = split[5].Split(':'); - endTime = Math.Max(startTime, Parsing.ParseDouble(ss[0])); - readCustomSampleBanks(string.Join(":", ss.Skip(1)), bankInfo); - } - - result = CreateHold(pos, combo, comboOffset, endTime + Offset); - } - - if (result == null) - { - Logger.Log($"Unknown hit object type: {type}. Skipped.", level: LogLevel.Error); - return null; - } - - result.StartTime = startTime; - - if (result.Samples.Count == 0) - result.Samples = convertSoundType(soundType, bankInfo); - - FirstObject = false; - - return result; + if (split.Length > 5) + readCustomSampleBanks(split[5], bankInfo); } - catch (FormatException) + else if (type.HasFlag(LegacyHitObjectType.Slider)) { - Logger.Log("A hitobject could not be parsed correctly and will be ignored", LoggingTarget.Runtime, LogLevel.Important); + double? length = null; + + int repeatCount = Parsing.ParseInt(split[6]); + + if (repeatCount > 9000) + throw new FormatException(@"Repeat count is way too high"); + + // osu-stable treated the first span of the slider as a repeat, but no repeats are happening + repeatCount = Math.Max(0, repeatCount - 1); + + if (split.Length > 7) + { + length = Math.Max(0, Parsing.ParseDouble(split[7], Parsing.MAX_COORDINATE_VALUE)); + if (length == 0) + length = null; + } + + if (split.Length > 10) + readCustomSampleBanks(split[10], bankInfo); + + // One node for each repeat + the start and end nodes + int nodes = repeatCount + 2; + + // Populate node sample bank infos with the default hit object sample bank + var nodeBankInfos = new List<SampleBankInfo>(); + for (int i = 0; i < nodes; i++) + nodeBankInfos.Add(bankInfo.Clone()); + + // Read any per-node sample banks + if (split.Length > 9 && split[9].Length > 0) + { + string[] sets = split[9].Split('|'); + + for (int i = 0; i < nodes; i++) + { + if (i >= sets.Length) + break; + + SampleBankInfo info = nodeBankInfos[i]; + readCustomSampleBanks(sets[i], info); + } + } + + // Populate node sound types with the default hit object sound type + var nodeSoundTypes = new List<LegacyHitSoundType>(); + for (int i = 0; i < nodes; i++) + nodeSoundTypes.Add(soundType); + + // Read any per-node sound types + if (split.Length > 8 && split[8].Length > 0) + { + string[] adds = split[8].Split('|'); + + for (int i = 0; i < nodes; i++) + { + if (i >= adds.Length) + break; + + int.TryParse(adds[i], out var sound); + nodeSoundTypes[i] = (LegacyHitSoundType)sound; + } + } + + // Generate the final per-node samples + var nodeSamples = new List<IList<HitSampleInfo>>(nodes); + for (int i = 0; i < nodes; i++) + nodeSamples.Add(convertSoundType(nodeSoundTypes[i], nodeBankInfos[i])); + + result = CreateSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples); } - catch (OverflowException) + else if (type.HasFlag(LegacyHitObjectType.Spinner)) { - Logger.Log("A hitobject could not be parsed correctly and will be ignored", LoggingTarget.Runtime, LogLevel.Important); + double duration = Math.Max(0, Parsing.ParseDouble(split[5]) + Offset - startTime); + + result = CreateSpinner(new Vector2(512, 384) / 2, combo, comboOffset, duration); + + if (split.Length > 6) + readCustomSampleBanks(split[6], bankInfo); + } + else if (type.HasFlag(LegacyHitObjectType.Hold)) + { + // Note: Hold is generated by BMS converts + + double endTime = Math.Max(startTime, Parsing.ParseDouble(split[2])); + + if (split.Length > 5 && !string.IsNullOrEmpty(split[5])) + { + string[] ss = split[5].Split(':'); + endTime = Math.Max(startTime, Parsing.ParseDouble(ss[0])); + readCustomSampleBanks(string.Join(':', ss.Skip(1)), bankInfo); + } + + result = CreateHold(pos, combo, comboOffset, endTime + Offset - startTime); } - return null; + if (result == null) + throw new InvalidDataException($"Unknown hit object type: {split[3]}"); + + result.StartTime = startTime; + + if (result.Samples.Count == 0) + result.Samples = convertSoundType(soundType, bankInfo); + + FirstObject = false; + + return result; } private void readCustomSampleBanks(string str, SampleBankInfo bankInfo) @@ -252,8 +185,8 @@ namespace osu.Game.Rulesets.Objects.Legacy string[] split = str.Split(':'); - var bank = (LegacyBeatmapDecoder.LegacySampleBank)Parsing.ParseInt(split[0]); - var addbank = (LegacyBeatmapDecoder.LegacySampleBank)Parsing.ParseInt(split[1]); + var bank = (LegacySampleBank)Parsing.ParseInt(split[0]); + var addbank = (LegacySampleBank)Parsing.ParseInt(split[1]); string stringBank = bank.ToString().ToLowerInvariant(); if (stringBank == @"none") @@ -274,6 +207,179 @@ namespace osu.Game.Rulesets.Objects.Legacy bankInfo.Filename = split.Length > 4 ? split[4] : null; } + private PathType convertPathType(string input) + { + switch (input[0]) + { + default: + case 'C': + return PathType.Catmull; + + case 'B': + return PathType.Bezier; + + case 'L': + return PathType.Linear; + + case 'P': + return PathType.PerfectCurve; + } + } + + /// <summary> + /// Converts a given point string into a set of path control points. + /// </summary> + /// <remarks> + /// A point string takes the form: X|1:1|2:2|2:2|3:3|Y|1:1|2:2. + /// This has three segments: + /// <list type="number"> + /// <item> + /// <description>X: { (1,1), (2,2) } (implicit segment)</description> + /// </item> + /// <item> + /// <description>X: { (2,2), (3,3) } (implicit segment)</description> + /// </item> + /// <item> + /// <description>Y: { (3,3), (1,1), (2, 2) } (explicit segment)</description> + /// </item> + /// </list> + /// </remarks> + /// <param name="pointString">The point string.</param> + /// <param name="offset">The positional offset to apply to the control points.</param> + /// <returns>All control points in the resultant path.</returns> + private PathControlPoint[] convertPathString(string pointString, Vector2 offset) + { + // This code takes on the responsibility of handling explicit segments of the path ("X" & "Y" from above). Implicit segments are handled by calls to convertPoints(). + string[] pointSplit = pointString.Split('|'); + + var controlPoints = new List<Memory<PathControlPoint>>(); + int startIndex = 0; + int endIndex = 0; + bool first = true; + + while (++endIndex < pointSplit.Length) + { + // Keep incrementing endIndex while it's not the start of a new segment (indicated by having a type descriptor of length 1). + if (pointSplit[endIndex].Length > 1) + continue; + + // Multi-segmented sliders DON'T contain the end point as part of the current segment as it's assumed to be the start of the next segment. + // The start of the next segment is the index after the type descriptor. + string endPoint = endIndex < pointSplit.Length - 1 ? pointSplit[endIndex + 1] : null; + + controlPoints.AddRange(convertPoints(pointSplit.AsMemory().Slice(startIndex, endIndex - startIndex), endPoint, first, offset)); + startIndex = endIndex; + first = false; + } + + if (endIndex > startIndex) + controlPoints.AddRange(convertPoints(pointSplit.AsMemory().Slice(startIndex, endIndex - startIndex), null, first, offset)); + + return mergePointsLists(controlPoints); + } + + /// <summary> + /// Converts a given point list into a set of path segments. + /// </summary> + /// <param name="points">The point list.</param> + /// <param name="endPoint">Any extra endpoint to consider as part of the points. This will NOT be returned.</param> + /// <param name="first">Whether this is the first segment in the set. If <c>true</c> the first of the returned segments will contain a zero point.</param> + /// <param name="offset">The positional offset to apply to the control points.</param> + /// <returns>The set of points contained by <paramref name="points"/> as one or more segments of the path, prepended by an extra zero point if <paramref name="first"/> is <c>true</c>.</returns> + private IEnumerable<Memory<PathControlPoint>> convertPoints(ReadOnlyMemory<string> points, string endPoint, bool first, Vector2 offset) + { + PathType type = convertPathType(points.Span[0]); + + int readOffset = first ? 1 : 0; // First control point is zero for the first segment. + int readablePoints = points.Length - 1; // Total points readable from the base point span. + int endPointLength = endPoint != null ? 1 : 0; // Extra length if an endpoint is given that lies outside the base point span. + + var vertices = new PathControlPoint[readOffset + readablePoints + endPointLength]; + + // Fill any non-read points. + for (int i = 0; i < readOffset; i++) + vertices[i] = new PathControlPoint(); + + // Parse into control points. + for (int i = 1; i < points.Length; i++) + readPoint(points.Span[i], offset, out vertices[readOffset + i - 1]); + + // If an endpoint is given, add it to the end. + if (endPoint != null) + readPoint(endPoint, offset, out vertices[^1]); + + // Edge-case rules (to match stable). + if (type == PathType.PerfectCurve) + { + if (vertices.Length != 3) + type = PathType.Bezier; + else if (isLinear(vertices)) + { + // osu-stable special-cased colinear perfect curves to a linear path + type = PathType.Linear; + } + } + + // The first control point must have a definite type. + vertices[0].Type.Value = type; + + // A path can have multiple implicit segments of the same type if there are two sequential control points with the same position. + // To handle such cases, this code may return multiple path segments with the final control point in each segment having a non-null type. + // For the point string X|1:1|2:2|2:2|3:3, this code returns the segments: + // X: { (1,1), (2, 2) } + // X: { (3, 3) } + // Note: (2, 2) is not returned in the second segments, as it is implicit in the path. + int startIndex = 0; + int endIndex = 0; + + while (++endIndex < vertices.Length - endPointLength) + { + if (vertices[endIndex].Position.Value != vertices[endIndex - 1].Position.Value) + 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); + + // Skip the current control point - as it's the same as the one that's just been returned. + startIndex = endIndex + 1; + } + + if (endIndex > startIndex) + yield return vertices.AsMemory().Slice(startIndex, endIndex - startIndex); + + static void readPoint(string value, Vector2 startPos, out PathControlPoint point) + { + string[] vertexSplit = value.Split(':'); + + Vector2 pos = new Vector2((int)Parsing.ParseDouble(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseDouble(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE)) - startPos; + point = new PathControlPoint { Position = { Value = pos } }; + } + + static bool isLinear(PathControlPoint[] p) => Precision.AlmostEquals(0, (p[1].Position.Value.Y - p[0].Position.Value.Y) * (p[2].Position.Value.X - p[0].Position.Value.X) + - (p[1].Position.Value.X - p[0].Position.Value.X) * (p[2].Position.Value.Y - p[0].Position.Value.Y)); + } + + private PathControlPoint[] mergePointsLists(List<Memory<PathControlPoint>> controlPointList) + { + int totalCount = 0; + + foreach (var arr in controlPointList) + totalCount += arr.Length; + + var mergedArray = new PathControlPoint[totalCount]; + var mergedArrayMemory = mergedArray.AsMemory(); + int copyIndex = 0; + + foreach (var arr in controlPointList) + { + arr.CopyTo(mergedArrayMemory.Slice(copyIndex)); + copyIndex += arr.Length; + } + + return mergedArray; + } + /// <summary> /// Creates a legacy Hit-type hit object. /// </summary> @@ -291,12 +397,11 @@ namespace osu.Game.Rulesets.Objects.Legacy /// <param name="comboOffset">When starting a new combo, the offset of the new combo relative to the current one.</param> /// <param name="controlPoints">The slider control points.</param> /// <param name="length">The slider length.</param> - /// <param name="pathType">The slider curve type.</param> /// <param name="repeatCount">The slider repeat count.</param> /// <param name="nodeSamples">The samples to be played when the slider nodes are hit. This includes the head and tail of the slider.</param> /// <returns>The hit object.</returns> - protected abstract HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, Vector2[] controlPoints, double? length, PathType pathType, int repeatCount, - List<List<HitSampleInfo>> nodeSamples); + protected abstract HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, + List<IList<HitSampleInfo>> nodeSamples); /// <summary> /// Creates a legacy Spinner-type hit object. @@ -304,9 +409,9 @@ namespace osu.Game.Rulesets.Objects.Legacy /// <param name="position">The position of the hit object.</param> /// <param name="newCombo">Whether the hit object creates a new combo.</param> /// <param name="comboOffset">When starting a new combo, the offset of the new combo relative to the current one.</param> - /// <param name="endTime">The spinner end time.</param> + /// <param name="duration">The spinner duration.</param> /// <returns>The hit object.</returns> - protected abstract HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double endTime); + protected abstract HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration); /// <summary> /// Creates a legacy Hold-type hit object. @@ -314,10 +419,10 @@ namespace osu.Game.Rulesets.Objects.Legacy /// <param name="position">The position of the hit object.</param> /// <param name="newCombo">Whether the hit object creates a new combo.</param> /// <param name="comboOffset">When starting a new combo, the offset of the new combo relative to the current one.</param> - /// <param name="endTime">The hold end time.</param> - protected abstract HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double endTime); + /// <param name="duration">The hold duration.</param> + protected abstract HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration); - private List<HitSampleInfo> convertSoundType(LegacySoundType type, SampleBankInfo bankInfo) + private List<HitSampleInfo> convertSoundType(LegacyHitSoundType type, SampleBankInfo bankInfo) { // Todo: This should return the normal SampleInfos if the specified sample file isn't found, but that's a pretty edge-case scenario if (!string.IsNullOrEmpty(bankInfo.Filename)) @@ -339,11 +444,14 @@ namespace osu.Game.Rulesets.Objects.Legacy Bank = bankInfo.Normal, Name = HitSampleInfo.HIT_NORMAL, Volume = bankInfo.Volume, - CustomSampleBank = bankInfo.CustomSampleBank + CustomSampleBank = 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 + IsLayered = type != LegacyHitSoundType.None && !type.HasFlag(LegacyHitSoundType.Normal) } }; - if (type.HasFlag(LegacySoundType.Finish)) + if (type.HasFlag(LegacyHitSoundType.Finish)) { soundTypes.Add(new LegacyHitSampleInfo { @@ -354,7 +462,7 @@ namespace osu.Game.Rulesets.Objects.Legacy }); } - if (type.HasFlag(LegacySoundType.Whistle)) + if (type.HasFlag(LegacyHitSoundType.Whistle)) { soundTypes.Add(new LegacyHitSampleInfo { @@ -365,7 +473,7 @@ namespace osu.Game.Rulesets.Objects.Legacy }); } - if (type.HasFlag(LegacySoundType.Clap)) + if (type.HasFlag(LegacyHitSoundType.Clap)) { soundTypes.Add(new LegacyHitSampleInfo { @@ -392,37 +500,48 @@ namespace osu.Game.Rulesets.Objects.Legacy public SampleBankInfo Clone() => (SampleBankInfo)MemberwiseClone(); } - private class LegacyHitSampleInfo : HitSampleInfo + public class LegacyHitSampleInfo : HitSampleInfo { + private int customSampleBank; + public int CustomSampleBank { + get => customSampleBank; set { - if (value > 1) + customSampleBank = value; + + if (value >= 2) Suffix = value.ToString(); } } + + /// <summary> + /// Whether this hit sample is layered. + /// </summary> + /// <remarks> + /// Layered hit samples are automatically added in all modes (except osu!mania), but can be disabled + /// using the <see cref="LegacySkinConfiguration.LegacySetting.LayeredHitSounds"/> skin config option. + /// </remarks> + public bool IsLayered { get; set; } } - private class FileHitSampleInfo : HitSampleInfo + private class FileHitSampleInfo : LegacyHitSampleInfo { public string Filename; + public FileHitSampleInfo() + { + // Make sure that the LegacyBeatmapSkin does not fall back to the user skin. + // Note that this does not change the lookup names, as they are overridden locally. + CustomSampleBank = 1; + } + public override IEnumerable<string> LookupNames => new[] { Filename, Path.ChangeExtension(Filename, null) }; } - - [Flags] - private enum LegacySoundType - { - None = 0, - Normal = 1, - Whistle = 2, - Finish = 4, - Clap = 8 - } } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index ff6b9be8b5..36b421586e 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -3,13 +3,17 @@ using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; +using Newtonsoft.Json; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Rulesets.Objects.Legacy { - internal abstract class ConvertSlider : HitObject, IHasCurve, IHasLegacyLastTickOffset + internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasLegacyLastTickOffset, +#pragma warning disable 618 + IHasCurve +#pragma warning restore 618 { /// <summary> /// Scoring distance with a speed-adjusted beat length of 1 second. @@ -23,11 +27,17 @@ namespace osu.Game.Rulesets.Objects.Legacy public double Distance => Path.Distance; - public List<List<HitSampleInfo>> NodeSamples { get; set; } + public List<IList<HitSampleInfo>> NodeSamples { get; set; } public int RepeatCount { get; set; } - public double EndTime => StartTime + this.SpanCount() * Distance / Velocity; - public double Duration => EndTime - StartTime; + [JsonIgnore] + public double Duration + { + get => this.SpanCount() * Distance / Velocity; + set => throw new System.NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed. + } + + public double EndTime => StartTime + Duration; public double Velocity = 1; diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs index 06fde576d2..0b69817c13 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs @@ -8,10 +8,8 @@ namespace osu.Game.Rulesets.Objects.Legacy.Mania /// <summary> /// Legacy osu!mania Hit-type, used for parsing Beatmaps. /// </summary> - internal sealed class ConvertHit : HitObject, IHasXPosition + internal sealed class ConvertHit : ConvertHitObject, IHasXPosition { public float X { get; set; } - - protected override HitWindows CreateHitWindows() => null; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs index 94aba95e90..bc64518f40 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHitObjectParser.cs @@ -3,7 +3,6 @@ using osuTK; using osu.Game.Audio; -using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; namespace osu.Game.Rulesets.Objects.Legacy.Mania @@ -26,33 +25,33 @@ namespace osu.Game.Rulesets.Objects.Legacy.Mania }; } - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, Vector2[] controlPoints, double? length, PathType pathType, int repeatCount, - List<List<HitSampleInfo>> nodeSamples) + protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, + List<IList<HitSampleInfo>> nodeSamples) { return new ConvertSlider { X = position.X, - Path = new SliderPath(pathType, controlPoints, length), + Path = new SliderPath(controlPoints, length), NodeSamples = nodeSamples, RepeatCount = repeatCount }; } - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) { return new ConvertSpinner { X = position.X, - EndTime = endTime + Duration = duration }; } - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) { return new ConvertHold { X = position.X, - EndTime = endTime + Duration = duration }; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs index 096c07f7d2..2fa4766c1d 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHold.cs @@ -5,14 +5,12 @@ using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy.Mania { - internal sealed class ConvertHold : HitObject, IHasXPosition, IHasEndTime + internal sealed class ConvertHold : ConvertHitObject, IHasXPosition, IHasDuration { public float X { get; set; } - public double EndTime { get; set; } + public double Duration { get; set; } - public double Duration => EndTime - StartTime; - - protected override HitWindows CreateHitWindows() => null; + public double EndTime => StartTime + Duration; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs index 226d91bb86..84cde5fa95 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs @@ -11,7 +11,5 @@ namespace osu.Game.Rulesets.Objects.Legacy.Mania internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasXPosition { public float X { get; set; } - - protected override HitWindows CreateHitWindows() => null; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs index eb20fa67f1..c05aaceb9c 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSpinner.cs @@ -8,14 +8,12 @@ namespace osu.Game.Rulesets.Objects.Legacy.Mania /// <summary> /// Legacy osu!mania Spinner-type, used for parsing Beatmaps. /// </summary> - internal sealed class ConvertSpinner : HitObject, IHasEndTime, IHasXPosition + internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasXPosition { - public double EndTime { get; set; } + public double Duration { get; set; } - public double Duration => EndTime - StartTime; + public double EndTime => StartTime + Duration; public float X { get; set; } - - protected override HitWindows CreateHitWindows() => null; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs index 84b66a4c26..069366bad3 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHit.cs @@ -9,7 +9,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu /// <summary> /// Legacy osu! Hit-type, used for parsing Beatmaps. /// </summary> - internal sealed class ConvertHit : HitObject, IHasPosition, IHasCombo + internal sealed class ConvertHit : ConvertHitObject, IHasPosition, IHasCombo { public Vector2 Position { get; set; } @@ -20,7 +20,5 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu public bool NewCombo { get; set; } public int ComboOffset { get; set; } - - protected override HitWindows CreateHitWindows() => null; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs index 65102f1e89..75ecab0b8f 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertHitObjectParser.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osuTK; -using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; using osu.Game.Audio; @@ -37,8 +36,8 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu }; } - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, Vector2[] controlPoints, double? length, PathType pathType, int repeatCount, - List<List<HitSampleInfo>> nodeSamples) + protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, + List<IList<HitSampleInfo>> nodeSamples) { newCombo |= forceNewCombo; comboOffset += extraComboOffset; @@ -51,13 +50,13 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu Position = position, NewCombo = FirstObject || newCombo, ComboOffset = comboOffset, - Path = new SliderPath(pathType, controlPoints, length), + Path = new SliderPath(controlPoints, length), NodeSamples = nodeSamples, RepeatCount = repeatCount }; } - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) { // Convert spinners don't create the new combo themselves, but force the next non-spinner hitobject to create a new combo // Their combo offset is still added to that next hitobject's combo index @@ -67,11 +66,11 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu return new ConvertSpinner { Position = position, - EndTime = endTime + Duration = duration }; } - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) { return null; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs index c850feb189..e947690668 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSlider.cs @@ -20,7 +20,5 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu public bool NewCombo { get; set; } public int ComboOffset { get; set; } - - protected override HitWindows CreateHitWindows() => null; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs index e5a8884aa2..e9e5ca8c94 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Osu/ConvertSpinner.cs @@ -9,11 +9,11 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu /// <summary> /// Legacy osu! Spinner-type, used for parsing Beatmaps. /// </summary> - internal sealed class ConvertSpinner : HitObject, IHasEndTime, IHasPosition, IHasCombo + internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration, IHasPosition, IHasCombo { - public double EndTime { get; set; } + public double Duration { get; set; } - public double Duration => EndTime - StartTime; + public double EndTime => StartTime + Duration; public Vector2 Position { get; set; } @@ -21,8 +21,6 @@ namespace osu.Game.Rulesets.Objects.Legacy.Osu public float Y => Position.Y; - protected override HitWindows CreateHitWindows() => null; - public bool NewCombo { get; set; } public int ComboOffset { get; set; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs index 5cecc2a59f..cb5178ce48 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs @@ -6,8 +6,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Taiko /// <summary> /// Legacy osu!taiko Hit-type, used for parsing Beatmaps. /// </summary> - internal sealed class ConvertHit : HitObject + internal sealed class ConvertHit : ConvertHitObject { - protected override HitWindows CreateHitWindows() => null; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs index eb598f1368..13e3e84c6a 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHitObjectParser.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osuTK; -using osu.Game.Rulesets.Objects.Types; using System.Collections.Generic; using osu.Game.Audio; @@ -23,26 +22,26 @@ namespace osu.Game.Rulesets.Objects.Legacy.Taiko return new ConvertHit(); } - protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, Vector2[] controlPoints, double? length, PathType pathType, int repeatCount, - List<List<HitSampleInfo>> nodeSamples) + protected override HitObject CreateSlider(Vector2 position, bool newCombo, int comboOffset, PathControlPoint[] controlPoints, double? length, int repeatCount, + List<IList<HitSampleInfo>> nodeSamples) { return new ConvertSlider { - Path = new SliderPath(pathType, controlPoints, length), + Path = new SliderPath(controlPoints, length), NodeSamples = nodeSamples, RepeatCount = repeatCount }; } - protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateSpinner(Vector2 position, bool newCombo, int comboOffset, double duration) { return new ConvertSpinner { - EndTime = endTime + Duration = duration }; } - protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double endTime) + protected override HitObject CreateHold(Vector2 position, bool newCombo, int comboOffset, double duration) { return null; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs index 5cedc6e2e5..821554f7ee 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs @@ -8,6 +8,5 @@ namespace osu.Game.Rulesets.Objects.Legacy.Taiko /// </summary> internal sealed class ConvertSlider : Legacy.ConvertSlider { - protected override HitWindows CreateHitWindows() => null; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs index ca9fdd53ed..1d5ecb1ef3 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSpinner.cs @@ -8,12 +8,10 @@ namespace osu.Game.Rulesets.Objects.Legacy.Taiko /// <summary> /// Legacy osu!taiko Spinner-type, used for parsing Beatmaps. /// </summary> - internal sealed class ConvertSpinner : HitObject, IHasEndTime + internal sealed class ConvertSpinner : ConvertHitObject, IHasDuration { - public double EndTime { get; set; } + public double Duration { get; set; } - public double Duration => EndTime - StartTime; - - protected override HitWindows CreateHitWindows() => null; + public double EndTime => StartTime + Duration; } } diff --git a/osu.Game/Rulesets/Objects/PathControlPoint.cs b/osu.Game/Rulesets/Objects/PathControlPoint.cs new file mode 100644 index 0000000000..f11917f4f4 --- /dev/null +++ b/osu.Game/Rulesets/Objects/PathControlPoint.cs @@ -0,0 +1,55 @@ +// 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. + +using System; +using Newtonsoft.Json; +using osu.Framework.Bindables; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.Objects +{ + public class PathControlPoint : IEquatable<PathControlPoint> + { + /// <summary> + /// The position of this <see cref="PathControlPoint"/>. + /// </summary> + [JsonProperty] + public readonly Bindable<Vector2> Position = new Bindable<Vector2>(); + + /// <summary> + /// The type of path segment starting at this <see cref="PathControlPoint"/>. + /// If null, this <see cref="PathControlPoint"/> will be a part of the previous path segment. + /// </summary> + [JsonProperty] + public readonly Bindable<PathType?> Type = new Bindable<PathType?>(); + + /// <summary> + /// Invoked when any property of this <see cref="PathControlPoint"/> is changed. + /// </summary> + internal event Action Changed; + + /// <summary> + /// Creates a new <see cref="PathControlPoint"/>. + /// </summary> + public PathControlPoint() + { + Position.ValueChanged += _ => Changed?.Invoke(); + Type.ValueChanged += _ => Changed?.Invoke(); + } + + /// <summary> + /// Creates a new <see cref="PathControlPoint"/> with a provided position and type. + /// </summary> + /// <param name="position">The initial position.</param> + /// <param name="type">The initial type.</param> + public PathControlPoint(Vector2 position, PathType? type = null) + : this() + { + Position.Value = position; + Type.Value = type; + } + + public bool Equals(PathControlPoint other) => Position.Value == other?.Position.Value && Type.Value == other.Type.Value; + } +} diff --git a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs index 7cda7485f9..d8c6da86f9 100644 --- a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs +++ b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs @@ -3,20 +3,31 @@ using System; using System.Collections.Generic; -using osuTK; +using System.Linq; +using System.Threading; namespace osu.Game.Rulesets.Objects { public static class SliderEventGenerator { - public static IEnumerable<SliderEventDescriptor> Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, double? legacyLastTickOffset) + [Obsolete("Use the overload with cancellation support instead.")] // can be removed 20201115 + // ReSharper disable once RedundantOverload.Global + public static IEnumerable<SliderEventDescriptor> Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, + double? legacyLastTickOffset) + { + return Generate(startTime, spanDuration, velocity, tickDistance, totalDistance, spanCount, legacyLastTickOffset, default); + } + + // ReSharper disable once MethodOverloadWithOptionalParameter + public static IEnumerable<SliderEventDescriptor> Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, + double? legacyLastTickOffset, CancellationToken cancellationToken = default) { // A very lenient maximum length of a slider for ticks to be generated. // This exists for edge cases such as /b/1573664 where the beatmap has been edited by the user, and should never be reached in normal usage. const double max_length = 100000; var length = Math.Min(max_length, totalDistance); - tickDistance = MathHelper.Clamp(tickDistance, 0, length); + tickDistance = Math.Clamp(tickDistance, 0, length); var minDistanceFromEnd = velocity * 10; @@ -36,24 +47,17 @@ namespace osu.Game.Rulesets.Objects var spanStartTime = startTime + span * spanDuration; var reversed = span % 2 == 1; - for (var d = tickDistance; d <= length; d += tickDistance) + var ticks = generateTicks(span, spanStartTime, spanDuration, reversed, length, tickDistance, minDistanceFromEnd, cancellationToken); + + if (reversed) { - if (d >= length - minDistanceFromEnd) - break; - - var pathProgress = d / length; - var timeProgress = reversed ? 1 - pathProgress : pathProgress; - - yield return new SliderEventDescriptor - { - Type = SliderEventType.Tick, - SpanIndex = span, - SpanStartTime = spanStartTime, - Time = spanStartTime + timeProgress * spanDuration, - PathProgress = pathProgress, - }; + // For repeat spans, ticks are returned in reverse-StartTime order, which is undesirable for some rulesets + ticks = ticks.Reverse(); } + foreach (var e in ticks) + yield return e; + if (span < spanCount - 1) { yield return new SliderEventDescriptor @@ -103,6 +107,43 @@ namespace osu.Game.Rulesets.Objects PathProgress = spanCount % 2, }; } + + /// <summary> + /// Generates the ticks for a span of the slider. + /// </summary> + /// <param name="spanIndex">The span index.</param> + /// <param name="spanStartTime">The start time of the span.</param> + /// <param name="spanDuration">The duration of the span.</param> + /// <param name="reversed">Whether the span is reversed.</param> + /// <param name="length">The length of the path.</param> + /// <param name="tickDistance">The distance between each tick.</param> + /// <param name="minDistanceFromEnd">The distance from the end of the path at which ticks are not allowed to be added.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A <see cref="SliderEventDescriptor"/> for each tick. If <paramref name="reversed"/> is true, the ticks will be returned in reverse-StartTime order.</returns> + private static IEnumerable<SliderEventDescriptor> generateTicks(int spanIndex, double spanStartTime, double spanDuration, bool reversed, double length, double tickDistance, + double minDistanceFromEnd, CancellationToken cancellationToken = default) + { + for (var d = tickDistance; d <= length; d += tickDistance) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (d >= length - minDistanceFromEnd) + break; + + // Always generate ticks from the start of the path rather than the span to ensure that ticks in repeat spans are positioned identically to those in non-repeat spans + var pathProgress = d / length; + var timeProgress = reversed ? 1 - pathProgress : pathProgress; + + yield return new SliderEventDescriptor + { + Type = SliderEventType.Tick, + SpanIndex = spanIndex, + SpanStartTime = spanStartTime, + Time = spanStartTime + timeProgress * spanDuration, + PathProgress = pathProgress, + }; + } + } } /// <summary> diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index bc9571c85d..3083fcfccb 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -1,68 +1,90 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using Newtonsoft.Json; -using osu.Framework.MathUtils; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Types; using osuTK; namespace osu.Game.Rulesets.Objects { - public struct SliderPath : IEquatable<SliderPath> + public class SliderPath { + /// <summary> + /// The current version of this <see cref="SliderPath"/>. Updated when any change to the path occurs. + /// </summary> + [JsonIgnore] + public IBindable<int> Version => version; + + private readonly Bindable<int> version = new Bindable<int>(); + /// <summary> /// The user-set distance of the path. If non-null, <see cref="Distance"/> will match this value, /// and the path will be shortened/lengthened to match this length. /// </summary> - public readonly double? ExpectedDistance; - - /// <summary> - /// The type of path. - /// </summary> - public readonly PathType Type; - - [JsonProperty] - private Vector2[] controlPoints; - - private List<Vector2> calculatedPath; - private List<double> cumulativeLength; - - private bool isInitialised; - - /// <summary> - /// Creates a new <see cref="SliderPath"/>. - /// </summary> - /// <param name="type">The type of path.</param> - /// <param name="controlPoints">The control points of the path.</param> - /// <param name="expectedDistance">A user-set distance of the path that may be shorter or longer than the true distance between all - /// <paramref name="controlPoints"/>. The path will be shortened/lengthened to match this length. - /// If null, the path will use the true distance between all <paramref name="controlPoints"/>.</param> - [JsonConstructor] - public SliderPath(PathType type, Vector2[] controlPoints, double? expectedDistance = null) - { - this = default; - this.controlPoints = controlPoints; - - Type = type; - ExpectedDistance = expectedDistance; - - ensureInitialised(); - } + public readonly Bindable<double?> ExpectedDistance = new Bindable<double?>(); /// <summary> /// The control points of the path. /// </summary> - [JsonIgnore] - public ReadOnlySpan<Vector2> ControlPoints + public readonly BindableList<PathControlPoint> ControlPoints = new BindableList<PathControlPoint>(); + + private readonly List<Vector2> calculatedPath = new List<Vector2>(); + private readonly List<double> cumulativeLength = new List<double>(); + private readonly Cached pathCache = new Cached(); + + private double calculatedLength; + + /// <summary> + /// Creates a new <see cref="SliderPath"/>. + /// </summary> + public SliderPath() { - get + ExpectedDistance.ValueChanged += _ => invalidate(); + + ControlPoints.CollectionChanged += (_, args) => { - ensureInitialised(); - return controlPoints.AsSpan(); - } + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var c in args.NewItems.Cast<PathControlPoint>()) + c.Changed += invalidate; + break; + + case NotifyCollectionChangedAction.Reset: + case NotifyCollectionChangedAction.Remove: + foreach (var c in args.OldItems.Cast<PathControlPoint>()) + c.Changed -= invalidate; + break; + } + + invalidate(); + }; + } + + /// <summary> + /// Creates a new <see cref="SliderPath"/> initialised with a list of control points. + /// </summary> + /// <param name="controlPoints">An optional set of <see cref="PathControlPoint"/>s to initialise the path with.</param> + /// <param name="expectedDistance">A user-set distance of the path that may be shorter or longer than the true distance between all control points. + /// The path will be shortened/lengthened to match this length. If null, the path will use the true distance between all control points.</param> + [JsonConstructor] + public SliderPath(PathControlPoint[] controlPoints, double? expectedDistance = null) + : this() + { + ControlPoints.AddRange(controlPoints); + ExpectedDistance.Value = expectedDistance; + } + + public SliderPath(PathType type, Vector2[] controlPoints, double? expectedDistance = null) + : this(controlPoints.Select((c, i) => new PathControlPoint(c, i == 0 ? (PathType?)type : null)).ToArray(), expectedDistance) + { } /// <summary> @@ -73,8 +95,20 @@ namespace osu.Game.Rulesets.Objects { get { - ensureInitialised(); - return cumulativeLength.Count == 0 ? 0 : cumulativeLength[cumulativeLength.Count - 1]; + ensureValid(); + return cumulativeLength.Count == 0 ? 0 : cumulativeLength[^1]; + } + } + + /// <summary> + /// The distance of the path prior to lengthening/shortening to account for <see cref="ExpectedDistance"/>. + /// </summary> + public double CalculatedDistance + { + get + { + ensureValid(); + return calculatedLength; } } @@ -87,7 +121,7 @@ namespace osu.Game.Rulesets.Objects /// <param name="p1">End progress. Ranges from 0 (beginning of the slider) to 1 (end of the slider).</param> public void GetPathToProgress(List<Vector2> path, double p0, double p1) { - ensureInitialised(); + ensureValid(); double d0 = progressToDistance(p0); double d1 = progressToDistance(p1); @@ -116,40 +150,73 @@ namespace osu.Game.Rulesets.Objects /// <returns></returns> public Vector2 PositionAt(double progress) { - ensureInitialised(); + ensureValid(); double d = progressToDistance(progress); return interpolateVertices(indexOfDistance(d), d); } - private void ensureInitialised() + private void invalidate() { - if (isInitialised) - return; - - isInitialised = true; - - controlPoints = controlPoints ?? Array.Empty<Vector2>(); - calculatedPath = new List<Vector2>(); - cumulativeLength = new List<double>(); - - calculatePath(); - calculateCumulativeLength(); + pathCache.Invalidate(); + version.Value++; } - private List<Vector2> calculateSubpath(ReadOnlySpan<Vector2> subControlPoints) + private void ensureValid() { - switch (Type) + if (pathCache.IsValid) + return; + + calculatePath(); + calculateLength(); + + pathCache.Validate(); + } + + private void calculatePath() + { + calculatedPath.Clear(); + + if (ControlPoints.Count == 0) + return; + + Vector2[] vertices = new Vector2[ControlPoints.Count]; + for (int i = 0; i < ControlPoints.Count; i++) + vertices[i] = ControlPoints[i].Position.Value; + + int start = 0; + + for (int i = 0; i < ControlPoints.Count; i++) + { + if (ControlPoints[i].Type.Value == null && i < ControlPoints.Count - 1) + continue; + + // The current vertex ends the segment + var segmentVertices = vertices.AsSpan().Slice(start, i - start + 1); + var segmentType = ControlPoints[start].Type.Value ?? PathType.Linear; + + foreach (Vector2 t in calculateSubPath(segmentVertices, segmentType)) + { + if (calculatedPath.Count == 0 || calculatedPath.Last() != t) + calculatedPath.Add(t); + } + + // Start the new segment at the current vertex + start = i; + } + } + + private List<Vector2> calculateSubPath(ReadOnlySpan<Vector2> subControlPoints, PathType type) + { + switch (type) { case PathType.Linear: return PathApproximator.ApproximateLinear(subControlPoints); case PathType.PerfectCurve: - //we can only use CircularArc iff we have exactly three control points and no dissection. - if (ControlPoints.Length != 3 || subControlPoints.Length != 3) + if (subControlPoints.Length != 3) break; - // Here we have exactly 3 control points. Attempt to fit a circular arc. List<Vector2> subpath = PathApproximator.ApproximateCircularArc(subControlPoints); // If for some reason a circular arc could not be fit to the 3 given points, fall back to a numerically stable bezier approximation. @@ -165,72 +232,49 @@ namespace osu.Game.Rulesets.Objects return PathApproximator.ApproximateBezier(subControlPoints); } - private void calculatePath() + private void calculateLength() { - calculatedPath.Clear(); - - // Sliders may consist of various subpaths separated by two consecutive vertices - // with the same position. The following loop parses these subpaths and computes - // their shape independently, consecutively appending them to calculatedPath. - - int start = 0; - int end = 0; - - for (int i = 0; i < ControlPoints.Length; ++i) - { - end++; - - if (i == ControlPoints.Length - 1 || ControlPoints[i] == ControlPoints[i + 1]) - { - ReadOnlySpan<Vector2> cpSpan = ControlPoints.Slice(start, end - start); - - foreach (Vector2 t in calculateSubpath(cpSpan)) - if (calculatedPath.Count == 0 || calculatedPath.Last() != t) - calculatedPath.Add(t); - - start = end; - } - } - } - - private void calculateCumulativeLength() - { - double l = 0; - + calculatedLength = 0; cumulativeLength.Clear(); - cumulativeLength.Add(l); + cumulativeLength.Add(0); - for (int i = 0; i < calculatedPath.Count - 1; ++i) + for (int i = 0; i < calculatedPath.Count - 1; i++) { Vector2 diff = calculatedPath[i + 1] - calculatedPath[i]; - double d = diff.Length; - - // Shorted slider paths that are too long compared to the expected distance - if (ExpectedDistance.HasValue && ExpectedDistance - l < d) - { - calculatedPath[i + 1] = calculatedPath[i] + diff * (float)((ExpectedDistance - l) / d); - calculatedPath.RemoveRange(i + 2, calculatedPath.Count - 2 - i); - - l = ExpectedDistance.Value; - cumulativeLength.Add(l); - break; - } - - l += d; - cumulativeLength.Add(l); + calculatedLength += diff.Length; + cumulativeLength.Add(calculatedLength); } - // Lengthen slider paths that are too short compared to the expected distance - if (ExpectedDistance.HasValue && l < ExpectedDistance && calculatedPath.Count > 1) + if (ExpectedDistance.Value is double expectedDistance && calculatedLength != expectedDistance) { - Vector2 diff = calculatedPath[calculatedPath.Count - 1] - calculatedPath[calculatedPath.Count - 2]; - double d = diff.Length; + // The last length is always incorrect + cumulativeLength.RemoveAt(cumulativeLength.Count - 1); - if (d <= 0) + int pathEndIndex = calculatedPath.Count - 1; + + if (calculatedLength > expectedDistance) + { + // The path will be shortened further, in which case we should trim any more unnecessary lengths and their associated path segments + while (cumulativeLength.Count > 0 && cumulativeLength[^1] >= expectedDistance) + { + cumulativeLength.RemoveAt(cumulativeLength.Count - 1); + calculatedPath.RemoveAt(pathEndIndex--); + } + } + + if (pathEndIndex <= 0) + { + // The expected distance is negative or zero + // TODO: Perhaps negative path lengths should be disallowed altogether + cumulativeLength.Add(0); return; + } - calculatedPath[calculatedPath.Count - 1] += diff * (float)((ExpectedDistance - l) / d); - cumulativeLength[calculatedPath.Count - 1] = ExpectedDistance.Value; + // The direction of the segment to shorten or lengthen + Vector2 dir = (calculatedPath[pathEndIndex] - calculatedPath[pathEndIndex - 1]).Normalized(); + + calculatedPath[pathEndIndex] = calculatedPath[pathEndIndex - 1] + dir * (float)(expectedDistance - cumulativeLength[^1]); + cumulativeLength.Add(expectedDistance); } } @@ -244,7 +288,7 @@ namespace osu.Game.Rulesets.Objects private double progressToDistance(double progress) { - return MathHelper.Clamp(progress, 0, 1) * Distance; + return Math.Clamp(progress, 0, 1) * Distance; } private Vector2 interpolateVertices(int i, double d) @@ -270,15 +314,5 @@ namespace osu.Game.Rulesets.Objects double w = (d - d0) / (d1 - d0); return p0 + (p1 - p0) * (float)w; } - - public bool Equals(SliderPath other) - { - if (ControlPoints == null && other.ControlPoints != null) - return false; - if (other.ControlPoints == null && ControlPoints != null) - return false; - - return ControlPoints.SequenceEqual(other.ControlPoints) && ExpectedDistance.Equals(other.ExpectedDistance) && Type == other.Type; - } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index e07da93a3a..211c077d4f 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -1,6 +1,8 @@ // 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. +using osu.Framework.Bindables; + namespace osu.Game.Rulesets.Objects.Types { /// <summary> @@ -8,16 +10,27 @@ namespace osu.Game.Rulesets.Objects.Types /// </summary> public interface IHasComboInformation : IHasCombo { + Bindable<int> IndexInCurrentComboBindable { get; } + /// <summary> /// The offset of this hitobject in the current combo. /// </summary> int IndexInCurrentCombo { get; set; } + Bindable<int> ComboIndexBindable { get; } + /// <summary> /// The offset of this combo in relation to the beatmap. /// </summary> int ComboIndex { get; set; } + /// <summary> + /// Whether the HitObject starts a new combo. + /// </summary> + new bool NewCombo { get; set; } + + Bindable<bool> LastInComboBindable { get; } + /// <summary> /// Whether this is the last object in the current combo. /// </summary> diff --git a/osu.Game/Rulesets/Objects/Types/IHasCurve.cs b/osu.Game/Rulesets/Objects/Types/IHasCurve.cs index e98a888bd7..26f50ffa31 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasCurve.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasCurve.cs @@ -1,13 +1,12 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. +using System; using osuTK; namespace osu.Game.Rulesets.Objects.Types { - /// <summary> - /// A HitObject that has a curve. - /// </summary> + [Obsolete("Use IHasPathWithRepeats instead.")] // can be removed 20201126 public interface IHasCurve : IHasDistance, IHasRepeats { /// <summary> @@ -16,6 +15,8 @@ namespace osu.Game.Rulesets.Objects.Types SliderPath Path { get; } } +#pragma warning disable 618 + [Obsolete("Use IHasPathWithRepeats instead.")] // can be removed 20201126 public static class HasCurveExtensions { /// <summary> @@ -50,4 +51,5 @@ namespace osu.Game.Rulesets.Objects.Types public static int SpanAt(this IHasCurve obj, double progress) => (int)(progress * obj.SpanCount()); } +#pragma warning restore 618 } diff --git a/osu.Game/Rulesets/Objects/Types/IHasDistance.cs b/osu.Game/Rulesets/Objects/Types/IHasDistance.cs index e7f552115e..b497ca5da3 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasDistance.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasDistance.cs @@ -6,7 +6,7 @@ namespace osu.Game.Rulesets.Objects.Types /// <summary> /// A HitObject that has a positional length. /// </summary> - public interface IHasDistance : IHasEndTime + public interface IHasDistance : IHasDuration { /// <summary> /// The positional length of the HitObject. diff --git a/osu.Game/Rulesets/Objects/Types/IHasDuration.cs b/osu.Game/Rulesets/Objects/Types/IHasDuration.cs new file mode 100644 index 0000000000..b558273650 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/IHasDuration.cs @@ -0,0 +1,31 @@ +// 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. + +namespace osu.Game.Rulesets.Objects.Types +{ + /// <summary> + /// A HitObject that ends at a different time than its start time. + /// </summary> +#pragma warning disable 618 + public interface IHasDuration : IHasEndTime +#pragma warning restore 618 + { + double IHasEndTime.EndTime + { + get => EndTime; + set => Duration = (Duration - EndTime) + value; + } + + double IHasEndTime.Duration => Duration; + + /// <summary> + /// The time at which the HitObject ends. + /// </summary> + new double EndTime { get; } + + /// <summary> + /// The duration of the HitObject. + /// </summary> + new double Duration { get; set; } + } +} diff --git a/osu.Game/Rulesets/Objects/Types/IHasEndTime.cs b/osu.Game/Rulesets/Objects/Types/IHasEndTime.cs index 516f1002a4..c3769c5909 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasEndTime.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasEndTime.cs @@ -1,17 +1,22 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. +using System; +using Newtonsoft.Json; + namespace osu.Game.Rulesets.Objects.Types { /// <summary> /// A HitObject that ends at a different time than its start time. /// </summary> + [Obsolete("Use IHasDuration instead.")] // can be removed 20201126 public interface IHasEndTime { /// <summary> /// The time at which the HitObject ends. /// </summary> - double EndTime { get; } + [JsonIgnore] + double EndTime { get; set; } /// <summary> /// The duration of the HitObject. diff --git a/osu.Game/Rulesets/Objects/Types/IHasPath.cs b/osu.Game/Rulesets/Objects/Types/IHasPath.cs new file mode 100644 index 0000000000..567c24a4a2 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/IHasPath.cs @@ -0,0 +1,13 @@ +// 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. + +namespace osu.Game.Rulesets.Objects.Types +{ + public interface IHasPath : IHasDistance + { + /// <summary> + /// The curve. + /// </summary> + SliderPath Path { get; } + } +} diff --git a/osu.Game/Rulesets/Objects/Types/IHasPathWithRepeats.cs b/osu.Game/Rulesets/Objects/Types/IHasPathWithRepeats.cs new file mode 100644 index 0000000000..279946b44e --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/IHasPathWithRepeats.cs @@ -0,0 +1,50 @@ +// 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. + +using osuTK; + +namespace osu.Game.Rulesets.Objects.Types +{ + /// <summary> + /// A HitObject that has a curve. + /// </summary> + // ReSharper disable once RedundantExtendsListEntry + public interface IHasPathWithRepeats : IHasPath, IHasRepeats + { + } + + public static class HasPathWithRepeatsExtensions + { + /// <summary> + /// Computes the position on the curve relative to how much of the <see cref="HitObject"/> has been completed. + /// </summary> + /// <param name="obj">The curve.</param> + /// <param name="progress">[0, 1] where 0 is the start time of the <see cref="HitObject"/> and 1 is the end time of the <see cref="HitObject"/>.</param> + /// <returns>The position on the curve.</returns> + public static Vector2 CurvePositionAt(this IHasPathWithRepeats obj, double progress) + => obj.Path.PositionAt(obj.ProgressAt(progress)); + + /// <summary> + /// Computes the progress along the curve relative to how much of the <see cref="HitObject"/> has been completed. + /// </summary> + /// <param name="obj">The curve.</param> + /// <param name="progress">[0, 1] where 0 is the start time of the <see cref="HitObject"/> and 1 is the end time of the <see cref="HitObject"/>.</param> + /// <returns>[0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.</returns> + public static double ProgressAt(this IHasPathWithRepeats obj, double progress) + { + double p = progress * obj.SpanCount() % 1; + if (obj.SpanAt(progress) % 2 == 1) + p = 1 - p; + return p; + } + + /// <summary> + /// Determines which span of the curve the progress point is on. + /// </summary> + /// <param name="obj">The curve.</param> + /// <param name="progress">[0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.</param> + /// <returns>[0, SpanCount) where 0 is the first run.</returns> + public static int SpanAt(this IHasPathWithRepeats obj, double progress) + => (int)(progress * obj.SpanCount()); + } +} diff --git a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs index 697adeda98..674e2aee88 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs @@ -9,12 +9,12 @@ namespace osu.Game.Rulesets.Objects.Types /// <summary> /// A HitObject that spans some length. /// </summary> - public interface IHasRepeats : IHasEndTime + public interface IHasRepeats : IHasDuration { /// <summary> /// The amount of times the HitObject repeats. /// </summary> - int RepeatCount { get; } + int RepeatCount { get; set; } /// <summary> /// The samples to be played when each node of the <see cref="IHasRepeats"/> is hit.<br /> @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Objects.Types /// n-1: The last repeat.<br /> /// n: The last node. /// </summary> - List<List<HitSampleInfo>> NodeSamples { get; } + List<IList<HitSampleInfo>> NodeSamples { get; } } public static class HasRepeatsExtensions @@ -35,5 +35,15 @@ namespace osu.Game.Rulesets.Objects.Types /// </summary> /// <param name="obj">The object that has repeats.</param> public static int SpanCount(this IHasRepeats obj) => obj.RepeatCount + 1; + + /// <summary> + /// Retrieves the samples at a particular node in a <see cref="IHasRepeats"/> object. + /// </summary> + /// <param name="obj">The <see cref="HitObject"/>.</param> + /// <param name="nodeIndex">The node to attempt to retrieve the samples at.</param> + /// <returns>The samples at the given node index, or <paramref name="obj"/>'s default samples if the given node doesn't exist.</returns> + public static IList<HitSampleInfo> GetNodeSamples<T>(this T obj, int nodeIndex) + where T : HitObject, IHasRepeats + => nodeIndex < obj.NodeSamples.Count ? obj.NodeSamples[nodeIndex] : obj.Samples; } } diff --git a/osu.Game/Rulesets/Replays/AutoGenerator.cs b/osu.Game/Rulesets/Replays/AutoGenerator.cs index 1d4cdbf04c..b3c609f2f4 100644 --- a/osu.Game/Rulesets/Replays/AutoGenerator.cs +++ b/osu.Game/Rulesets/Replays/AutoGenerator.cs @@ -3,6 +3,7 @@ using osu.Game.Beatmaps; using osu.Game.Replays; +using osu.Game.Rulesets.Objects; namespace osu.Game.Rulesets.Replays { @@ -31,8 +32,16 @@ namespace osu.Game.Rulesets.Replays #region Constants // Shared amongst all modes - protected const double KEY_UP_DELAY = 50; + public const double KEY_UP_DELAY = 50; #endregion + + protected virtual HitObject GetNextObject(int currentIndex) + { + if (currentIndex >= Beatmap.HitObjects.Count - 1) + return null; + + return Beatmap.HitObjects[currentIndex + 1]; + } } } diff --git a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs index 4c011388fa..0b41ca31ea 100644 --- a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs +++ b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs @@ -3,11 +3,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using JetBrains.Annotations; -using osu.Framework.Input.StateChanges; using osu.Game.Input.Handlers; using osu.Game.Replays; -using osuTK; namespace osu.Game.Rulesets.Replays { @@ -41,52 +40,42 @@ namespace osu.Game.Rulesets.Replays return null; if (!currentFrameIndex.HasValue) - return (TFrame)Frames[0]; + return currentDirection > 0 ? (TFrame)Frames[0] : null; - if (currentDirection > 0) - return currentFrameIndex == Frames.Count - 1 ? null : (TFrame)Frames[currentFrameIndex.Value + 1]; - else - return currentFrameIndex == 0 ? null : (TFrame)Frames[nextFrameIndex]; + int nextFrame = clampedNextFrameIndex; + + if (nextFrame == currentFrameIndex.Value) + return null; + + return (TFrame)Frames[clampedNextFrameIndex]; } } private int? currentFrameIndex; - private int nextFrameIndex => currentFrameIndex.HasValue ? MathHelper.Clamp(currentFrameIndex.Value + (currentDirection > 0 ? 1 : -1), 0, Frames.Count - 1) : 0; + private int clampedNextFrameIndex => + currentFrameIndex.HasValue ? Math.Clamp(currentFrameIndex.Value + currentDirection, 0, Frames.Count - 1) : 0; protected FramedReplayInputHandler(Replay replay) { this.replay = replay; } - private bool advanceFrame() - { - int newFrame = nextFrameIndex; - - //ensure we aren't at an extent. - if (newFrame == currentFrameIndex) return false; - - currentFrameIndex = newFrame; - return true; - } - - public override List<IInput> GetPendingInputs() => new List<IInput>(); - 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; + private int currentDirection = 1; /// <summary> /// 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). /// </summary> - public bool FrameAccuratePlayback = false; + public bool FrameAccuratePlayback; - protected bool HasFrames => Frames.Count > 0; + public bool HasFrames => Frames.Count > 0; private bool inImportantSection { @@ -100,8 +89,8 @@ namespace osu.Game.Rulesets.Replays 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(frame) && // a button is in a pressed state + Math.Abs(CurrentTime - NextFrame?.Time ?? 0) <= AllowedImportantTimeSpan; // the next frame is within an allowable time span } } @@ -115,6 +104,62 @@ namespace osu.Game.Rulesets.Replays /// <param name="time">The time which we should use for finding the current frame.</param> /// <returns>The usable time value. If null, we should not advance time as we do not have enough data.</returns> 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. + if (replay.HasReceivedAllFrames) + return CurrentTime = time; + + return null; + } + + TFrame next = NextFrame; + + // 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) + { + int compare = time.CompareTo(next.Time); + + if (compare == 0 || compare == currentDirection) + { + currentFrameIndex = clampedNextFrameIndex; + return CurrentTime = CurrentFrame.Time; + } + } + + // 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). + + // the exception is if currently in an important section, which is respected above all. + if (inImportantSection) + { + Debug.Assert(next != null || !replay.HasReceivedAllFrames); + return null; + } + + // if a next frame does exist, allow interpolation. + if (next != null) + return CurrentTime = time; + + // 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; + } + + private void updateDirection(double time) { if (!CurrentTime.HasValue) { @@ -125,27 +170,6 @@ namespace osu.Game.Rulesets.Replays currentDirection = time.CompareTo(CurrentTime); if (currentDirection == 0) currentDirection = 1; } - - if (HasFrames) - { - // check if the next frame is valid for the current playback direction. - // validity is if the next frame is equal or "earlier" - var compare = time.CompareTo(NextFrame?.Time); - - if (compare == 0 || compare == currentDirection) - { - if (advanceFrame()) - return CurrentTime = CurrentFrame.Time; - } - else - { - // if we didn't change frames, we need to ensure we are allowed to run frames in between, else return null. - if (inImportantSection) - return null; - } - } - - return CurrentTime = time; } } } diff --git a/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs b/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs index 7ecdc0715b..d9aa615c6e 100644 --- a/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs +++ b/osu.Game/Rulesets/Replays/Types/IConvertibleReplayFrame.cs @@ -14,8 +14,15 @@ namespace osu.Game.Rulesets.Replays.Types /// <summary> /// Populates this <see cref="ReplayFrame"/> using values from a <see cref="LegacyReplayFrame"/>. /// </summary> - /// <param name="legacyFrame">The <see cref="LegacyReplayFrame"/> to extract values from.</param> + /// <param name="currentFrame">The <see cref="LegacyReplayFrame"/> to extract values from.</param> /// <param name="beatmap">The beatmap.</param> - void ConvertFrom(LegacyReplayFrame legacyFrame, IBeatmap beatmap); + /// <param name="lastFrame">The last post-conversion <see cref="ReplayFrame"/>, used to fill in missing delta information. May be null.</param> + void FromLegacy(LegacyReplayFrame currentFrame, IBeatmap beatmap, ReplayFrame lastFrame = null); + + /// <summary> + /// Populates this <see cref="ReplayFrame"/> using values from a <see cref="LegacyReplayFrame"/>. + /// </summary> + /// <param name="beatmap">The beatmap.</param> + LegacyReplayFrame ToLegacy(IBeatmap beatmap); } } diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 42b1322cae..8caadffd1d 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; +using osu.Framework.IO.Stores; using osu.Game.Beatmaps; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Edit; @@ -17,13 +18,22 @@ using osu.Game.Beatmaps.Legacy; using osu.Game.Configuration; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Skinning; +using osu.Game.Users; +using JetBrains.Annotations; +using osu.Framework.Extensions; +using osu.Framework.Testing; +using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Utils; namespace osu.Game.Rulesets { + [ExcludeFromDynamicCompile] public abstract class Ruleset { - public readonly RulesetInfo RulesetInfo; + public RulesetInfo RulesetInfo { get; internal set; } public IEnumerable<Mod> GetAllMods() => Enum.GetValues(typeof(ModType)).Cast<ModType>() // Confine all mods of each mod type into a single IEnumerable<Mod> @@ -38,15 +48,79 @@ namespace osu.Game.Rulesets /// <summary> /// Converts mods from legacy enum values. Do not override if you're not a legacy ruleset. /// </summary> - /// <param name="mods">The legacy enum which will be converted</param> - /// <returns>An enumerable of constructed <see cref="Mod"/>s</returns> - public virtual IEnumerable<Mod> ConvertLegacyMods(LegacyMods mods) => new Mod[] { }; + /// <param name="mods">The legacy enum which will be converted.</param> + /// <returns>An enumerable of constructed <see cref="Mod"/>s.</returns> + public virtual IEnumerable<Mod> ConvertFromLegacyMods(LegacyMods mods) => Array.Empty<Mod>(); - public ModAutoplay GetAutoplayMod() => GetAllMods().OfType<ModAutoplay>().First(); - - protected Ruleset(RulesetInfo rulesetInfo = null) + /// <summary> + /// Converts mods to legacy enum values. Do not override if you're not a legacy ruleset. + /// </summary> + /// <param name="mods">The mods which will be converted.</param> + /// <returns>A single bitwise enumerable value representing (to the best of our ability) the mods.</returns> + public virtual LegacyMods ConvertToLegacyMods(Mod[] mods) { - RulesetInfo = rulesetInfo ?? createRulesetInfo(); + var value = LegacyMods.None; + + foreach (var mod in mods) + { + switch (mod) + { + case ModNoFail _: + value |= LegacyMods.NoFail; + break; + + case ModEasy _: + value |= LegacyMods.Easy; + break; + + case ModHidden _: + value |= LegacyMods.Hidden; + break; + + case ModHardRock _: + value |= LegacyMods.HardRock; + break; + + case ModSuddenDeath _: + value |= LegacyMods.SuddenDeath; + break; + + case ModDoubleTime _: + value |= LegacyMods.DoubleTime; + break; + + case ModRelax _: + value |= LegacyMods.Relax; + break; + + case ModHalfTime _: + value |= LegacyMods.HalfTime; + break; + + case ModFlashlight _: + value |= LegacyMods.Flashlight; + break; + } + } + + return value; + } + + [CanBeNull] + public ModAutoplay GetAutoplayMod() => GetAllMods().OfType<ModAutoplay>().FirstOrDefault(); + + public virtual ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => null; + + protected Ruleset() + { + RulesetInfo = new RulesetInfo + { + Name = Description, + ShortName = ShortName, + ID = (this as ILegacyRuleset)?.LegacyID, + InstantiationInfo = GetType().AssemblyQualifiedName, + Available = true, + }; } /// <summary> @@ -56,7 +130,19 @@ namespace osu.Game.Rulesets /// <param name="mods">The <see cref="Mod"/>s to apply.</param> /// <exception cref="BeatmapInvalidForRulesetException">Unable to successfully load the beatmap to be usable with this ruleset.</exception> /// <returns></returns> - public abstract DrawableRuleset CreateDrawableRulesetWith(WorkingBeatmap beatmap, IReadOnlyList<Mod> mods); + public abstract DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null); + + /// <summary> + /// Creates a <see cref="ScoreProcessor"/> for this <see cref="Ruleset"/>. + /// </summary> + /// <returns>The score processor.</returns> + public virtual ScoreProcessor CreateScoreProcessor() => new ScoreProcessor(); + + /// <summary> + /// Creates a <see cref="HealthProcessor"/> for this <see cref="Ruleset"/>. + /// </summary> + /// <returns>The health processor.</returns> + public virtual HealthProcessor CreateHealthProcessor(double drainStartTime) => new DrainingHealthProcessor(drainStartTime); /// <summary> /// Creates a <see cref="IBeatmapConverter"/> to convert a <see cref="IBeatmap"/> to one that is applicable for this <see cref="Ruleset"/>. @@ -74,12 +160,35 @@ namespace osu.Game.Rulesets public abstract DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap); - public virtual PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) => null; + /// <summary> + /// Optionally creates a <see cref="PerformanceCalculator"/> to generate performance data from the provided score. + /// </summary> + /// <param name="attributes">Difficulty attributes for the beatmap related to the provided score.</param> + /// <param name="score">The score to be processed.</param> + /// <returns>A performance calculator instance for the provided score.</returns> + [CanBeNull] + public virtual PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => null; + + /// <summary> + /// Optionally creates a <see cref="PerformanceCalculator"/> to generate performance data from the provided score. + /// </summary> + /// <param name="beatmap">The beatmap to use as a source for generating <see cref="DifficultyAttributes"/>.</param> + /// <param name="score">The score to be processed.</param> + /// <returns>A performance calculator instance for the provided score.</returns> + [CanBeNull] + public PerformanceCalculator CreatePerformanceCalculator(WorkingBeatmap beatmap, ScoreInfo score) + { + var difficultyCalculator = CreateDifficultyCalculator(beatmap); + var difficultyAttributes = difficultyCalculator.Calculate(score.Mods); + return CreatePerformanceCalculator(difficultyAttributes, score); + } public virtual HitObjectComposer CreateHitObjectComposer() => null; public virtual Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.QuestionCircle }; + public virtual IResourceStore<byte[]> CreateResourceStore() => new NamespacedResourceStore<byte[]>(new DllResourceStore(GetType().Assembly), @"Resources"); + public abstract string Description { get; } public virtual RulesetSettingsSubsection CreateSettings() => null; @@ -90,16 +199,16 @@ namespace osu.Game.Rulesets /// <param name="settings">The <see cref="SettingsStore"/> to store the settings.</param> public virtual IRulesetConfigManager CreateConfig(SettingsStore settings) => null; - /// <summary> - /// Do not override this unless you are a legacy mode. - /// </summary> - public virtual int? LegacyID => null; - /// <summary> /// A unique short name to reference this ruleset in online requests. /// </summary> public abstract string ShortName { get; } + /// <summary> + /// The playing verb to be shown in the <see cref="UserActivity.SoloGame.Status"/>. + /// </summary> + public virtual string PlayingVerb => "Playing solo"; + /// <summary> /// A list of available variant ids. /// </summary> @@ -110,7 +219,7 @@ namespace osu.Game.Rulesets /// </summary> /// <param name="variant">A variant.</param> /// <returns>A list of valid <see cref="KeyBinding"/>s.</returns> - public virtual IEnumerable<KeyBinding> GetDefaultKeyBindings(int variant = 0) => new KeyBinding[] { }; + public virtual IEnumerable<KeyBinding> GetDefaultKeyBindings(int variant = 0) => Array.Empty<KeyBinding>(); /// <summary> /// Gets the name for a key binding variant. This is used for display in the settings overlay. @@ -127,16 +236,59 @@ namespace osu.Game.Rulesets public virtual IConvertibleReplayFrame CreateConvertibleReplayFrame() => null; /// <summary> - /// Create a ruleset info based on this ruleset. + /// Creates the statistics for a <see cref="ScoreInfo"/> to be displayed in the results screen. /// </summary> - /// <returns>A filled <see cref="RulesetInfo"/>.</returns> - private RulesetInfo createRulesetInfo() => new RulesetInfo + /// <param name="score">The <see cref="ScoreInfo"/> to create the statistics for. The score is guaranteed to have <see cref="ScoreInfo.HitEvents"/> populated.</param> + /// <param name="playableBeatmap">The <see cref="IBeatmap"/>, converted for this <see cref="Ruleset"/> with all relevant <see cref="Mod"/>s applied.</param> + /// <returns>The <see cref="StatisticRow"/>s to display. Each <see cref="StatisticRow"/> may contain 0 or more <see cref="StatisticItem"/>.</returns> + [NotNull] + public virtual StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty<StatisticRow>(); + + /// <summary> + /// Get all valid <see cref="HitResult"/>s for this ruleset. + /// Generally used for results display purposes, where it can't be determined if zero-count means the user has not achieved any or the type is not used by this ruleset. + /// </summary> + /// <returns> + /// All valid <see cref="HitResult"/>s along with a display-friendly name. + /// </returns> + public IEnumerable<(HitResult result, string displayName)> GetHitResults() { - Name = Description, - ShortName = ShortName, - InstantiationInfo = GetType().AssemblyQualifiedName, - ID = LegacyID, - Available = true - }; + var validResults = GetValidHitResults(); + + // enumerate over ordered list to guarantee return order is stable. + foreach (var result in OrderAttributeUtils.GetValuesInOrder<HitResult>()) + { + switch (result) + { + // hard blocked types, should never be displayed even if the ruleset tells us to. + case HitResult.None: + case HitResult.IgnoreHit: + case HitResult.IgnoreMiss: + // display is handled as a completion count with corresponding "hit" type. + case HitResult.LargeTickMiss: + case HitResult.SmallTickMiss: + continue; + } + + if (result == HitResult.Miss || validResults.Contains(result)) + yield return (result, GetDisplayNameForHitResult(result)); + } + } + + /// <summary> + /// Get all valid <see cref="HitResult"/>s for this ruleset. + /// Generally used for results display purposes, where it can't be determined if zero-count means the user has not achieved any or the type is not used by this ruleset. + /// </summary> + /// <remarks> + /// <see cref="HitResult.Miss"/> is implicitly included. Special types like <see cref="HitResult.IgnoreHit"/> are ignored even when specified. + /// </remarks> + protected virtual IEnumerable<HitResult> GetValidHitResults() => OrderAttributeUtils.GetValuesInOrder<HitResult>(); + + /// <summary> + /// Get a display friendly name for the specified result type. + /// </summary> + /// <param name="result">The result type to get the name for.</param> + /// <returns>The display name.</returns> + public virtual string GetDisplayNameForHitResult(HitResult result) => result.GetDescription(); } } diff --git a/osu.Game/Rulesets/RulesetConfigCache.cs b/osu.Game/Rulesets/RulesetConfigCache.cs index 9a5a4d4acd..d42428638c 100644 --- a/osu.Game/Rulesets/RulesetConfigCache.cs +++ b/osu.Game/Rulesets/RulesetConfigCache.cs @@ -32,9 +32,18 @@ namespace osu.Game.Rulesets public IRulesetConfigManager GetConfigFor(Ruleset ruleset) { if (ruleset.RulesetInfo.ID == null) - throw new InvalidOperationException("The provided ruleset doesn't have a valid id."); + return null; return configCache.GetOrAdd(ruleset.RulesetInfo.ID.Value, _ => ruleset.CreateConfig(settingsStore)); } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + // ensures any potential database operations are finalised before game destruction. + foreach (var c in configCache.Values) + c?.Dispose(); + } } } diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index d9cff86265..d5aca8c650 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -3,10 +3,13 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Newtonsoft.Json; +using osu.Framework.Testing; namespace osu.Game.Rulesets { + [ExcludeFromDynamicCompile] public class RulesetInfo : IEquatable<RulesetInfo> { public int? ID { get; set; } @@ -15,12 +18,36 @@ namespace osu.Game.Rulesets public string ShortName { get; set; } - public string InstantiationInfo { 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)); + } [JsonIgnore] public bool Available { get; set; } - public virtual Ruleset CreateInstance() => (Ruleset)Activator.CreateInstance(Type.GetType(InstantiationInfo), this); + // TODO: this should probably be moved to RulesetStore. + public virtual Ruleset CreateInstance() + { + if (!Available) return null; + + var ruleset = (Ruleset)Activator.CreateInstance(Type.GetType(InstantiationInfo)); + + // overwrite the pre-populated RulesetInfo with a potentially database attached copy. + ruleset.RulesetInfo = this; + + return ruleset; + } public bool Equals(RulesetInfo other) => other != null && ID == other.ID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo; @@ -39,6 +66,6 @@ namespace osu.Game.Rulesets } } - public override string ToString() => $"{Name} ({ShortName}) ID: {ID}"; + public override string ToString() => Name ?? $"{Name} ({ShortName}) ID: {ID}"; } } diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index 2d8c9f5b49..d422bca087 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -6,32 +6,36 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using osu.Framework; using osu.Framework.Logging; +using osu.Framework.Platform; using osu.Game.Database; namespace osu.Game.Rulesets { - /// <summary> - /// Todo: All of this needs to be moved to a RulesetStore. - /// </summary> - public class RulesetStore : DatabaseBackedStore + public class RulesetStore : DatabaseBackedStore, IDisposable { - private static readonly Dictionary<Assembly, Type> loaded_assemblies = new Dictionary<Assembly, Type>(); + private const string ruleset_library_prefix = "osu.Game.Rulesets"; - static RulesetStore() + private readonly Dictionary<Assembly, Type> loadedAssemblies = new Dictionary<Assembly, Type>(); + + private readonly Storage rulesetStorage; + + public RulesetStore(IDatabaseContextFactory factory, Storage storage = null) + : base(factory) { - AppDomain.CurrentDomain.AssemblyResolve += currentDomain_AssemblyResolve; + rulesetStorage = storage?.GetStorageForDirectory("rulesets"); // On android in release configuration assemblies are loaded from the apk directly into memory. // We cannot read assemblies from cwd, so should check loaded assemblies instead. loadFromAppDomain(); - loadFromDisk(); - } - public RulesetStore(IDatabaseContextFactory factory) - : base(factory) - { + // the event handler contains code for resolving dependency on the game assembly for rulesets located outside the base game directory. + // It needs to be attached to the assembly lookup event before the actual call to loadUserRulesets() else rulesets located out of the base game directory will fail + // to load as unable to locate the game core assembly. + AppDomain.CurrentDomain.AssemblyResolve += resolveRulesetDependencyAssembly; + loadUserRulesets(); addMissingRulesets(); } @@ -54,9 +58,25 @@ namespace osu.Game.Rulesets /// </summary> public IEnumerable<RulesetInfo> AvailableRulesets { get; private set; } - private static Assembly currentDomain_AssemblyResolve(object sender, ResolveEventArgs args) => loaded_assemblies.Keys.FirstOrDefault(a => a.FullName == args.Name); + private Assembly resolveRulesetDependencyAssembly(object sender, ResolveEventArgs args) + { + var asm = new AssemblyName(args.Name); - private const string ruleset_library_prefix = "osu.Game.Rulesets"; + // the requesting assembly may be located out of the executable's base directory, thus requiring manual resolving of its dependencies. + // this attempts resolving the ruleset dependencies on game core and framework assemblies by returning assemblies with the same assembly name + // already loaded in the AppDomain. + var domainAssembly = AppDomain.CurrentDomain.GetAssemblies() + // Given name is always going to be equally-or-more qualified than the assembly name. + .Where(a => args.Name.Contains(a.GetName().Name, StringComparison.Ordinal)) + // Pick the greatest assembly version. + .OrderByDescending(a => a.GetName().Version) + .FirstOrDefault(); + + if (domainAssembly != null) + return domainAssembly; + + return loadedAssemblies.Keys.FirstOrDefault(a => a.FullName == asm.FullName); + } private void addMissingRulesets() { @@ -64,36 +84,36 @@ namespace osu.Game.Rulesets { var context = usage.Context; - var instances = loaded_assemblies.Values.Select(r => (Ruleset)Activator.CreateInstance(r, (RulesetInfo)null)).ToList(); + var instances = loadedAssemblies.Values.Select(r => (Ruleset)Activator.CreateInstance(r)).ToList(); - //add all legacy modes in correct order - foreach (var r in instances.Where(r => r.LegacyID != null).OrderBy(r => r.LegacyID)) + // add all legacy rulesets first to ensure they have exclusive choice of primary key. + foreach (var r in instances.Where(r => r is ILegacyRuleset)) { - if (context.RulesetInfo.SingleOrDefault(rsi => rsi.ID == r.RulesetInfo.ID) == null) + if (context.RulesetInfo.SingleOrDefault(dbRuleset => dbRuleset.ID == r.RulesetInfo.ID) == null) context.RulesetInfo.Add(r.RulesetInfo); } context.SaveChanges(); - //add any other modes - foreach (var r in instances.Where(r => r.LegacyID == null)) - if (context.RulesetInfo.FirstOrDefault(ri => ri.InstantiationInfo == r.RulesetInfo.InstantiationInfo) == null) + // add any other modes + var existingRulesets = context.RulesetInfo.ToList(); + + foreach (var r in instances.Where(r => !(r is ILegacyRuleset))) + { + // todo: StartsWith can be changed to Equals on 2020-11-08 + // This is to give users enough time to have their database use new abbreviated info). + if (existingRulesets.FirstOrDefault(ri => ri.InstantiationInfo.StartsWith(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null) context.RulesetInfo.Add(r.RulesetInfo); + } context.SaveChanges(); - //perform a consistency check + // perform a consistency check foreach (var r in context.RulesetInfo) { try { - var instanceInfo = ((Ruleset)Activator.CreateInstance(Type.GetType(r.InstantiationInfo, asm => - { - // for the time being, let's ignore the version being loaded. - // this allows for debug builds to successfully load rulesets (even though debug rulesets have a 0.0.0 version). - asm.Version = null; - return Assembly.Load(asm); - }, null), (RulesetInfo)null)).RulesetInfo; + var instanceInfo = ((Ruleset)Activator.CreateInstance(Type.GetType(r.InstantiationInfo))).RulesetInfo; r.Name = instanceInfo.Name; r.ShortName = instanceInfo.ShortName; @@ -113,7 +133,7 @@ namespace osu.Game.Rulesets } } - private static void loadFromAppDomain() + private void loadFromAppDomain() { foreach (var ruleset in AppDomain.CurrentDomain.GetAssemblies()) { @@ -126,26 +146,36 @@ namespace osu.Game.Rulesets } } - private static void loadFromDisk() + private void loadUserRulesets() + { + if (rulesetStorage == null) return; + + var rulesets = rulesetStorage.GetFiles(".", $"{ruleset_library_prefix}.*.dll"); + + foreach (var ruleset in rulesets.Where(f => !f.Contains("Tests"))) + loadRulesetFromFile(rulesetStorage.GetFullPath(ruleset)); + } + + private void loadFromDisk() { try { - string[] files = Directory.GetFiles(Environment.CurrentDirectory, $"{ruleset_library_prefix}.*.dll"); + var files = Directory.GetFiles(RuntimeInfo.StartupDirectory, $"{ruleset_library_prefix}.*.dll"); foreach (string file in files.Where(f => !Path.GetFileName(f).Contains("Tests"))) loadRulesetFromFile(file); } - catch + catch (Exception e) { - Logger.Log($"Could not load rulesets from directory {Environment.CurrentDirectory}"); + Logger.Error(e, $"Could not load rulesets from directory {RuntimeInfo.StartupDirectory}"); } } - private static void loadRulesetFromFile(string file) + private void loadRulesetFromFile(string file) { var filename = Path.GetFileNameWithoutExtension(file); - if (loaded_assemblies.Values.Any(t => t.Namespace == filename)) + if (loadedAssemblies.Values.Any(t => t.Namespace == filename)) return; try @@ -158,19 +188,35 @@ namespace osu.Game.Rulesets } } - private static void addRuleset(Assembly assembly) + private void addRuleset(Assembly assembly) { - if (loaded_assemblies.ContainsKey(assembly)) + if (loadedAssemblies.ContainsKey(assembly)) + return; + + // the same assembly may be loaded twice in the same AppDomain (currently a thing in certain Rider versions https://youtrack.jetbrains.com/issue/RIDER-48799). + // as a failsafe, also compare by FullName. + if (loadedAssemblies.Any(a => a.Key.FullName == assembly.FullName)) return; try { - loaded_assemblies[assembly] = assembly.GetTypes().First(t => t.IsPublic && t.IsSubclassOf(typeof(Ruleset))); + loadedAssemblies[assembly] = assembly.GetTypes().First(t => t.IsPublic && t.IsSubclassOf(typeof(Ruleset))); } catch (Exception e) { Logger.Error(e, $"Failed to add ruleset {assembly}"); } } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; + } } } diff --git a/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs new file mode 100644 index 0000000000..5dfb5167f4 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/AccumulatingHealthProcessor.cs @@ -0,0 +1,32 @@ +// 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. + +namespace osu.Game.Rulesets.Scoring +{ + /// <summary> + /// A <see cref="HealthProcessor"/> that accumulates health and causes a fail if the final health + /// is less than a value required to pass the beatmap. + /// </summary> + public class AccumulatingHealthProcessor : HealthProcessor + { + protected override bool DefaultFailCondition => JudgedHits == MaxHits && Health.Value < requiredHealth; + + private readonly double requiredHealth; + + /// <summary> + /// Creates a new <see cref="AccumulatingHealthProcessor"/>. + /// </summary> + /// <param name="requiredHealth">The minimum amount of health required to beatmap.</param> + public AccumulatingHealthProcessor(double requiredHealth) + { + this.requiredHealth = requiredHealth; + } + + protected override void Reset(bool storeResults) + { + base.Reset(storeResults); + + Health.Value = 0; + } + } +} diff --git a/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs new file mode 100644 index 0000000000..cae41e22f4 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/DrainingHealthProcessor.cs @@ -0,0 +1,188 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Utils; + +namespace osu.Game.Rulesets.Scoring +{ + /// <summary> + /// A <see cref="HealthProcessor"/> which continuously drains health.<br /> + /// At HP=0, the minimum health reached for a perfect play is 95%.<br /> + /// At HP=5, the minimum health reached for a perfect play is 70%.<br /> + /// At HP=10, the minimum health reached for a perfect play is 30%. + /// </summary> + public class DrainingHealthProcessor : HealthProcessor + { + /// <summary> + /// A reasonable allowable error for the minimum health offset from <see cref="targetMinimumHealth"/>. A 1% error is unnoticeable. + /// </summary> + private const double minimum_health_error = 0.01; + + /// <summary> + /// The minimum health target at an HP drain rate of 0. + /// </summary> + private const double min_health_target = 0.95; + + /// <summary> + /// The minimum health target at an HP drain rate of 5. + /// </summary> + private const double mid_health_target = 0.70; + + /// <summary> + /// The minimum health target at an HP drain rate of 10. + /// </summary> + private const double max_health_target = 0.30; + + private IBeatmap beatmap; + + private double gameplayEndTime; + + private readonly double drainStartTime; + private readonly double drainLenience; + + private readonly List<(double time, double health)> healthIncreases = new List<(double, double)>(); + private double targetMinimumHealth; + private double drainRate = 1; + + private PeriodTracker noDrainPeriodTracker; + + /// <summary> + /// Creates a new <see cref="DrainingHealthProcessor"/>. + /// </summary> + /// <param name="drainStartTime">The time after which draining should begin.</param> + /// <param name="drainLenience">A lenience to apply to the default drain rate.<br /> + /// A value of 0 uses the default drain rate.<br /> + /// A value of 0.5 halves the drain rate.<br /> + /// A value of 1 completely removes drain.</param> + public DrainingHealthProcessor(double drainStartTime, double drainLenience = 0) + { + this.drainStartTime = drainStartTime; + this.drainLenience = drainLenience; + } + + protected override void Update() + { + base.Update(); + + if (noDrainPeriodTracker?.IsInAny(Time.Current) == true) + return; + + // When jumping in and out of gameplay time within a single frame, health should only be drained for the period within the gameplay time + double lastGameplayTime = Math.Clamp(Time.Current - Time.Elapsed, drainStartTime, gameplayEndTime); + double currentGameplayTime = Math.Clamp(Time.Current, drainStartTime, gameplayEndTime); + + Health.Value -= drainRate * (currentGameplayTime - lastGameplayTime); + } + + public override void ApplyBeatmap(IBeatmap beatmap) + { + this.beatmap = beatmap; + + if (beatmap.HitObjects.Count > 0) + gameplayEndTime = beatmap.HitObjects[^1].GetEndTime(); + + noDrainPeriodTracker = new PeriodTracker(beatmap.Breaks.Select(breakPeriod => new Period( + beatmap.HitObjects + .Select(hitObject => hitObject.GetEndTime()) + .Where(endTime => endTime <= breakPeriod.StartTime) + .DefaultIfEmpty(double.MinValue) + .Last(), + beatmap.HitObjects + .Select(hitObject => hitObject.StartTime) + .Where(startTime => startTime >= breakPeriod.EndTime) + .DefaultIfEmpty(double.MaxValue) + .First() + ))); + + targetMinimumHealth = BeatmapDifficulty.DifficultyRange(beatmap.BeatmapInfo.BaseDifficulty.DrainRate, min_health_target, mid_health_target, max_health_target); + + // Add back a portion of the amount of HP to be drained, depending on the lenience requested. + targetMinimumHealth += drainLenience * (1 - targetMinimumHealth); + + // Ensure the target HP is within an acceptable range. + targetMinimumHealth = Math.Clamp(targetMinimumHealth, 0, 1); + + base.ApplyBeatmap(beatmap); + } + + protected override void ApplyResultInternal(JudgementResult result) + { + base.ApplyResultInternal(result); + + if (!result.Type.IsBonus()) + healthIncreases.Add((result.HitObject.GetEndTime() + result.TimeOffset, GetHealthIncreaseFor(result))); + } + + protected override void Reset(bool storeResults) + { + base.Reset(storeResults); + + drainRate = 1; + + if (storeResults) + drainRate = computeDrainRate(); + + healthIncreases.Clear(); + } + + private double computeDrainRate() + { + if (healthIncreases.Count <= 1) + return 0; + + int adjustment = 1; + double result = 1; + + // Although we expect the following loop to converge within 30 iterations (health within 1/2^31 accuracy of the target), + // we'll still keep a safety measure to avoid infinite loops by detecting overflows. + while (adjustment > 0) + { + double currentHealth = 1; + double lowestHealth = 1; + int currentBreak = -1; + + for (int i = 0; i < healthIncreases.Count; i++) + { + double currentTime = healthIncreases[i].time; + double lastTime = i > 0 ? healthIncreases[i - 1].time : drainStartTime; + + // Subtract any break time from the duration since the last object + if (beatmap.Breaks.Count > 0) + { + // Advance the last break occuring before the current time + while (currentBreak + 1 < beatmap.Breaks.Count && beatmap.Breaks[currentBreak + 1].EndTime < currentTime) + currentBreak++; + + if (currentBreak >= 0) + lastTime = Math.Max(lastTime, beatmap.Breaks[currentBreak].EndTime); + } + + // Apply health adjustments + currentHealth -= (healthIncreases[i].time - lastTime) * result; + lowestHealth = Math.Min(lowestHealth, currentHealth); + currentHealth = Math.Min(1, currentHealth + healthIncreases[i].health); + + // Common scenario for when the drain rate is definitely too harsh + if (lowestHealth < 0) + break; + } + + // Stop if the resulting health is within a reasonable offset from the target + if (Math.Abs(lowestHealth - targetMinimumHealth) <= minimum_health_error) + break; + + // This effectively works like a binary search - each iteration the search space moves closer to the target, but may exceed it. + adjustment *= 2; + result += 1.0 / adjustment * Math.Sign(lowestHealth - targetMinimumHealth); + } + + return result; + } + } +} diff --git a/osu.Game/Rulesets/Scoring/HealthProcessor.cs b/osu.Game/Rulesets/Scoring/HealthProcessor.cs new file mode 100644 index 0000000000..1535fe4d00 --- /dev/null +++ b/osu.Game/Rulesets/Scoring/HealthProcessor.cs @@ -0,0 +1,78 @@ +// 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. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; + +namespace osu.Game.Rulesets.Scoring +{ + public abstract class HealthProcessor : JudgementProcessor + { + /// <summary> + /// Invoked when the <see cref="ScoreProcessor"/> is in a failed state. + /// Return true if the fail was permitted. + /// </summary> + public event Func<bool> Failed; + + /// <summary> + /// Additional conditions on top of <see cref="DefaultFailCondition"/> that cause a failing state. + /// </summary> + public event Func<HealthProcessor, JudgementResult, bool> FailConditions; + + /// <summary> + /// The current health. + /// </summary> + public readonly BindableDouble Health = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; + + /// <summary> + /// Whether this ScoreProcessor has already triggered the failed state. + /// </summary> + public bool HasFailed { get; private set; } + + protected override void ApplyResultInternal(JudgementResult result) + { + result.HealthAtJudgement = Health.Value; + result.FailedAtJudgement = HasFailed; + + if (HasFailed) + return; + + Health.Value += GetHealthIncreaseFor(result); + + if (!DefaultFailCondition && FailConditions?.Invoke(this, result) != true) + return; + + if (Failed?.Invoke() != false) + HasFailed = true; + } + + protected override void RevertResultInternal(JudgementResult result) + { + Health.Value = result.HealthAtJudgement; + + // Todo: Revert HasFailed state with proper player support + } + + /// <summary> + /// Retrieves the health increase for a <see cref="JudgementResult"/>. + /// </summary> + /// <param name="result">The <see cref="JudgementResult"/>.</param> + /// <returns>The health increase.</returns> + protected virtual double GetHealthIncreaseFor(JudgementResult result) => result.Judgement.HealthIncreaseFor(result); + + /// <summary> + /// The default conditions for failing. + /// </summary> + protected virtual bool DefaultFailCondition => Precision.AlmostBigger(Health.MinValue, Health.Value); + + protected override void Reset(bool storeResults) + { + base.Reset(storeResults); + + Health.Value = 1; + HasFailed = false; + } + } +} diff --git a/osu.Game/Rulesets/Scoring/HitEvent.cs b/osu.Game/Rulesets/Scoring/HitEvent.cs new file mode 100644 index 0000000000..0ebbec62ba --- /dev/null +++ b/osu.Game/Rulesets/Scoring/HitEvent.cs @@ -0,0 +1,66 @@ +// 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. + +using JetBrains.Annotations; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Scoring +{ + /// <summary> + /// A <see cref="HitEvent"/> generated by the <see cref="ScoreProcessor"/> containing extra statistics around a <see cref="HitResult"/>. + /// </summary> + public readonly struct HitEvent + { + /// <summary> + /// The time offset from the end of <see cref="HitObject"/> at which the event occurred. + /// </summary> + public readonly double TimeOffset; + + /// <summary> + /// The hit result. + /// </summary> + public readonly HitResult Result; + + /// <summary> + /// The <see cref="HitObject"/> on which the result occurred. + /// </summary> + public readonly HitObject HitObject; + + /// <summary> + /// The <see cref="HitObject"/> occurring prior to <see cref="HitObject"/>. + /// </summary> + [CanBeNull] + public readonly HitObject LastHitObject; + + /// <summary> + /// A position, if available, at the time of the event. + /// </summary> + [CanBeNull] + public readonly Vector2? Position; + + /// <summary> + /// Creates a new <see cref="HitEvent"/>. + /// </summary> + /// <param name="timeOffset">The time offset from the end of <paramref name="hitObject"/> at which the event occurs.</param> + /// <param name="result">The <see cref="HitResult"/>.</param> + /// <param name="hitObject">The <see cref="HitObject"/> that triggered the event.</param> + /// <param name="lastHitObject">The previous <see cref="HitObject"/>.</param> + /// <param name="position">A position corresponding to the event.</param> + public HitEvent(double timeOffset, HitResult result, HitObject hitObject, [CanBeNull] HitObject lastHitObject, [CanBeNull] Vector2? position) + { + TimeOffset = timeOffset; + Result = result; + HitObject = hitObject; + LastHitObject = lastHitObject; + Position = position; + } + + /// <summary> + /// Creates a new <see cref="HitEvent"/> with an optional positional offset. + /// </summary> + /// <param name="positionOffset">The positional offset.</param> + /// <returns>The new <see cref="HitEvent"/>.</returns> + public HitEvent With(Vector2? positionOffset) => new HitEvent(TimeOffset, Result, HitObject, LastHitObject, positionOffset); + } +} diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 2376f12e9e..6a3a034fc1 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -2,42 +2,192 @@ // See the LICENCE file in the repository root for full licence text. using System.ComponentModel; +using System.Diagnostics; +using osu.Game.Utils; namespace osu.Game.Rulesets.Scoring { + [HasOrderedElements] public enum HitResult { /// <summary> /// Indicates that the object has not been judged yet. /// </summary> [Description(@"")] + [Order(14)] None, /// <summary> /// Indicates that the object has been judged as a miss. /// </summary> + /// <remarks> + /// This miss window should determine how early a hit can be before it is considered for judgement (as opposed to being ignored as + /// "too far in the future). It should also define when a forced miss should be triggered (as a result of no user input in time). + /// </remarks> [Description(@"Miss")] + [Order(5)] Miss, [Description(@"Meh")] + [Order(4)] Meh, - /// <summary> - /// Optional judgement. - /// </summary> [Description(@"OK")] + [Order(3)] Ok, [Description(@"Good")] + [Order(2)] Good, [Description(@"Great")] + [Order(1)] Great, - /// <summary> - /// Optional judgement. - /// </summary> [Description(@"Perfect")] + [Order(0)] Perfect, + + /// <summary> + /// Indicates small tick miss. + /// </summary> + [Order(11)] + SmallTickMiss, + + /// <summary> + /// Indicates a small tick hit. + /// </summary> + [Description(@"S Tick")] + [Order(7)] + SmallTickHit, + + /// <summary> + /// Indicates a large tick miss. + /// </summary> + [Order(10)] + LargeTickMiss, + + /// <summary> + /// Indicates a large tick hit. + /// </summary> + [Description(@"L Tick")] + [Order(6)] + LargeTickHit, + + /// <summary> + /// Indicates a small bonus. + /// </summary> + [Description("S Bonus")] + [Order(9)] + SmallBonus, + + /// <summary> + /// Indicates a large bonus. + /// </summary> + [Description("L Bonus")] + [Order(8)] + LargeBonus, + + /// <summary> + /// Indicates a miss that should be ignored for scoring purposes. + /// </summary> + [Order(13)] + IgnoreMiss, + + /// <summary> + /// Indicates a hit that should be ignored for scoring purposes. + /// </summary> + [Order(12)] + IgnoreHit, + } + + public static class HitResultExtensions + { + /// <summary> + /// Whether a <see cref="HitResult"/> increases/decreases the combo, and affects the combo portion of the score. + /// </summary> + public static bool AffectsCombo(this HitResult result) + { + switch (result) + { + case HitResult.Miss: + case HitResult.Meh: + case HitResult.Ok: + case HitResult.Good: + case HitResult.Great: + case HitResult.Perfect: + case HitResult.LargeTickHit: + case HitResult.LargeTickMiss: + return true; + + default: + return false; + } + } + + /// <summary> + /// Whether a <see cref="HitResult"/> affects the accuracy portion of the score. + /// </summary> + public static bool AffectsAccuracy(this HitResult result) + => IsScorable(result) && !IsBonus(result); + + /// <summary> + /// Whether a <see cref="HitResult"/> should be counted as bonus score. + /// </summary> + public static bool IsBonus(this HitResult result) + { + switch (result) + { + case HitResult.SmallBonus: + case HitResult.LargeBonus: + return true; + + default: + return false; + } + } + + /// <summary> + /// Whether a <see cref="HitResult"/> represents a successful hit. + /// </summary> + public static bool IsHit(this HitResult result) + { + switch (result) + { + case HitResult.None: + case HitResult.IgnoreMiss: + case HitResult.Miss: + case HitResult.SmallTickMiss: + case HitResult.LargeTickMiss: + return false; + + default: + return true; + } + } + + /// <summary> + /// Whether a <see cref="HitResult"/> is scorable. + /// </summary> + public static bool IsScorable(this HitResult result) => result >= HitResult.Miss && result < HitResult.IgnoreMiss; + + /// <summary> + /// Whether a <see cref="HitResult"/> is valid within a given <see cref="HitResult"/> range. + /// </summary> + /// <param name="result">The <see cref="HitResult"/> to check.</param> + /// <param name="minResult">The minimum <see cref="HitResult"/>.</param> + /// <param name="maxResult">The maximum <see cref="HitResult"/>.</param> + /// <returns>Whether <see cref="HitResult"/> falls between <paramref name="minResult"/> and <paramref name="maxResult"/>.</returns> + public static bool IsValidHitResult(this HitResult result, HitResult minResult, HitResult maxResult) + { + if (result == HitResult.None) + return false; + + if (result == minResult || result == maxResult) + return true; + + Debug.Assert(minResult <= maxResult); + return result > minResult && result < maxResult; + } } } diff --git a/osu.Game/Rulesets/Scoring/HitWindows.cs b/osu.Game/Rulesets/Scoring/HitWindows.cs new file mode 100644 index 0000000000..018b50bd3d --- /dev/null +++ b/osu.Game/Rulesets/Scoring/HitWindows.cs @@ -0,0 +1,227 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Scoring +{ + /// <summary> + /// A structure containing timing data for hit window based gameplay. + /// </summary> + public class HitWindows + { + private static readonly DifficultyRange[] base_ranges = + { + new DifficultyRange(HitResult.Perfect, 22.4D, 19.4D, 13.9D), + new DifficultyRange(HitResult.Great, 64, 49, 34), + new DifficultyRange(HitResult.Good, 97, 82, 67), + new DifficultyRange(HitResult.Ok, 127, 112, 97), + new DifficultyRange(HitResult.Meh, 151, 136, 121), + new DifficultyRange(HitResult.Miss, 188, 173, 158), + }; + + private double perfect; + private double great; + private double good; + private double ok; + private double meh; + private double miss; + + /// <summary> + /// An empty <see cref="HitWindows"/> with only <see cref="HitResult.Miss"/> and <see cref="HitResult.Perfect"/>. + /// No time values are provided (meaning instantaneous hit or miss). + /// </summary> + public static HitWindows Empty => new EmptyHitWindows(); + + public HitWindows() + { + Debug.Assert(GetRanges().Any(r => r.Result == HitResult.Miss), $"{nameof(GetRanges)} should always contain {nameof(HitResult.Miss)}"); + Debug.Assert(GetRanges().Any(r => r.Result != HitResult.Miss), $"{nameof(GetRanges)} should always contain at least one result type other than {nameof(HitResult.Miss)}."); + } + + /// <summary> + /// Retrieves the <see cref="HitResult"/> with the largest hit window that produces a successful hit. + /// </summary> + /// <returns>The lowest allowed successful <see cref="HitResult"/>.</returns> + protected HitResult LowestSuccessfulHitResult() + { + for (var result = HitResult.Meh; result <= HitResult.Perfect; ++result) + { + if (IsHitResultAllowed(result)) + return result; + } + + return HitResult.None; + } + + /// <summary> + /// Retrieves a mapping of <see cref="HitResult"/>s to their timing windows for all allowed <see cref="HitResult"/>s. + /// </summary> + /// <returns></returns> + public IEnumerable<(HitResult result, double length)> GetAllAvailableWindows() + { + for (var result = HitResult.Meh; result <= HitResult.Perfect; ++result) + { + if (IsHitResultAllowed(result)) + yield return (result, WindowFor(result)); + } + } + + /// <summary> + /// Check whether it is possible to achieve the provided <see cref="HitResult"/>. + /// </summary> + /// <param name="result">The result type to check.</param> + /// <returns>Whether the <see cref="HitResult"/> can be achieved.</returns> + public virtual bool IsHitResultAllowed(HitResult result) => true; + + /// <summary> + /// Sets hit windows with values that correspond to a difficulty parameter. + /// </summary> + /// <param name="difficulty">The parameter.</param> + public void SetDifficulty(double difficulty) + { + foreach (var range in GetRanges()) + { + var value = BeatmapDifficulty.DifficultyRange(difficulty, (range.Min, range.Average, range.Max)); + + switch (range.Result) + { + case HitResult.Miss: + miss = value; + break; + + case HitResult.Meh: + meh = value; + break; + + case HitResult.Ok: + ok = value; + break; + + case HitResult.Good: + good = value; + break; + + case HitResult.Great: + great = value; + break; + + case HitResult.Perfect: + perfect = value; + break; + } + } + } + + /// <summary> + /// Retrieves the <see cref="HitResult"/> for a time offset. + /// </summary> + /// <param name="timeOffset">The time offset.</param> + /// <returns>The hit result, or <see cref="HitResult.None"/> if <paramref name="timeOffset"/> doesn't result in a judgement.</returns> + public HitResult ResultFor(double timeOffset) + { + timeOffset = Math.Abs(timeOffset); + + for (var result = HitResult.Perfect; result >= HitResult.Miss; --result) + { + if (IsHitResultAllowed(result) && timeOffset <= WindowFor(result)) + return result; + } + + return HitResult.None; + } + + /// <summary> + /// Retrieves the hit window for a <see cref="HitResult"/>. + /// This is the number of +/- milliseconds allowed for the requested result (so the actual hittable range is double this). + /// </summary> + /// <param name="result">The expected <see cref="HitResult"/>.</param> + /// <returns>One half of the hit window for <paramref name="result"/>.</returns> + public double WindowFor(HitResult result) + { + switch (result) + { + case HitResult.Perfect: + return perfect; + + case HitResult.Great: + return great; + + case HitResult.Good: + return good; + + case HitResult.Ok: + return ok; + + case HitResult.Meh: + return meh; + + case HitResult.Miss: + return miss; + + default: + throw new ArgumentException("Unknown enum member", nameof(result)); + } + } + + /// <summary> + /// Given a time offset, whether the <see cref="HitObject"/> can ever be hit in the future with a non-<see cref="HitResult.Miss"/> result. + /// This happens if <paramref name="timeOffset"/> is less than what is required for <see cref="LowestSuccessfulHitResult"/>. + /// </summary> + /// <param name="timeOffset">The time offset.</param> + /// <returns>Whether the <see cref="HitObject"/> can be hit at any point in the future from this time offset.</returns> + public bool CanBeHit(double timeOffset) => timeOffset <= WindowFor(LowestSuccessfulHitResult()); + + /// <summary> + /// Retrieve a valid list of <see cref="DifficultyRange"/>s representing hit windows. + /// Defaults are provided but can be overridden to customise for a ruleset. + /// </summary> + protected virtual DifficultyRange[] GetRanges() => base_ranges; + + public class EmptyHitWindows : HitWindows + { + private static readonly DifficultyRange[] ranges = + { + new DifficultyRange(HitResult.Perfect, 0, 0, 0), + new DifficultyRange(HitResult.Miss, 0, 0, 0), + }; + + public override bool IsHitResultAllowed(HitResult result) + { + switch (result) + { + case HitResult.Perfect: + case HitResult.Miss: + return true; + } + + return false; + } + + protected override DifficultyRange[] GetRanges() => ranges; + } + } + + public struct DifficultyRange + { + public readonly HitResult Result; + + public double Min; + public double Average; + public double Max; + + public DifficultyRange(HitResult result, double min, double average, double max) + { + Result = result; + + Min = min; + Average = average; + Max = max; + } + } +} diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs new file mode 100644 index 0000000000..8aef615b5f --- /dev/null +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -0,0 +1,139 @@ +// 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. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Scoring +{ + public abstract class JudgementProcessor : Component + { + /// <summary> + /// Invoked when a new judgement has occurred. This occurs after the judgement has been processed by this <see cref="JudgementProcessor"/>. + /// </summary> + public event Action<JudgementResult> NewJudgement; + + /// <summary> + /// The maximum number of hits that can be judged. + /// </summary> + protected int MaxHits { get; private set; } + + /// <summary> + /// The total number of judged <see cref="HitObject"/>s at the current point in time. + /// </summary> + public int JudgedHits { get; private set; } + + private readonly BindableBool hasCompleted = new BindableBool(); + + /// <summary> + /// Whether all <see cref="Judgement"/>s have been processed. + /// </summary> + public IBindable<bool> HasCompleted => hasCompleted; + + /// <summary> + /// Applies a <see cref="IBeatmap"/> to this <see cref="ScoreProcessor"/>. + /// </summary> + /// <param name="beatmap">The <see cref="IBeatmap"/> to read properties from.</param> + public virtual void ApplyBeatmap(IBeatmap beatmap) + { + Reset(false); + SimulateAutoplay(beatmap); + Reset(true); + } + + /// <summary> + /// Applies the score change of a <see cref="JudgementResult"/> to this <see cref="ScoreProcessor"/>. + /// </summary> + /// <param name="result">The <see cref="JudgementResult"/> to apply.</param> + public void ApplyResult(JudgementResult result) + { + JudgedHits++; + + ApplyResultInternal(result); + + NewJudgement?.Invoke(result); + + updateHasCompleted(); + } + + /// <summary> + /// Reverts the score change of a <see cref="JudgementResult"/> that was applied to this <see cref="ScoreProcessor"/>. + /// </summary> + /// <param name="result">The judgement scoring result.</param> + public void RevertResult(JudgementResult result) + { + JudgedHits--; + + updateHasCompleted(); + + RevertResultInternal(result); + } + + /// <summary> + /// Applies the score change of a <see cref="JudgementResult"/> to this <see cref="ScoreProcessor"/>. + /// </summary> + /// <remarks> + /// Any changes applied via this method can be reverted via <see cref="RevertResultInternal"/>. + /// </remarks> + /// <param name="result">The <see cref="JudgementResult"/> to apply.</param> + protected abstract void ApplyResultInternal(JudgementResult result); + + /// <summary> + /// Reverts the score change of a <see cref="JudgementResult"/> that was applied to this <see cref="ScoreProcessor"/> via <see cref="ApplyResultInternal"/>. + /// </summary> + /// <param name="result">The judgement scoring result.</param> + protected abstract void RevertResultInternal(JudgementResult result); + + /// <summary> + /// Resets this <see cref="JudgementProcessor"/> to a default state. + /// </summary> + /// <param name="storeResults">Whether to store the current state of the <see cref="JudgementProcessor"/> for future use.</param> + protected virtual void Reset(bool storeResults) + { + if (storeResults) + MaxHits = JudgedHits; + + JudgedHits = 0; + } + + /// <summary> + /// Creates the <see cref="JudgementResult"/> that represents the scoring result for a <see cref="HitObject"/>. + /// </summary> + /// <param name="hitObject">The <see cref="HitObject"/> which was judged.</param> + /// <param name="judgement">The <see cref="Judgement"/> that provides the scoring information.</param> + protected virtual JudgementResult CreateResult(HitObject hitObject, Judgement judgement) => new JudgementResult(hitObject, judgement); + + /// <summary> + /// Simulates an autoplay of the <see cref="IBeatmap"/> to determine scoring values. + /// </summary> + /// <remarks>This provided temporarily. DO NOT USE.</remarks> + /// <param name="beatmap">The <see cref="IBeatmap"/> to simulate.</param> + protected virtual void SimulateAutoplay(IBeatmap beatmap) + { + foreach (var obj in beatmap.HitObjects) + simulate(obj); + + void simulate(HitObject obj) + { + foreach (var nested in obj.NestedHitObjects) + simulate(nested); + + var judgement = obj.CreateJudgement(); + + var result = CreateResult(obj, judgement); + if (result == null) + throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); + + result.Type = judgement.MaxResult; + ApplyResult(result); + } + } + + private void updateHasCompleted() => hasCompleted.Value = JudgedHits == MaxHits; + } +} diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 47ce28db4c..499673619f 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -7,40 +7,17 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.Extensions.TypeExtensions; -using osu.Framework.MathUtils; -using osu.Game.Beatmaps; +using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.UI; using osu.Game.Scoring; namespace osu.Game.Rulesets.Scoring { - public abstract class ScoreProcessor + public class ScoreProcessor : JudgementProcessor { - /// <summary> - /// Invoked when the <see cref="ScoreProcessor"/> is in a failed state. - /// This may occur regardless of whether an <see cref="AllJudged"/> event is invoked. - /// Return true if the fail was permitted. - /// </summary> - public event Func<bool> Failed; - - /// <summary> - /// Invoked when all <see cref="HitObject"/>s have been judged. - /// </summary> - public event Action AllJudged; - - /// <summary> - /// Invoked when a new judgement has occurred. This occurs after the judgement has been processed by the <see cref="ScoreProcessor"/>. - /// </summary> - public event Action<JudgementResult> NewJudgement; - - /// <summary> - /// Additional conditions on top of <see cref="DefaultFailCondition"/> that cause a failing state. - /// </summary> - public event Func<ScoreProcessor, JudgementResult, bool> FailConditions; + private const double max_score = 1000000; /// <summary> /// The current total score. @@ -52,11 +29,6 @@ namespace osu.Game.Rulesets.Scoring /// </summary> public readonly BindableDouble Accuracy = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; - /// <summary> - /// The current health. - /// </summary> - public readonly BindableDouble Health = new BindableDouble { MinValue = 0, MaxValue = 1 }; - /// <summary> /// The current combo. /// </summary> @@ -67,18 +39,13 @@ namespace osu.Game.Rulesets.Scoring /// </summary> public readonly Bindable<IReadOnlyList<Mod>> Mods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); - /// <summary> - /// Create a <see cref="HitWindows"/> for this processor. - /// </summary> - public virtual HitWindows CreateHitWindows() => new HitWindows(); - /// <summary> /// The current rank. /// </summary> public readonly Bindable<ScoreRank> Rank = new Bindable<ScoreRank>(ScoreRank.X); /// <summary> - /// THe highest combo achieved by this score. + /// The highest combo achieved by this score. /// </summary> public readonly BindableInt HighestCombo = new BindableInt(); @@ -88,31 +55,182 @@ namespace osu.Game.Rulesets.Scoring public readonly Bindable<ScoringMode> Mode = new Bindable<ScoringMode>(); /// <summary> - /// Whether all <see cref="Judgement"/>s have been processed. + /// The default portion of <see cref="max_score"/> awarded for hitting <see cref="HitObject"/>s accurately. Defaults to 30%. /// </summary> - protected virtual bool HasCompleted => false; + protected virtual double DefaultAccuracyPortion => 0.3; /// <summary> - /// Whether this ScoreProcessor has already triggered the failed state. + /// The default portion of <see cref="max_score"/> awarded for achieving a high combo. Default to 70%. /// </summary> - public virtual bool HasFailed { get; private set; } + protected virtual double DefaultComboPortion => 0.7; - /// <summary> - /// The default conditions for failing. - /// </summary> - protected virtual bool DefaultFailCondition => Precision.AlmostBigger(Health.MinValue, Health.Value); + private readonly double accuracyPortion; + private readonly double comboPortion; - protected ScoreProcessor() + private int maxAchievableCombo; + private double maxBaseScore; + private double rollingMaxBaseScore; + private double baseScore; + + private readonly List<HitEvent> hitEvents = new List<HitEvent>(); + private HitObject lastHitObject; + + private double scoreMultiplier = 1; + + public ScoreProcessor() { - Combo.ValueChanged += delegate { HighestCombo.Value = Math.Max(HighestCombo.Value, Combo.Value); }; - Accuracy.ValueChanged += delegate + accuracyPortion = DefaultAccuracyPortion; + comboPortion = DefaultComboPortion; + + if (!Precision.AlmostEquals(1.0, accuracyPortion + comboPortion)) + throw new InvalidOperationException($"{nameof(DefaultAccuracyPortion)} + {nameof(DefaultComboPortion)} must equal 1."); + + Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); + Accuracy.ValueChanged += accuracy => { - Rank.Value = rankFrom(Accuracy.Value); + Rank.Value = rankFrom(accuracy.NewValue); foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>()) - Rank.Value = mod.AdjustRank(Rank.Value, Accuracy.Value); + Rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); + }; + + Mode.ValueChanged += _ => updateScore(); + Mods.ValueChanged += mods => + { + scoreMultiplier = 1; + + foreach (var m in mods.NewValue) + scoreMultiplier *= m.ScoreMultiplier; + + updateScore(); }; } + private readonly Dictionary<HitResult, int> scoreResultCounts = new Dictionary<HitResult, int>(); + + protected sealed override void ApplyResultInternal(JudgementResult result) + { + result.ComboAtJudgement = Combo.Value; + result.HighestComboAtJudgement = HighestCombo.Value; + + if (result.FailedAtJudgement) + return; + + if (!result.Type.IsScorable()) + return; + + if (result.Type.AffectsCombo()) + { + switch (result.Type) + { + case HitResult.Miss: + case HitResult.LargeTickMiss: + Combo.Value = 0; + break; + + default: + Combo.Value++; + break; + } + } + + double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; + + if (!result.Type.IsBonus()) + { + baseScore += scoreIncrease; + rollingMaxBaseScore += result.Judgement.MaxNumericResult; + } + + scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) + 1; + + hitEvents.Add(CreateHitEvent(result)); + lastHitObject = result.HitObject; + + updateScore(); + } + + /// <summary> + /// Creates the <see cref="HitEvent"/> that describes a <see cref="JudgementResult"/>. + /// </summary> + /// <param name="result">The <see cref="JudgementResult"/> to describe.</param> + /// <returns>The <see cref="HitEvent"/>.</returns> + protected virtual HitEvent CreateHitEvent(JudgementResult result) + => new HitEvent(result.TimeOffset, result.Type, result.HitObject, lastHitObject, null); + + protected sealed override void RevertResultInternal(JudgementResult result) + { + Combo.Value = result.ComboAtJudgement; + HighestCombo.Value = result.HighestComboAtJudgement; + + if (result.FailedAtJudgement) + return; + + if (!result.Type.IsScorable()) + return; + + double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; + + if (!result.Type.IsBonus()) + { + baseScore -= scoreIncrease; + rollingMaxBaseScore -= result.Judgement.MaxNumericResult; + } + + scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) - 1; + + Debug.Assert(hitEvents.Count > 0); + lastHitObject = hitEvents[^1].LastHitObject; + hitEvents.RemoveAt(hitEvents.Count - 1); + + updateScore(); + } + + private void updateScore() + { + if (rollingMaxBaseScore != 0) + Accuracy.Value = baseScore / rollingMaxBaseScore; + + TotalScore.Value = getScore(Mode.Value); + } + + private double getScore(ScoringMode mode) + { + return GetScore(mode, maxAchievableCombo, + maxBaseScore > 0 ? baseScore / maxBaseScore : 0, + maxAchievableCombo > 0 ? (double)HighestCombo.Value / maxAchievableCombo : 1, + scoreResultCounts); + } + + /// <summary> + /// Computes the total score. + /// </summary> + /// <param name="mode">The <see cref="ScoringMode"/> to compute the total score in.</param> + /// <param name="maxCombo">The maximum combo achievable in the beatmap.</param> + /// <param name="accuracyRatio">The accuracy percentage achieved by the player.</param> + /// <param name="comboRatio">The proportion of <paramref name="maxCombo"/> achieved by the player.</param> + /// <param name="statistics">Any statistics to be factored in.</param> + /// <returns>The total score.</returns> + public double GetScore(ScoringMode mode, int maxCombo, double accuracyRatio, double comboRatio, Dictionary<HitResult, int> statistics) + { + switch (mode) + { + default: + case ScoringMode.Standardised: + double accuracyScore = accuracyPortion * accuracyRatio; + double comboScore = comboPortion * comboRatio; + + return (max_score * (accuracyScore + comboScore) + getBonusScore(statistics)) * scoreMultiplier; + + case ScoringMode.Classic: + // should emulate osu-stable's scoring as closely as we can (https://osu.ppy.sh/help/wiki/Score/ScoreV1) + return getBonusScore(statistics) + (accuracyRatio * Math.Max(1, maxCombo) * 300) * (1 + Math.Max(0, (comboRatio * maxCombo) - 1) * scoreMultiplier / 25); + } + } + + private double getBonusScore(Dictionary<HitResult, int> statistics) + => statistics.GetOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE + + statistics.GetOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE; + private ScoreRank rankFrom(double acc) { if (acc == 1) @@ -129,50 +247,42 @@ namespace osu.Game.Rulesets.Scoring return ScoreRank.D; } + public int GetStatistic(HitResult result) => scoreResultCounts.GetOrDefault(result); + + public double GetStandardisedScore() => getScore(ScoringMode.Standardised); + /// <summary> /// Resets this ScoreProcessor to a default state. /// </summary> /// <param name="storeResults">Whether to store the current state of the <see cref="ScoreProcessor"/> for future use.</param> - protected virtual void Reset(bool storeResults) + protected override void Reset(bool storeResults) { + base.Reset(storeResults); + + scoreResultCounts.Clear(); + hitEvents.Clear(); + lastHitObject = null; + + if (storeResults) + { + maxAchievableCombo = HighestCombo.Value; + maxBaseScore = baseScore; + } + + baseScore = 0; + rollingMaxBaseScore = 0; + TotalScore.Value = 0; Accuracy.Value = 1; - Health.Value = 1; Combo.Value = 0; Rank.Value = ScoreRank.X; HighestCombo.Value = 0; - - HasFailed = false; } - /// <summary> - /// Checks if the score is in a failed state and notifies subscribers. - /// <para> - /// This can only ever notify subscribers once. - /// </para> - /// </summary> - protected void UpdateFailed(JudgementResult result) + protected override void Dispose(bool isDisposing) { - if (HasFailed) - return; - - if (!DefaultFailCondition && FailConditions?.Invoke(this, result) != true) - return; - - if (Failed?.Invoke() != false) - HasFailed = true; - } - - /// <summary> - /// Notifies subscribers of <see cref="NewJudgement"/> that a new judgement has occurred. - /// </summary> - /// <param name="result">The judgement scoring result to notify subscribers of.</param> - protected void NotifyNewJudgement(JudgementResult result) - { - NewJudgement?.Invoke(result); - - if (HasCompleted) - AllJudged?.Invoke(); + base.Dispose(isDisposing); + hitEvents.Clear(); } /// <summary> @@ -180,259 +290,24 @@ namespace osu.Game.Rulesets.Scoring /// </summary> public virtual void PopulateScore(ScoreInfo score) { - score.TotalScore = (long)Math.Round(TotalScore.Value); + score.TotalScore = (long)Math.Round(GetStandardisedScore()); score.Combo = Combo.Value; score.MaxCombo = HighestCombo.Value; score.Accuracy = Math.Round(Accuracy.Value, 4); score.Rank = Rank.Value; score.Date = DateTimeOffset.Now; - var hitWindows = CreateHitWindows(); - - foreach (var result in Enum.GetValues(typeof(HitResult)).OfType<HitResult>().Where(r => r > HitResult.None && hitWindows.IsHitResultAllowed(r))) + foreach (var result in Enum.GetValues(typeof(HitResult)).OfType<HitResult>().Where(r => r.IsScorable())) score.Statistics[result] = GetStatistic(result); - } - protected abstract int GetStatistic(HitResult result); - - public abstract double GetStandardisedScore(); - } - - public class ScoreProcessor<TObject> : ScoreProcessor - where TObject : HitObject - { - private const double base_portion = 0.3; - private const double combo_portion = 0.7; - private const double max_score = 1000000; - - protected sealed override bool HasCompleted => JudgedHits == MaxHits; - - protected int MaxHits { get; private set; } - protected int JudgedHits { get; private set; } - - private double maxHighestCombo; - - private double maxBaseScore; - private double rollingMaxBaseScore; - private double baseScore; - private double bonusScore; - - protected ScoreProcessor() - { - } - - public ScoreProcessor(DrawableRuleset<TObject> drawableRuleset) - { - Debug.Assert(base_portion + combo_portion == 1.0); - - drawableRuleset.OnNewResult += applyResult; - drawableRuleset.OnRevertResult += revertResult; - - ApplyBeatmap(drawableRuleset.Beatmap); - SimulateAutoplay(drawableRuleset.Beatmap); - Reset(true); - - if (maxBaseScore == 0 || maxHighestCombo == 0) - { - Mode.Value = ScoringMode.Classic; - Mode.Disabled = true; - } - - Mode.ValueChanged += _ => updateScore(); + score.HitEvents = hitEvents; } /// <summary> - /// Applies any properties of the <see cref="Beatmap{TObject}"/> which affect scoring to this <see cref="ScoreProcessor{TObject}"/>. + /// Create a <see cref="HitWindows"/> for this processor. /// </summary> - /// <param name="beatmap">The <see cref="Beatmap{TObject}"/> to read properties from.</param> - protected virtual void ApplyBeatmap(Beatmap<TObject> beatmap) - { - } - - /// <summary> - /// Simulates an autoplay of the <see cref="Beatmap{TObject}"/> to determine scoring values. - /// </summary> - /// <remarks>This provided temporarily. DO NOT USE.</remarks> - /// <param name="beatmap">The <see cref="Beatmap{TObject}"/> to simulate.</param> - protected virtual void SimulateAutoplay(Beatmap<TObject> beatmap) - { - foreach (var obj in beatmap.HitObjects) - simulate(obj); - - void simulate(HitObject obj) - { - foreach (var nested in obj.NestedHitObjects) - simulate(nested); - - var judgement = obj.CreateJudgement(); - if (judgement == null) - return; - - var result = CreateResult(judgement); - if (result == null) - throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); - - result.Type = judgement.MaxResult; - - applyResult(result); - } - } - - /// <summary> - /// Applies the score change of a <see cref="JudgementResult"/> to this <see cref="ScoreProcessor"/>. - /// </summary> - /// <param name="result">The <see cref="JudgementResult"/> to apply.</param> - private void applyResult(JudgementResult result) - { - ApplyResult(result); - updateScore(); - - UpdateFailed(result); - NotifyNewJudgement(result); - } - - /// <summary> - /// Reverts the score change of a <see cref="JudgementResult"/> that was applied to this <see cref="ScoreProcessor"/>. - /// </summary> - /// <param name="result">The judgement scoring result.</param> - private void revertResult(JudgementResult result) - { - RevertResult(result); - updateScore(); - } - - private readonly Dictionary<HitResult, int> scoreResultCounts = new Dictionary<HitResult, int>(); - - /// <summary> - /// Applies the score change of a <see cref="JudgementResult"/> to this <see cref="ScoreProcessor"/>. - /// </summary> - /// <param name="result">The <see cref="JudgementResult"/> to apply.</param> - protected virtual void ApplyResult(JudgementResult result) - { - result.ComboAtJudgement = Combo.Value; - result.HighestComboAtJudgement = HighestCombo.Value; - result.HealthAtJudgement = Health.Value; - - JudgedHits++; - - if (result.Type != HitResult.None) - scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) + 1; - - if (result.Judgement.AffectsCombo) - { - switch (result.Type) - { - case HitResult.None: - break; - - case HitResult.Miss: - Combo.Value = 0; - break; - - default: - Combo.Value++; - break; - } - } - - if (result.Judgement.IsBonus) - { - if (result.IsHit) - bonusScore += result.Judgement.NumericResultFor(result); - } - else - { - baseScore += result.Judgement.NumericResultFor(result); - rollingMaxBaseScore += result.Judgement.MaxNumericResult; - } - - Health.Value += HealthAdjustmentFactorFor(result) * result.Judgement.HealthIncreaseFor(result); - } - - /// <summary> - /// Reverts the score change of a <see cref="JudgementResult"/> that was applied to this <see cref="ScoreProcessor"/>. - /// </summary> - /// <param name="result">The judgement scoring result.</param> - protected virtual void RevertResult(JudgementResult result) - { - Combo.Value = result.ComboAtJudgement; - HighestCombo.Value = result.HighestComboAtJudgement; - Health.Value = result.HealthAtJudgement; - - JudgedHits--; - - if (result.Type != HitResult.None) - scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) - 1; - - if (result.Judgement.IsBonus) - { - if (result.IsHit) - bonusScore -= result.Judgement.NumericResultFor(result); - } - else - { - baseScore -= result.Judgement.NumericResultFor(result); - rollingMaxBaseScore -= result.Judgement.MaxNumericResult; - } - } - - /// <summary> - /// An adjustment factor which is multiplied into the health increase provided by a <see cref="JudgementResult"/>. - /// </summary> - /// <param name="result">The <see cref="JudgementResult"/> for which the adjustment should apply.</param> - /// <returns>The adjustment factor.</returns> - protected virtual double HealthAdjustmentFactorFor(JudgementResult result) => 1; - - private void updateScore() - { - if (rollingMaxBaseScore != 0) - Accuracy.Value = baseScore / rollingMaxBaseScore; - - TotalScore.Value = getScore(Mode.Value); - } - - private double getScore(ScoringMode mode) - { - switch (mode) - { - default: - case ScoringMode.Standardised: - return max_score * (base_portion * baseScore / maxBaseScore + combo_portion * HighestCombo.Value / maxHighestCombo) + bonusScore; - - case ScoringMode.Classic: - // should emulate osu-stable's scoring as closely as we can (https://osu.ppy.sh/help/wiki/Score/ScoreV1) - return bonusScore + baseScore * (1 + Math.Max(0, HighestCombo.Value - 1) / 25); - } - } - - protected override int GetStatistic(HitResult result) => scoreResultCounts.GetOrDefault(result); - - public override double GetStandardisedScore() => getScore(ScoringMode.Standardised); - - protected override void Reset(bool storeResults) - { - scoreResultCounts.Clear(); - - if (storeResults) - { - MaxHits = JudgedHits; - maxHighestCombo = HighestCombo.Value; - maxBaseScore = baseScore; - } - - base.Reset(storeResults); - - JudgedHits = 0; - baseScore = 0; - rollingMaxBaseScore = 0; - bonusScore = 0; - } - - /// <summary> - /// Creates the <see cref="JudgementResult"/> that represents the scoring result for a <see cref="HitObject"/>. - /// </summary> - /// <param name="judgement">The <see cref="Judgement"/> that provides the scoring information.</param> - protected virtual JudgementResult CreateResult(Judgement judgement) => new JudgementResult(judgement); + [Obsolete("Method is now unused.")] // Can be removed 20210328 + public virtual HitWindows CreateHitWindows() => new HitWindows(); } public enum ScoringMode diff --git a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs index 9bab065d1e..4b3c3f90f0 100644 --- a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs +++ b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs @@ -20,7 +20,13 @@ namespace osu.Game.Rulesets.Timing /// <summary> /// The aggregate multiplier which this <see cref="MultiplierControlPoint"/> provides. /// </summary> - public double Multiplier => Velocity * DifficultyPoint.SpeedMultiplier * 1000 / TimingPoint.BeatLength; + public double Multiplier => Velocity * DifficultyPoint.SpeedMultiplier * BaseBeatLength / TimingPoint.BeatLength; + + /// <summary> + /// The base beat length to scale the <see cref="TimingPoint"/> provided multiplier relative to. + /// </summary> + /// <example>For a <see cref="BaseBeatLength"/> of 1000, a <see cref="TimingPoint"/> with a beat length of 500 will increase the multiplier by 2.</example> + public double BaseBeatLength = TimingControlPoint.DEFAULT_BEAT_LENGTH; /// <summary> /// The velocity multiplier. diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 52fba9cab3..f6cf836fe7 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -13,6 +13,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics.Cursor; using osu.Framework.Input; @@ -26,6 +27,7 @@ using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osuTK; namespace osu.Game.Rulesets.UI { @@ -36,6 +38,10 @@ namespace osu.Game.Rulesets.UI public abstract class DrawableRuleset<TObject> : DrawableRuleset, IProvideCursor, ICanAttachKeyCounter where TObject : HitObject { + public override event Action<JudgementResult> OnNewResult; + + public override event Action<JudgementResult> OnRevertResult; + /// <summary> /// The selected variant. /// </summary> @@ -46,7 +52,7 @@ namespace osu.Game.Rulesets.UI /// </summary> public PassThroughInputManager KeyBindingInputManager; - public override double GameplayStartTime => Objects.First().StartTime - 2000; + public override double GameplayStartTime => Objects.FirstOrDefault()?.StartTime - 2000 ?? 0; private readonly Lazy<Playfield> playfield; @@ -55,27 +61,32 @@ namespace osu.Game.Rulesets.UI /// </summary> public override Playfield Playfield => playfield.Value; - /// <summary> - /// Place to put drawables above hit objects but below UI. - /// </summary> - public Container Overlays { get; private set; } + public override Container Overlays { get; } = new Container { RelativeSizeAxes = Axes.Both }; - public override GameplayClock FrameStableClock => frameStabilityContainer.GameplayClock; + public override Container FrameStableComponents { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + public override IFrameStableClock FrameStableClock => frameStabilityContainer.FrameStableClock; + + private bool frameStablePlayback = true; /// <summary> - /// Invoked when a <see cref="JudgementResult"/> has been applied by a <see cref="DrawableHitObject"/>. + /// Whether to enable frame-stable playback. /// </summary> - public event Action<JudgementResult> OnNewResult; - - /// <summary> - /// Invoked when a <see cref="JudgementResult"/> is being reverted by a <see cref="DrawableHitObject"/>. - /// </summary> - public event Action<JudgementResult> OnRevertResult; + internal bool FrameStablePlayback + { + get => frameStablePlayback; + set + { + frameStablePlayback = false; + if (frameStabilityContainer != null) + frameStabilityContainer.FrameStablePlayback = value; + } + } /// <summary> /// The beatmap. /// </summary> - public Beatmap<TObject> Beatmap; + public readonly Beatmap<TObject> Beatmap; public override IEnumerable<HitObject> Objects => Beatmap.HitObjects; @@ -85,32 +96,34 @@ namespace osu.Game.Rulesets.UI /// The mods which are to be applied. /// </summary> [Cached(typeof(IReadOnlyList<Mod>))] - private readonly IReadOnlyList<Mod> mods; + protected readonly IReadOnlyList<Mod> Mods; private FrameStabilityContainer frameStabilityContainer; private OnScreenDisplay onScreenDisplay; + private DrawableRulesetDependencies dependencies; + /// <summary> /// Creates a ruleset visualisation for the provided ruleset and beatmap. /// </summary> /// <param name="ruleset">The ruleset being represented.</param> - /// <param name="workingBeatmap">The beatmap to create the hit renderer for.</param> + /// <param name="beatmap">The beatmap to create the hit renderer for.</param> /// <param name="mods">The <see cref="Mod"/>s to apply.</param> - protected DrawableRuleset(Ruleset ruleset, WorkingBeatmap workingBeatmap, IReadOnlyList<Mod> mods) + protected DrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null) : base(ruleset) { - if (workingBeatmap == null) - throw new ArgumentException("Beatmap cannot be null.", nameof(workingBeatmap)); + if (beatmap == null) + throw new ArgumentNullException(nameof(beatmap), "Beatmap cannot be null."); - this.mods = mods.ToArray(); + if (!(beatmap is Beatmap<TObject> tBeatmap)) + throw new ArgumentException($"{GetType()} expected the beatmap to contain hitobjects of type {typeof(TObject)}.", nameof(beatmap)); + + Beatmap = tBeatmap; + Mods = mods?.ToArray() ?? Array.Empty<Mod>(); RelativeSizeAxes = Axes.Both; - Beatmap = (Beatmap<TObject>)workingBeatmap.GetPlayableBeatmap(ruleset.RulesetInfo, mods); - - applyBeatmapMods(mods); - KeyBindingInputManager = CreateInputManager(); playfield = new Lazy<Playfield>(CreatePlayfield); @@ -125,36 +138,40 @@ namespace osu.Game.Rulesets.UI protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies = new DrawableRulesetDependencies(Ruleset, base.CreateChildDependencies(parent)); + + Config = dependencies.RulesetConfigManager; onScreenDisplay = dependencies.Get<OnScreenDisplay>(); - - Config = dependencies.Get<RulesetConfigCache>().GetConfigFor(Ruleset); - if (Config != null) - { - dependencies.Cache(Config); onScreenDisplay?.BeginTracking(this, Config); - } return dependencies; } public virtual PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new PlayfieldAdjustmentContainer(); + [Resolved] + private OsuConfigManager config { get; set; } + [BackgroundDependencyLoader] - private void load(OsuConfigManager config, CancellationToken? cancellationToken) + private void load(CancellationToken? cancellationToken) { InternalChildren = new Drawable[] { frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) { - Child = KeyBindingInputManager - .WithChild(CreatePlayfieldAdjustmentContainer() - .WithChild(Playfield) - ) + FrameStablePlayback = FrameStablePlayback, + Children = new Drawable[] + { + FrameStableComponents, + KeyBindingInputManager + .WithChild(CreatePlayfieldAdjustmentContainer() + .WithChild(Playfield) + ), + Overlays, + } }, - Overlays = new Container { RelativeSizeAxes = Axes.Both } }; if ((ResumeOverlay = CreateResumeOverlay()) != null) @@ -164,11 +181,18 @@ namespace osu.Game.Rulesets.UI .WithChild(ResumeOverlay))); } - applyRulesetMods(mods, config); + RegenerateAutoplay(); loadObjects(cancellationToken); } + public void RegenerateAutoplay() + { + // for now this is applying mods which aren't just autoplay. + // we'll need to reconsider this flow in the future. + applyRulesetMods(Mods, config); + } + /// <summary> /// Creates and adds drawable representations of hit objects to the play field. /// </summary> @@ -184,8 +208,8 @@ namespace osu.Game.Rulesets.UI Playfield.PostProcess(); - foreach (var mod in mods.OfType<IApplicableToDrawableHitObjects>()) - mod.ApplyToDrawableHitObjects(Playfield.HitObjectContainer.Objects); + foreach (var mod in Mods.OfType<IApplicableToDrawableHitObjects>()) + mod.ApplyToDrawableHitObjects(Playfield.AllHitObjects); } public override void RequestResume(Action continueResume) @@ -200,14 +224,16 @@ namespace osu.Game.Rulesets.UI continueResume(); } - public ResumeOverlay ResumeOverlay { get; private set; } - - protected virtual ResumeOverlay CreateResumeOverlay() => null; + public override void CancelResume() + { + // called if the user pauses while the resume overlay is open + ResumeOverlay?.Hide(); + } /// <summary> - /// Creates and adds the visual representation of a <see cref="TObject"/> to this <see cref="DrawableRuleset{TObject}"/>. + /// Creates and adds the visual representation of a <typeparamref name="TObject"/> to this <see cref="DrawableRuleset{TObject}"/>. /// </summary> - /// <param name="hitObject">The <see cref="TObject"/> to add the visual representation for.</param> + /// <param name="hitObject">The <typeparamref name="TObject"/> to add the visual representation for.</param> private void addHitObject(TObject hitObject) { var drawableObject = CreateDrawableRepresentation(hitObject); @@ -221,6 +247,21 @@ namespace osu.Game.Rulesets.UI Playfield.Add(drawableObject); } + public override void SetRecordTarget(Replay recordingReplay) + { + if (!(KeyBindingInputManager is IHasRecordingHandler recordingInputManager)) + throw new InvalidOperationException($"A {nameof(KeyBindingInputManager)} which supports recording is not available"); + + var recorder = CreateReplayRecorder(recordingReplay); + + if (recorder == null) + return; + + recorder.ScreenSpaceToGamefield = Playfield.ScreenSpaceToGamefield; + + recordingInputManager.Recorder = recorder; + } + public override void SetReplayScore(Score replayScore) { if (!(KeyBindingInputManager is IHasReplayHandler replayInputManager)) @@ -261,27 +302,14 @@ namespace osu.Game.Rulesets.UI protected virtual ReplayInputHandler CreateReplayInputHandler(Replay replay) => null; + protected virtual ReplayRecorder CreateReplayRecorder(Replay replay) => null; + /// <summary> /// Creates a Playfield. /// </summary> /// <returns>The Playfield.</returns> protected abstract Playfield CreatePlayfield(); - public override ScoreProcessor CreateScoreProcessor() => new ScoreProcessor<TObject>(this); - - /// <summary> - /// Applies the active mods to the Beatmap. - /// </summary> - /// <param name="mods"></param> - private void applyBeatmapMods(IReadOnlyList<Mod> mods) - { - if (mods == null) - return; - - foreach (var mod in mods.OfType<IApplicableToBeatmap<TObject>>()) - mod.ApplyToBeatmap(Beatmap); - } - /// <summary> /// Applies the active mods to this DrawableRuleset. /// </summary> @@ -303,6 +331,9 @@ namespace osu.Game.Rulesets.UI protected override bool OnHover(HoverEvent e) => true; // required for IProvideCursor + // only show the cursor when within the playfield, by default. + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Playfield.ReceivePositionalInputAt(screenSpacePos); + CursorContainer IProvideCursor.Cursor => Playfield.Cursor; public override GameplayCursorContainer Cursor => Playfield.Cursor; @@ -320,6 +351,9 @@ namespace osu.Game.Rulesets.UI onScreenDisplay?.StopTracking(this, Config); Config = null; } + + // Dispose the components created by this dependency container. + dependencies?.Dispose(); } } @@ -332,6 +366,16 @@ namespace osu.Game.Rulesets.UI /// </summary> public abstract class DrawableRuleset : CompositeDrawable { + /// <summary> + /// Invoked when a <see cref="JudgementResult"/> has been applied by a <see cref="DrawableHitObject"/>. + /// </summary> + public abstract event Action<JudgementResult> OnNewResult; + + /// <summary> + /// Invoked when a <see cref="JudgementResult"/> is being reverted by a <see cref="DrawableHitObject"/>. + /// </summary> + public abstract event Action<JudgementResult> OnRevertResult; + /// <summary> /// Whether a replay is currently loaded. /// </summary> @@ -347,10 +391,20 @@ namespace osu.Game.Rulesets.UI /// </summary> public abstract Playfield Playfield { get; } + /// <summary> + /// Content to be placed above hitobjects. Will be affected by frame stability. + /// </summary> + public abstract Container Overlays { get; } + + /// <summary> + /// Components to be run potentially multiple times in line with frame-stable gameplay. + /// </summary> + public abstract Container FrameStableComponents { get; } + /// <summary> /// The frame-stable clock which is being used for playfield display. /// </summary> - public abstract GameplayClock FrameStableClock { get; } + public abstract IFrameStableClock FrameStableClock { get; } /// <summary>~ /// The associated ruleset. @@ -387,12 +441,54 @@ namespace osu.Game.Rulesets.UI /// </summary> public abstract GameplayCursorContainer Cursor { get; } + /// <summary> + /// An optional overlay used when resuming gameplay from a paused state. + /// </summary> + public ResumeOverlay ResumeOverlay { get; protected set; } + + /// <summary> + /// Returns first available <see cref="HitWindows"/> provided by a <see cref="HitObject"/>. + /// </summary> + [CanBeNull] + public HitWindows FirstAvailableHitWindows + { + get + { + foreach (var h in Objects) + { + if (h.HitWindows.WindowFor(HitResult.Miss) > 0) + return h.HitWindows; + + foreach (var n in h.NestedHitObjects) + { + if (h.HitWindows.WindowFor(HitResult.Miss) > 0) + return n.HitWindows; + } + } + + return null; + } + } + + protected virtual ResumeOverlay CreateResumeOverlay() => null; + + /// <summary> + /// Whether to display gameplay overlays, such as <see cref="HUDOverlay"/> and <see cref="BreakOverlay"/>. + /// </summary> + public virtual bool AllowGameplayOverlays => true; + /// <summary> /// Sets a replay to be used, overriding local input. /// </summary> /// <param name="replayScore">The replay, null for local input.</param> public abstract void SetReplayScore(Score replayScore); + /// <summary> + /// Sets a replay to be used to record gameplay. + /// </summary> + /// <param name="recordingReplay">The target to be recorded to.</param> + public abstract void SetRecordTarget(Replay recordingReplay); + /// <summary> /// Invoked when the interactive user requests resuming from a paused state. /// Allows potentially delaying the resume process until an interaction is performed. @@ -401,11 +497,9 @@ namespace osu.Game.Rulesets.UI public abstract void RequestResume(Action continueResume); /// <summary> - /// Create a <see cref="ScoreProcessor"/> for the associated ruleset and link with this - /// <see cref="DrawableRuleset"/>. + /// Invoked when the user requests to pause while the resume overlay is active. /// </summary> - /// <returns>A score processor.</returns> - public abstract ScoreProcessor CreateScoreProcessor(); + public abstract void CancelResume(); } public class BeatmapInvalidForRulesetException : ArgumentException diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs new file mode 100644 index 0000000000..a9b2a15b35 --- /dev/null +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -0,0 +1,173 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Game.Rulesets.Configuration; + +namespace osu.Game.Rulesets.UI +{ + public class DrawableRulesetDependencies : DependencyContainer, IDisposable + { + /// <summary> + /// The texture store to be used for the ruleset. + /// </summary> + public TextureStore TextureStore { get; } + + /// <summary> + /// The sample store to be used for the ruleset. + /// </summary> + /// <remarks> + /// This is the local sample store pointing to the ruleset sample resources, + /// the cached sample store (<see cref="FallbackSampleStore"/>) retrieves from + /// this store and falls back to the parent store if this store doesn't have the requested sample. + /// </remarks> + public ISampleStore SampleStore { get; } + + /// <summary> + /// The ruleset config manager. + /// </summary> + public IRulesetConfigManager RulesetConfigManager { get; private set; } + + public DrawableRulesetDependencies(Ruleset ruleset, IReadOnlyDependencyContainer parent) + : base(parent) + { + var resources = ruleset.CreateResourceStore(); + + if (resources != null) + { + TextureStore = new TextureStore(new TextureLoaderStore(new NamespacedResourceStore<byte[]>(resources, @"Textures"))); + CacheAs(TextureStore = new FallbackTextureStore(TextureStore, parent.Get<TextureStore>())); + + SampleStore = parent.Get<AudioManager>().GetSampleStore(new NamespacedResourceStore<byte[]>(resources, @"Samples")); + SampleStore.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; + CacheAs(SampleStore = new FallbackSampleStore(SampleStore, parent.Get<ISampleStore>())); + } + + RulesetConfigManager = parent.Get<RulesetConfigCache>().GetConfigFor(ruleset); + if (RulesetConfigManager != null) + Cache(RulesetConfigManager); + } + + #region Disposal + + ~DrawableRulesetDependencies() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private bool isDisposed; + + protected void Dispose(bool disposing) + { + if (isDisposed) + return; + + isDisposed = true; + + SampleStore?.Dispose(); + TextureStore?.Dispose(); + RulesetConfigManager = null; + } + + #endregion + + /// <summary> + /// A sample store which adds a fallback source and prevents disposal of the fallback source. + /// </summary> + private class FallbackSampleStore : ISampleStore + { + private readonly ISampleStore primary; + private readonly ISampleStore fallback; + + public FallbackSampleStore(ISampleStore primary, ISampleStore fallback) + { + this.primary = primary; + this.fallback = fallback; + } + + public SampleChannel Get(string name) => primary.Get(name) ?? fallback.Get(name); + + public Task<SampleChannel> GetAsync(string name) => primary.GetAsync(name) ?? fallback.GetAsync(name); + + public Stream GetStream(string name) => primary.GetStream(name) ?? fallback.GetStream(name); + + public IEnumerable<string> GetAvailableResources() => throw new NotSupportedException(); + + public void AddAdjustment(AdjustableProperty type, BindableNumber<double> adjustBindable) => throw new NotSupportedException(); + + public void RemoveAdjustment(AdjustableProperty type, BindableNumber<double> adjustBindable) => throw new NotSupportedException(); + + public void RemoveAllAdjustments(AdjustableProperty type) => throw new NotSupportedException(); + + public BindableNumber<double> Volume => throw new NotSupportedException(); + + public BindableNumber<double> Balance => throw new NotSupportedException(); + + public BindableNumber<double> Frequency => throw new NotSupportedException(); + + public BindableNumber<double> Tempo => throw new NotSupportedException(); + + public IBindable<double> GetAggregate(AdjustableProperty type) => throw new NotSupportedException(); + + public IBindable<double> AggregateVolume => throw new NotSupportedException(); + + public IBindable<double> AggregateBalance => throw new NotSupportedException(); + + public IBindable<double> AggregateFrequency => throw new NotSupportedException(); + + public IBindable<double> AggregateTempo => throw new NotSupportedException(); + + public int PlaybackConcurrency + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public void Dispose() + { + primary?.Dispose(); + } + } + + /// <summary> + /// A texture store which adds a fallback source and prevents disposal of the fallback source. + /// </summary> + private class FallbackTextureStore : TextureStore + { + private readonly TextureStore primary; + private readonly TextureStore fallback; + + public FallbackTextureStore(TextureStore primary, TextureStore fallback) + { + this.primary = primary; + this.fallback = fallback; + } + + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) + => primary.Get(name, wrapModeS, wrapModeT) ?? fallback.Get(name, wrapModeS, wrapModeT); + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + primary?.Dispose(); + } + } + } +} diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index 1cc56fff8b..e9865f6c8b 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -2,7 +2,10 @@ // 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.Timing; @@ -13,7 +16,7 @@ namespace osu.Game.Rulesets.UI { /// <summary> /// A container which consumes a parent gameplay clock and standardises frame counts for children. - /// Will ensure a minimum of 40 frames per clock second is maintained, regardless of any system lag or seeks. + /// Will ensure a minimum of 50 frames per clock second is maintained, regardless of any system lag or seeks. /// </summary> public class FrameStabilityContainer : Container, IHasReplayHandler { @@ -24,14 +27,21 @@ namespace osu.Game.Rulesets.UI /// </summary> public int MaxCatchUpFrames { get; set; } = 5; - [Cached] - public GameplayClock GameplayClock { get; } + /// <summary> + /// Whether to enable frame-stable playback. + /// </summary> + internal bool FrameStablePlayback = true; + + public IFrameStableClock FrameStableClock => frameStableClock; + + [Cached(typeof(GameplayClock))] + private readonly FrameStabilityClock frameStableClock; public FrameStabilityContainer(double gameplayStartTime = double.MinValue) { RelativeSizeAxes = Axes.Both; - GameplayClock = new GameplayClock(framedClock = new FramedClock(manualClock = new ManualClock())); + frameStableClock = new FrameStabilityClock(framedClock = new FramedClock(manualClock = new ManualClock())); this.gameplayStartTime = gameplayStartTime; } @@ -42,13 +52,18 @@ namespace osu.Game.Rulesets.UI private IFrameBasedClock parentGameplayClock; + /// <summary> + /// The current direction of playback to be exposed to frame stable children. + /// </summary> + private int direction; + [BackgroundDependencyLoader(true)] - private void load(GameplayClock clock) + private void load(GameplayClock clock, ISamplePlaybackDisabler sampleDisabler) { if (clock != null) { - parentGameplayClock = clock; - GameplayClock.IsPaused.BindTo(clock.IsPaused); + parentGameplayClock = frameStableClock.ParentGameplayClock = clock; + frameStableClock.IsPaused.BindTo(clock.IsPaused); } } @@ -58,21 +73,11 @@ namespace osu.Game.Rulesets.UI setClock(); } - /// <summary> - /// Whether we are running up-to-date with our parent clock. - /// If not, we will need to keep processing children until we catch up. - /// </summary> - private bool requireMoreUpdateLoops; + private PlaybackState state; - /// <summary> - /// Whether we are in a valid state (ie. should we keep processing children frames). - /// This should be set to false when the replay is, for instance, waiting for future frames to arrive. - /// </summary> - private bool validState; + protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate && state != PlaybackState.NotValid; - protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate && validState; - - private bool isAttached => ReplayInputHandler != null; + private bool hasReplayAttached => ReplayInputHandler != null; private const double sixty_frame_time = 1000.0 / 60; @@ -80,100 +85,199 @@ namespace osu.Game.Rulesets.UI public override bool UpdateSubTree() { - requireMoreUpdateLoops = true; - validState = !GameplayClock.IsPaused.Value; + int loops = MaxCatchUpFrames; - int loops = 0; - - while (validState && requireMoreUpdateLoops && loops++ < MaxCatchUpFrames) + do { + // update clock is always trying to approach the aim time. + // it should be provided as the original value each loop. updateClock(); - if (validState) - { - base.UpdateSubTree(); - UpdateSubTreeMasking(this, ScreenSpaceDrawQuad.AABBFloat); - } - } + if (state == PlaybackState.NotValid) + break; + + base.UpdateSubTree(); + UpdateSubTreeMasking(this, ScreenSpaceDrawQuad.AABBFloat); + } while (state == PlaybackState.RequiresCatchUp && loops-- > 0); return true; } private void updateClock() { + if (frameStableClock.WaitingOnFrames.Value) + { + // if waiting on frames, run one update loop to determine if frames have arrived. + state = PlaybackState.Valid; + } + else if (frameStableClock.IsPaused.Value) + { + // time should not advance while paused, nor should anything run. + state = PlaybackState.NotValid; + return; + } + else + { + state = PlaybackState.Valid; + } + if (parentGameplayClock == null) setClock(); // LoadComplete may not be run yet, but we still want the clock. - validState = true; + double proposedTime = parentGameplayClock.CurrentTime; - manualClock.Rate = parentGameplayClock.Rate; + if (FrameStablePlayback) + // if we require frame stability, the proposed time will be adjusted to move at most one known + // frame interval in the current direction. + applyFrameStability(ref proposedTime); + + if (hasReplayAttached) + { + bool valid = updateReplay(ref proposedTime); + + if (!valid) + state = PlaybackState.NotValid; + } + + if (state == PlaybackState.Valid) + direction = proposedTime >= manualClock.CurrentTime ? 1 : -1; + + double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime); + + frameStableClock.IsCatchingUp.Value = timeBehind > 200; + frameStableClock.WaitingOnFrames.Value = state == PlaybackState.NotValid; + + manualClock.CurrentTime = proposedTime; + manualClock.Rate = Math.Abs(parentGameplayClock.Rate) * direction; manualClock.IsRunning = parentGameplayClock.IsRunning; - var newProposedTime = parentGameplayClock.CurrentTime; + // determine whether catch-up is required. + if (state == PlaybackState.Valid && timeBehind > 0) + state = PlaybackState.RequiresCatchUp; - try + // The manual clock time has changed in the above code. The framed clock now needs to be updated + // to ensure that the its time is valid for our children before input is processed + framedClock.ProcessFrame(); + } + + /// <summary> + /// Attempt to advance replay playback for a given time. + /// </summary> + /// <param name="proposedTime">The time which is to be displayed.</param> + /// <returns>Whether playback is still valid.</returns> + private bool updateReplay(ref double proposedTime) + { + double? newTime; + + if (FrameStablePlayback) { - if (firstConsumption) + // when stability is turned on, we shouldn't execute for time values the replay is unable to satisfy. + newTime = ReplayInputHandler.SetFrameFromTime(proposedTime); + } + else + { + // when stability is disabled, we don't really care about accuracy. + // looping over the replay will allow it to catch up and feed out the required values + // for the current time. + while ((newTime = ReplayInputHandler.SetFrameFromTime(proposedTime)) != proposedTime) { - // On the first update, frame-stability seeking would result in unexpected/unwanted behaviour. - // Instead we perform an initial seek to the proposed time. - manualClock.CurrentTime = newProposedTime; - - // do a second process to clear out ElapsedTime - framedClock.ProcessFrame(); - - firstConsumption = false; - } - else if (manualClock.CurrentTime < gameplayStartTime) - manualClock.CurrentTime = newProposedTime = Math.Min(gameplayStartTime, newProposedTime); - else if (Math.Abs(manualClock.CurrentTime - newProposedTime) > sixty_frame_time * 1.2f) - { - newProposedTime = newProposedTime > manualClock.CurrentTime - ? Math.Min(newProposedTime, manualClock.CurrentTime + sixty_frame_time) - : Math.Max(newProposedTime, manualClock.CurrentTime - sixty_frame_time); - } - - if (!isAttached) - { - manualClock.CurrentTime = newProposedTime; - } - else - { - double? newTime = ReplayInputHandler.SetFrameFromTime(newProposedTime); - if (newTime == null) { - // we shouldn't execute for this time value. probably waiting on more replay data. - validState = false; - - requireMoreUpdateLoops = true; - manualClock.CurrentTime = newProposedTime; - return; + // special case for when the replay actually can't arrive at the required time. + // protects from potential endless loop. + break; } - - manualClock.CurrentTime = newTime.Value; } - - requireMoreUpdateLoops = manualClock.CurrentTime != parentGameplayClock.CurrentTime; } - finally + + if (newTime == null) + return false; + + proposedTime = newTime.Value; + return true; + } + + /// <summary> + /// Apply frame stability modifier to a time. + /// </summary> + /// <param name="proposedTime">The time which is to be displayed.</param> + private void applyFrameStability(ref double proposedTime) + { + if (firstConsumption) { - // The manual clock time has changed in the above code. The framed clock now needs to be updated - // to ensure that the its time is valid for our children before input is processed + // On the first update, frame-stability seeking would result in unexpected/unwanted behaviour. + // Instead we perform an initial seek to the proposed time. + + // process frame (in addition to finally clause) to clear out ElapsedTime + manualClock.CurrentTime = proposedTime; framedClock.ProcessFrame(); + + firstConsumption = false; + return; + } + + if (manualClock.CurrentTime < gameplayStartTime) + manualClock.CurrentTime = proposedTime = Math.Min(gameplayStartTime, proposedTime); + else if (Math.Abs(manualClock.CurrentTime - proposedTime) > sixty_frame_time * 1.2f) + { + proposedTime = proposedTime > manualClock.CurrentTime + ? Math.Min(proposedTime, manualClock.CurrentTime + sixty_frame_time) + : Math.Max(proposedTime, manualClock.CurrentTime - sixty_frame_time); } } private void setClock() { - // in case a parent gameplay clock isn't available, just use the parent clock. if (parentGameplayClock == null) - parentGameplayClock = Clock; - - Clock = GameplayClock; - ProcessCustomClock = false; + { + // in case a parent gameplay clock isn't available, just use the parent clock. + parentGameplayClock ??= Clock; + } + else + { + Clock = frameStableClock; + } } public ReplayInputHandler ReplayInputHandler { get; set; } + + private enum PlaybackState + { + /// <summary> + /// Playback is not possible. Child hierarchy should not be processed. + /// </summary> + NotValid, + + /// <summary> + /// Playback is running behind real-time. Catch-up will be attempted by processing more than once per + /// game loop (limited to a sane maximum to avoid frame drops). + /// </summary> + RequiresCatchUp, + + /// <summary> + /// In a valid state, progressing one child hierarchy loop per game loop. + /// </summary> + Valid + } + + private class FrameStabilityClock : GameplayClock, IFrameStableClock + { + public GameplayClock ParentGameplayClock; + + public readonly Bindable<bool> IsCatchingUp = new Bindable<bool>(); + + public readonly Bindable<bool> WaitingOnFrames = new Bindable<bool>(); + + public override IEnumerable<Bindable<double>> NonGameplayAdjustments => ParentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty<Bindable<double>>(); + + public FrameStabilityClock(FramedClock underlyingClock) + : base(underlyingClock) + { + } + + IBindable<bool> IFrameStableClock.IsCatchingUp => IsCatchingUp; + + IBindable<bool> IFrameStableClock.WaitingOnFrames => WaitingOnFrames; + } } } diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 9485189433..4cadfa9ad4 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.Linq; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Performance; using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.UI @@ -14,13 +16,64 @@ namespace osu.Game.Rulesets.UI public IEnumerable<DrawableHitObject> Objects => InternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime); public IEnumerable<DrawableHitObject> AliveObjects => AliveInternalChildren.Cast<DrawableHitObject>().OrderBy(h => h.HitObject.StartTime); + private readonly Dictionary<DrawableHitObject, (IBindable<double> bindable, double timeAtAdd)> startTimeMap = new Dictionary<DrawableHitObject, (IBindable<double>, double)>(); + public HitObjectContainer() { RelativeSizeAxes = Axes.Both; } - public virtual void Add(DrawableHitObject hitObject) => AddInternal(hitObject); - public virtual bool Remove(DrawableHitObject hitObject) => RemoveInternal(hitObject); + public virtual void Add(DrawableHitObject hitObject) + { + // Added first for the comparer to remain ordered during AddInternal + startTimeMap[hitObject] = (hitObject.HitObject.StartTimeBindable.GetBoundCopy(), hitObject.HitObject.StartTime); + startTimeMap[hitObject].bindable.BindValueChanged(_ => onStartTimeChanged(hitObject)); + + AddInternal(hitObject); + } + + public virtual bool Remove(DrawableHitObject hitObject) + { + if (!RemoveInternal(hitObject)) + return false; + + // Removed last for the comparer to remain ordered during RemoveInternal + startTimeMap[hitObject].bindable.UnbindAll(); + startTimeMap.Remove(hitObject); + + return true; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + unbindStartTimeMap(); + } + + public virtual void Clear(bool disposeChildren = true) + { + ClearInternal(disposeChildren); + unbindStartTimeMap(); + } + + private void unbindStartTimeMap() + { + foreach (var kvp in startTimeMap) + kvp.Value.bindable.UnbindAll(); + startTimeMap.Clear(); + } + + public int IndexOf(DrawableHitObject hitObject) => IndexOfInternal(hitObject); + + private void onStartTimeChanged(DrawableHitObject hitObject) + { + if (!RemoveInternal(hitObject)) + return; + + // Update the stored time, preserving the existing bindable + startTimeMap[hitObject] = (startTimeMap[hitObject].bindable, hitObject.HitObject.StartTime); + AddInternal(hitObject); + } protected override int Compare(Drawable x, Drawable y) { @@ -28,7 +81,7 @@ namespace osu.Game.Rulesets.UI return base.Compare(x, y); // Put earlier hitobjects towards the end of the list, so they handle input first - int i = yObj.HitObject.StartTime.CompareTo(xObj.HitObject.StartTime); + int i = startTimeMap[yObj].timeAtAdd.CompareTo(startTimeMap[xObj].timeAtAdd); return i == 0 ? CompareReverseChildID(x, y) : i; } diff --git a/osu.Game/Rulesets/UI/IFrameStableClock.cs b/osu.Game/Rulesets/UI/IFrameStableClock.cs new file mode 100644 index 0000000000..569ef5e06c --- /dev/null +++ b/osu.Game/Rulesets/UI/IFrameStableClock.cs @@ -0,0 +1,18 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Framework.Timing; + +namespace osu.Game.Rulesets.UI +{ + public interface IFrameStableClock : IFrameBasedClock + { + IBindable<bool> IsCatchingUp { get; } + + /// <summary> + /// Whether the frame stable clock is waiting on new frames to arrive to be able to progress time. + /// </summary> + IBindable<bool> WaitingOnFrames { get; } + } +} diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index 86feea09a8..8ea6c74349 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -9,36 +9,45 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; using osuTK; +using osu.Framework.Bindables; namespace osu.Game.Rulesets.UI { public class ModIcon : Container, IHasTooltip { + public readonly BindableBool Selected = new BindableBool(); + private readonly SpriteIcon modIcon; + private readonly SpriteText modAcronym; private readonly SpriteIcon background; private const float size = 80; - public IconUsage Icon - { - get => modIcon.Icon; - set => modIcon.Icon = value; - } - private readonly ModType type; - public virtual string TooltipText { get; } + public virtual string TooltipText => mod.IconTooltip; + + private Mod mod; + + public Mod Mod + { + get => mod; + set + { + mod = value; + updateMod(value); + } + } public ModIcon(Mod mod) { - if (mod == null) throw new ArgumentNullException(nameof(mod)); + this.mod = mod ?? throw new ArgumentNullException(nameof(mod)); type = mod.Type; - TooltipText = mod.Name; - Size = new Vector2(size); Children = new Drawable[] @@ -49,18 +58,45 @@ namespace osu.Game.Rulesets.UI Anchor = Anchor.Centre, Size = new Vector2(size), Icon = OsuIcon.ModBg, - Y = -6.5f, Shadow = true, }, + modAcronym = new OsuSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = OsuColour.Gray(84), + Alpha = 0, + Font = OsuFont.Numeric.With(null, 22f), + UseFullGlyphHeight = false, + Text = mod.Acronym + }, modIcon = new SpriteIcon { Origin = Anchor.Centre, Anchor = Anchor.Centre, Colour = OsuColour.Gray(84), - Size = new Vector2(size - 35), - Icon = mod.Icon + Size = new Vector2(45), + Icon = FontAwesome.Solid.Question }, }; + + updateMod(mod); + } + + private void updateMod(Mod value) + { + modAcronym.Text = value.Acronym; + modIcon.Icon = value.Icon ?? FontAwesome.Solid.Question; + + if (value.Icon is null) + { + modIcon.FadeOut(); + modAcronym.FadeIn(); + return; + } + + modIcon.FadeIn(); + modAcronym.FadeOut(); } private Color4 backgroundColour; @@ -96,27 +132,19 @@ namespace osu.Game.Rulesets.UI backgroundColour = colours.Pink; highlightedColour = colours.PinkLight; break; - } - applyStyle(); - } - - private bool highlighted; - - public bool Highlighted - { - get => highlighted; - - set - { - highlighted = value; - applyStyle(); + case ModType.System: + backgroundColour = colours.Gray6; + highlightedColour = colours.Gray7; + modIcon.Colour = colours.Yellow; + break; } } - private void applyStyle() + protected override void LoadComplete() { - background.Colour = highlighted ? highlightedColour : backgroundColour; + base.LoadComplete(); + Selected.BindValueChanged(selected => background.Colour = selected.NewValue ? highlightedColour : backgroundColour, true); } } } diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index f2e7f51b52..d92ba210db 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -10,7 +10,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osuTK; @@ -30,10 +29,29 @@ namespace osu.Game.Rulesets.UI /// </summary> public Func<Vector2, Vector2> GamefieldToScreenSpace => HitObjectContainer.ToScreenSpace; + /// <summary> + /// A function that converts screen space coordinates to gamefield. + /// </summary> + public Func<Vector2, Vector2> ScreenSpaceToGamefield => HitObjectContainer.ToLocalSpace; + /// <summary> /// All the <see cref="DrawableHitObject"/>s contained in this <see cref="Playfield"/> and all <see cref="NestedPlayfields"/>. /// </summary> - public IEnumerable<DrawableHitObject> AllHitObjects => HitObjectContainer?.Objects.Concat(NestedPlayfields.SelectMany(p => p.AllHitObjects)) ?? Enumerable.Empty<DrawableHitObject>(); + public IEnumerable<DrawableHitObject> AllHitObjects + { + get + { + if (HitObjectContainer == null) + return Enumerable.Empty<DrawableHitObject>(); + + var enumerable = HitObjectContainer.Objects; + + if (nestedPlayfields.IsValueCreated) + enumerable = enumerable.Concat(NestedPlayfields.SelectMany(p => p.AllHitObjects)); + + return enumerable; + } + } /// <summary> /// All <see cref="Playfield"/>s nested inside this <see cref="Playfield"/>. @@ -57,10 +75,7 @@ namespace osu.Game.Rulesets.UI hitObjectContainerLazy = new Lazy<HitObjectContainer>(CreateHitObjectContainer); } - [Resolved] - private IBindable<WorkingBeatmap> beatmap { get; set; } - - [Resolved] + [Resolved(CanBeNull = true)] private IReadOnlyList<Mod> mods { get; set; } [BackgroundDependencyLoader] @@ -100,10 +115,13 @@ namespace osu.Game.Rulesets.UI public GameplayCursorContainer Cursor { get; private set; } /// <summary> - /// Provide an optional cursor which is to be used for gameplay. + /// Provide a cursor which is to be used for gameplay. /// </summary> - /// <returns>The cursor, or null if a cursor is not rqeuired.</returns> - protected virtual GameplayCursorContainer CreateCursor() => null; + /// <remarks> + /// The default provided cursor is invisible when inside the bounds of the <see cref="Playfield"/>. + /// </remarks> + /// <returns>The cursor, or null to show the menu cursor.</returns> + protected virtual GameplayCursorContainer CreateCursor() => new InvisibleCursorContainer(); /// <summary> /// Registers a <see cref="Playfield"/> as a nested <see cref="Playfield"/>. @@ -129,15 +147,28 @@ namespace osu.Game.Rulesets.UI { base.Update(); - if (beatmap != null) + if (mods != null) + { foreach (var mod in mods) + { if (mod is IUpdatableByPlayfield updatable) updatable.Update(this); + } + } } /// <summary> /// Creates the container that will be used to contain the <see cref="DrawableHitObject"/>s. /// </summary> protected virtual HitObjectContainer CreateHitObjectContainer() => new HitObjectContainer(); + + public class InvisibleCursorContainer : GameplayCursorContainer + { + protected override Drawable CreateCursor() => new InvisibleCursor(); + + private class InvisibleCursor : Drawable + { + } + } } } diff --git a/osu.Game/Rulesets/UI/PlayfieldBorder.cs b/osu.Game/Rulesets/UI/PlayfieldBorder.cs new file mode 100644 index 0000000000..458b88c6db --- /dev/null +++ b/osu.Game/Rulesets/UI/PlayfieldBorder.cs @@ -0,0 +1,149 @@ +// 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. + +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.UI +{ + /// <summary> + /// Provides a border around the playfield. + /// </summary> + public class PlayfieldBorder : CompositeDrawable + { + public Bindable<PlayfieldBorderStyle> PlayfieldBorderStyle { get; } = new Bindable<PlayfieldBorderStyle>(); + + private const int fade_duration = 500; + + private const float corner_length = 0.05f; + private const float corner_thickness = 2; + + public PlayfieldBorder() + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Line(Direction.Horizontal) + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + }, + new Line(Direction.Horizontal) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + new Line(Direction.Horizontal) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + new Line(Direction.Horizontal) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + new Line(Direction.Vertical) + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + }, + new Line(Direction.Vertical) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + new Line(Direction.Vertical) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }, + new Line(Direction.Vertical) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + PlayfieldBorderStyle.BindValueChanged(updateStyle, true); + } + + private void updateStyle(ValueChangedEvent<PlayfieldBorderStyle> style) + { + switch (style.NewValue) + { + case UI.PlayfieldBorderStyle.None: + this.FadeOut(fade_duration, Easing.OutQuint); + foreach (var line in InternalChildren.OfType<Line>()) + line.TweenLength(0); + + break; + + case UI.PlayfieldBorderStyle.Corners: + this.FadeIn(fade_duration, Easing.OutQuint); + foreach (var line in InternalChildren.OfType<Line>()) + line.TweenLength(corner_length); + + break; + + case UI.PlayfieldBorderStyle.Full: + this.FadeIn(fade_duration, Easing.OutQuint); + foreach (var line in InternalChildren.OfType<Line>()) + line.TweenLength(0.5f); + + break; + } + } + + private class Line : Box + { + private readonly Direction direction; + + public Line(Direction direction) + { + this.direction = direction; + + Colour = Color4.White; + // starting in relative avoids the framework thinking it knows best and setting the width to 1 initially. + + switch (direction) + { + case Direction.Horizontal: + RelativeSizeAxes = Axes.X; + Size = new Vector2(0, corner_thickness); + break; + + case Direction.Vertical: + RelativeSizeAxes = Axes.Y; + Size = new Vector2(corner_thickness, 0); + break; + } + } + + public void TweenLength(float value) + { + switch (direction) + { + case Direction.Horizontal: + this.ResizeWidthTo(value, fade_duration, Easing.OutQuint); + break; + + case Direction.Vertical: + this.ResizeHeightTo(value, fade_duration, Easing.OutQuint); + break; + } + } + } + } +} diff --git a/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs b/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs new file mode 100644 index 0000000000..0a0aad884e --- /dev/null +++ b/osu.Game/Rulesets/UI/PlayfieldBorderStyle.cs @@ -0,0 +1,12 @@ +// 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. + +namespace osu.Game.Rulesets.UI +{ + public enum PlayfieldBorderStyle + { + None, + Corners, + Full + } +} diff --git a/osu.Game/Rulesets/UI/ReplayRecorder.cs b/osu.Game/Rulesets/UI/ReplayRecorder.cs new file mode 100644 index 0000000000..1438ebd37a --- /dev/null +++ b/osu.Game/Rulesets/UI/ReplayRecorder.cs @@ -0,0 +1,106 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Online.Spectator; +using osu.Game.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Screens.Play; +using osuTK; + +namespace osu.Game.Rulesets.UI +{ + public abstract class ReplayRecorder<T> : ReplayRecorder, IKeyBindingHandler<T> + where T : struct + { + private readonly Replay target; + + private readonly List<T> pressedActions = new List<T>(); + + private InputManager inputManager; + + public int RecordFrameRate = 60; + + [Resolved(canBeNull: true)] + private SpectatorStreamingClient spectatorStreaming { get; set; } + + [Resolved] + private GameplayBeatmap gameplayBeatmap { get; set; } + + protected ReplayRecorder(Replay target) + { + this.target = target; + + RelativeSizeAxes = Axes.Both; + + Depth = float.MinValue; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + + spectatorStreaming?.BeginPlaying(gameplayBeatmap); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + spectatorStreaming?.EndPlaying(); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + recordFrame(false); + return base.OnMouseMove(e); + } + + public bool OnPressed(T action) + { + pressedActions.Add(action); + recordFrame(true); + return false; + } + + public void OnReleased(T action) + { + pressedActions.Remove(action); + recordFrame(true); + } + + private void recordFrame(bool important) + { + var last = target.Frames.LastOrDefault(); + + if (!important && last != null && Time.Current - last.Time < (1000d / RecordFrameRate)) + return; + + var position = ScreenSpaceToGamefield?.Invoke(inputManager.CurrentState.Mouse.Position) ?? inputManager.CurrentState.Mouse.Position; + + var frame = HandleFrame(position, pressedActions, last); + + if (frame != null) + { + target.Frames.Add(frame); + + spectatorStreaming?.HandleFrame(frame); + } + } + + protected abstract ReplayFrame HandleFrame(Vector2 mousePosition, List<T> actions, ReplayFrame previousFrame); + } + + public abstract class ReplayRecorder : Component + { + public Func<Vector2, Vector2> ScreenSpaceToGamefield; + } +} diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index e25c3bd0e7..07de2bf601 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -1,6 +1,7 @@ // 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. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,21 +18,29 @@ using osu.Game.Input.Handlers; using osu.Game.Screens.Play; using osuTK.Input; using static osu.Game.Input.Handlers.ReplayInputHandler; -using JoystickState = osu.Framework.Input.States.JoystickState; -using KeyboardState = osu.Framework.Input.States.KeyboardState; -using MouseState = osu.Framework.Input.States.MouseState; namespace osu.Game.Rulesets.UI { - public abstract class RulesetInputManager<T> : PassThroughInputManager, ICanAttachKeyCounter, IHasReplayHandler + public abstract class RulesetInputManager<T> : PassThroughInputManager, ICanAttachKeyCounter, IHasReplayHandler, IHasRecordingHandler where T : struct { - protected override InputState CreateInitialState() + private ReplayRecorder recorder; + + public ReplayRecorder Recorder { - var state = base.CreateInitialState(); - return new RulesetInputManagerInputState<T>(state.Mouse, state.Keyboard, state.Joystick); + set + { + if (recorder != null) + throw new InvalidOperationException("Cannot attach more than one recorder"); + + recorder = value; + + KeyBindingContainer.Add(recorder); + } } + protected override InputState CreateInitialState() => new RulesetInputManagerInputState<T>(base.CreateInitialState()); + protected readonly KeyBindingContainer<T> KeyBindingContainer; protected override Container<Drawable> Content => content; @@ -127,7 +136,11 @@ namespace osu.Game.Rulesets.UI KeyBindingContainer.Add(receptor); keyCounter.SetReceptor(receptor); - keyCounter.AddRange(KeyBindingContainer.DefaultKeyBindings.Select(b => b.GetAction<T>()).Distinct().Select(b => new KeyCounterAction<T>(b))); + keyCounter.AddRange(KeyBindingContainer.DefaultKeyBindings + .Select(b => b.GetAction<T>()) + .Distinct() + .OrderBy(action => action) + .Select(action => new KeyCounterAction<T>(action))); } public class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler<T> @@ -137,14 +150,18 @@ namespace osu.Game.Rulesets.UI { } - public bool OnPressed(T action) => Target.Children.OfType<KeyCounterAction<T>>().Any(c => c.OnPressed(action)); + public bool OnPressed(T action) => Target.Children.OfType<KeyCounterAction<T>>().Any(c => c.OnPressed(action, Clock.Rate >= 0)); - public bool OnReleased(T action) => Target.Children.OfType<KeyCounterAction<T>>().Any(c => c.OnReleased(action)); + public void OnReleased(T action) + { + foreach (var c in Target.Children.OfType<KeyCounterAction<T>>()) + c.OnReleased(action, Clock.Rate >= 0); + } } #endregion - protected virtual RulesetKeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) + protected virtual KeyBindingContainer<T> CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new RulesetKeyBindingContainer(ruleset, variant, unique); public class RulesetKeyBindingContainer : DatabasedKeyBindingContainer<T> @@ -164,6 +181,11 @@ namespace osu.Game.Rulesets.UI ReplayInputHandler ReplayInputHandler { get; set; } } + public interface IHasRecordingHandler + { + public ReplayRecorder Recorder { set; } + } + /// <summary> /// Supports attaching a <see cref="KeyCounterDisplay"/>. /// Keys will be populated automatically and a receptor will be injected inside. @@ -178,8 +200,8 @@ namespace osu.Game.Rulesets.UI { public ReplayState<T> LastReplayState; - public RulesetInputManagerInputState(MouseState mouse = null, KeyboardState keyboard = null, JoystickState joystick = null) - : base(mouse, keyboard, joystick) + public RulesetInputManagerInputState(InputState state = null) + : base(state) { } } diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs index 75ea3efdf2..0d4283e319 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/ConstantScrollAlgorithm.cs @@ -5,7 +5,11 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public class ConstantScrollAlgorithm : IScrollAlgorithm { - public double GetDisplayStartTime(double time, double timeRange) => time - timeRange; + public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) + { + var adjustedTime = TimeAt(-offset, originTime, timeRange, scrollLength); + return adjustedTime - timeRange; + } public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) { diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs index b7a5eedc22..c394a05bcc 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/IScrollAlgorithm.cs @@ -6,15 +6,33 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms public interface IScrollAlgorithm { /// <summary> - /// Given a point in time, computes the time at which it enters the time range. + /// Given a point in time associated with an object's origin + /// and the spatial distance between the edge and the origin of the object along the scrolling axis, + /// computes the time at which the object initially enters the time range. /// </summary> - /// <remarks> - /// E.g. For a constant time range of 5000ms, the time at which t=7000ms enters the time range is 2000ms. - /// </remarks> - /// <param name="time">The point in time.</param> + /// <example> + /// Let's assume the following parameters: + /// <list type="bullet"> + /// <item><paramref name="originTime"/> = 7000ms,</item> + /// <item><paramref name="offset"/> = 100px,</item> + /// <item><paramref name="timeRange"/> = 5000ms,</item> + /// <item><paramref name="scrollLength"/> = 1000px</item> + /// </list> + /// and a constant scrolling rate. + /// To arrive at the end of the scrolling container, the object's origin has to cover + /// <code>1000 + 100 = 1100px</code> + /// so that the edge starts at the end of the scrolling container. + /// One scroll length of 1000px covers 5000ms of time, so the time required to cover 1100px is equal to + /// <code>5000 * (1100 / 1000) = 5500ms,</code> + /// and therefore the object should start being visible at + /// <code>7000 - 5500 = 1500ms.</code> + /// </example> + /// <param name="originTime">The time point at which the object origin should enter the time range.</param> + /// <param name="offset">The spatial distance between the object's edge and its origin along the scrolling axis.</param> /// <param name="timeRange">The amount of visible time.</param> - /// <returns>The time at which <paramref name="time"/> enters <see cref="timeRange"/>.</returns> - double GetDisplayStartTime(double time, double timeRange); + /// <param name="scrollLength">The absolute spatial length through <paramref name="timeRange"/>.</param> + /// <returns>The time at which the object should enter the time range.</returns> + double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength); /// <summary> /// Computes the spatial length within a start and end time. @@ -22,7 +40,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// <param name="startTime">The start time.</param> /// <param name="endTime">The end time.</param> /// <param name="timeRange">The amount of visible time.</param> - /// <param name="scrollLength">The absolute spatial length through <see cref="timeRange"/>.</param> + /// <param name="scrollLength">The absolute spatial length through <paramref name="timeRange"/>.</param> /// <returns>The absolute spatial length.</returns> float GetLength(double startTime, double endTime, double timeRange, float scrollLength); @@ -32,7 +50,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// <param name="time">The time to compute the spatial position of.</param> /// <param name="currentTime">The current time.</param> /// <param name="timeRange">The amount of visible time.</param> - /// <param name="scrollLength">The absolute spatial length through <see cref="timeRange"/>.</param> + /// <param name="scrollLength">The absolute spatial length through <paramref name="timeRange"/>.</param> /// <returns>The absolute spatial position.</returns> float PositionAt(double time, double currentTime, double timeRange, float scrollLength); @@ -42,7 +60,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// <param name="position">The absolute spatial position.</param> /// <param name="currentTime">The current time.</param> /// <param name="timeRange">The amount of visible time.</param> - /// <param name="scrollLength">The absolute spatial length through <see cref="timeRange"/>.</param> + /// <param name="scrollLength">The absolute spatial length through <paramref name="timeRange"/>.</param> /// <returns>The time at which <see cref="PositionAt(double,double,double,float)"/> == <paramref name="position"/>.</returns> double TimeAt(float position, double currentTime, double timeRange, float scrollLength); diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs index 5316585493..7b827e0c63 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs @@ -1,9 +1,9 @@ // 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. +using System; using osu.Framework.Lists; using osu.Game.Rulesets.Timing; -using osuTK; namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { @@ -20,11 +20,12 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms searchPoint = new MultiplierControlPoint(); } - public double GetDisplayStartTime(double time, double timeRange) + public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) { + var controlPoint = controlPointAt(originTime); // The total amount of time that the hitobject will remain visible within the timeRange, which decreases as the speed multiplier increases - double visibleDuration = timeRange / controlPointAt(time).Multiplier; - return time - visibleDuration; + double visibleDuration = (scrollLength + offset) * timeRange / controlPoint.Multiplier / scrollLength; + return originTime - visibleDuration; } public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) @@ -59,7 +60,7 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms } } - i = MathHelper.Clamp(i, 0, controlPoints.Count - 1); + i = Math.Clamp(i, 0, controlPoints.Count - 1); return controlPoints[i].StartTime + (position - pos) * timeRange / controlPoints[i].Multiplier / scrollLength; } diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs index 3c9a205412..a1f68d7201 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/SequentialScrollAlgorithm.cs @@ -3,76 +3,60 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using JetBrains.Annotations; using osu.Game.Rulesets.Timing; namespace osu.Game.Rulesets.UI.Scrolling.Algorithms { public class SequentialScrollAlgorithm : IScrollAlgorithm { - private readonly Dictionary<double, double> positionCache; + private static readonly IComparer<PositionMapping> by_position_comparer = Comparer<PositionMapping>.Create((c1, c2) => c1.Position.CompareTo(c2.Position)); private readonly IReadOnlyList<MultiplierControlPoint> controlPoints; + /// <summary> + /// Stores a mapping of time -> position for each control point. + /// </summary> + private readonly List<PositionMapping> positionMappings = new List<PositionMapping>(); + public SequentialScrollAlgorithm(IReadOnlyList<MultiplierControlPoint> controlPoints) { this.controlPoints = controlPoints; - - positionCache = new Dictionary<double, double>(); } - public double GetDisplayStartTime(double time, double timeRange) => time - timeRange - 1000; + public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) + { + return TimeAt(-(scrollLength + offset), originTime, timeRange, scrollLength); + } public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) { - var objectLength = relativePositionAtCached(endTime, timeRange) - relativePositionAtCached(startTime, timeRange); + var objectLength = relativePositionAt(endTime, timeRange) - relativePositionAt(startTime, timeRange); return (float)(objectLength * scrollLength); } public float PositionAt(double time, double currentTime, double timeRange, float scrollLength) { - // Caching is not used here as currentTime is unlikely to have been previously cached - double timelinePosition = relativePositionAt(currentTime, timeRange); - return (float)((relativePositionAtCached(time, timeRange) - timelinePosition) * scrollLength); + double timelineLength = relativePositionAt(time, timeRange) - relativePositionAt(currentTime, timeRange); + return (float)(timelineLength * scrollLength); } public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) { - // Convert the position to a length relative to time = 0 - double length = position / scrollLength + relativePositionAt(currentTime, timeRange); + if (controlPoints.Count == 0) + return position * timeRange; - // We need to consider all timing points until the specified time and not just the currently-active one, - // since each timing point individually affects the positions of _all_ hitobjects after its start time - for (int i = 0; i < controlPoints.Count; i++) - { - var current = controlPoints[i]; - var next = i < controlPoints.Count - 1 ? controlPoints[i + 1] : null; + // Find the position at the current time, and the given length. + double relativePosition = relativePositionAt(currentTime, timeRange) + position / scrollLength; - // Duration of the current control point - var currentDuration = (next?.StartTime ?? double.PositiveInfinity) - current.StartTime; + var positionMapping = findControlPointMapping(timeRange, new PositionMapping(0, null, relativePosition), by_position_comparer); - // Figure out the length of control point - var currentLength = currentDuration / timeRange * current.Multiplier; - - if (currentLength > length) - { - // The point is within this control point - return current.StartTime + length * timeRange / current.Multiplier; - } - - length -= currentLength; - } - - return 0; // Should never occur + // Begin at the control point's time and add the remaining time to reach the given position. + return positionMapping.Time + (relativePosition - positionMapping.Position) * timeRange / positionMapping.ControlPoint.Multiplier; } - private double relativePositionAtCached(double time, double timeRange) - { - if (!positionCache.TryGetValue(time, out double existing)) - positionCache[time] = existing = relativePositionAt(time, timeRange); - return existing; - } - - public void Reset() => positionCache.Clear(); + public void Reset() => positionMappings.Clear(); /// <summary> /// Finds the position which corresponds to a point in time. @@ -81,37 +65,100 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms /// <param name="time">The time to find the position at.</param> /// <param name="timeRange">The amount of time visualised by the scrolling area.</param> /// <returns>A positive value indicating the position at <paramref name="time"/>.</returns> - private double relativePositionAt(double time, double timeRange) + private double relativePositionAt(in double time, in double timeRange) { if (controlPoints.Count == 0) return time / timeRange; - double length = 0; + var mapping = findControlPointMapping(timeRange, new PositionMapping(time)); - // We need to consider all timing points until the specified time and not just the currently-active one, - // since each timing point individually affects the positions of _all_ hitobjects after its start time - for (int i = 0; i < controlPoints.Count; i++) + // Begin at the control point's position and add the remaining distance to reach the given time. + return mapping.Position + (time - mapping.Time) / timeRange * mapping.ControlPoint.Multiplier; + } + + /// <summary> + /// Finds a <see cref="MultiplierControlPoint"/>'s <see cref="PositionMapping"/> that is relevant to a given <see cref="PositionMapping"/>. + /// </summary> + /// <remarks> + /// This is used to find the last <see cref="MultiplierControlPoint"/> occuring prior to a time value, or prior to a position value (if <see cref="by_position_comparer"/> is used). + /// </remarks> + /// <param name="timeRange">The time range.</param> + /// <param name="search">The <see cref="PositionMapping"/> to find the closest <see cref="PositionMapping"/> to.</param> + /// <param name="comparer">The comparison. If null, the default comparer is used (by time).</param> + /// <returns>The <see cref="MultiplierControlPoint"/>'s <see cref="PositionMapping"/> that is relevant for <paramref name="search"/>.</returns> + private PositionMapping findControlPointMapping(in double timeRange, in PositionMapping search, IComparer<PositionMapping> comparer = null) + { + generatePositionMappings(timeRange); + + var mappingIndex = positionMappings.BinarySearch(search, comparer ?? Comparer<PositionMapping>.Default); + + if (mappingIndex < 0) { - var current = controlPoints[i]; - var next = i < controlPoints.Count - 1 ? controlPoints[i + 1] : null; + // If the search value isn't found, the _next_ control point is returned, but we actually want the _previous_ control point. + // In doing so, we must make sure to not underflow the position mapping list (i.e. always use the 0th control point for time < first_control_point_time). + mappingIndex = Math.Max(0, ~mappingIndex - 1); - // We don't need to consider any control points beyond the current time, since it will not yet - // affect any hitobjects - if (i > 0 && current.StartTime > time) - continue; - - // Duration of the current control point - var currentDuration = (next?.StartTime ?? double.PositiveInfinity) - current.StartTime; - - // We want to consider the minimal amount of time that this control point has affected, - // which may be either its duration, or the amount of time that has passed within it - var durationInCurrent = Math.Min(currentDuration, time - current.StartTime); - - // Figure out how much of the time range the duration represents, and adjust it by the speed multiplier - length += durationInCurrent / timeRange * current.Multiplier; + Debug.Assert(mappingIndex < positionMappings.Count); } - return length; + var mapping = positionMappings[mappingIndex]; + Debug.Assert(mapping.ControlPoint != null); + + return mapping; + } + + /// <summary> + /// Generates the mapping of <see cref="MultiplierControlPoint"/> (and their respective start times) to their relative position from 0. + /// </summary> + /// <param name="timeRange">The time range.</param> + private void generatePositionMappings(in double timeRange) + { + if (positionMappings.Count > 0) + return; + + if (controlPoints.Count == 0) + return; + + positionMappings.Add(new PositionMapping(controlPoints[0].StartTime, controlPoints[0])); + + for (int i = 0; i < controlPoints.Count - 1; i++) + { + var current = controlPoints[i]; + var next = controlPoints[i + 1]; + + // Figure out how much of the time range the duration represents, and adjust it by the speed multiplier + float length = (float)((next.StartTime - current.StartTime) / timeRange * current.Multiplier); + + positionMappings.Add(new PositionMapping(next.StartTime, next, positionMappings[^1].Position + length)); + } + } + + private readonly struct PositionMapping : IComparable<PositionMapping> + { + /// <summary> + /// The time corresponding to this position. + /// </summary> + public readonly double Time; + + /// <summary> + /// The <see cref="MultiplierControlPoint"/> at <see cref="Time"/>. + /// </summary> + [CanBeNull] + public readonly MultiplierControlPoint ControlPoint; + + /// <summary> + /// The relative position from 0 of <see cref="ControlPoint"/>. + /// </summary> + public readonly double Position; + + public PositionMapping(double time, MultiplierControlPoint controlPoint = null, double position = default) + { + Time = time; + ControlPoint = controlPoint; + Position = position; + } + + public int CompareTo(PositionMapping other) => Time.CompareTo(other.Time); } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index 42ec0b79b9..0955f32790 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -9,13 +9,14 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Lists; +using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Timing; using osu.Game.Rulesets.UI.Scrolling.Algorithms; @@ -70,18 +71,21 @@ namespace osu.Game.Rulesets.UI.Scrolling protected virtual bool UserScrollSpeedAdjustment => true; /// <summary> - /// Provides the default <see cref="MultiplierControlPoint"/>s that adjust the scrolling rate of <see cref="HitObject"/>s - /// inside this <see cref="DrawableRuleset{TObject}"/>. + /// Whether <see cref="TimingControlPoint"/> beat lengths should scale relative to the most common beat length in the <see cref="Beatmap"/>. /// </summary> - /// <returns></returns> - private readonly SortedList<MultiplierControlPoint> controlPoints = new SortedList<MultiplierControlPoint>(Comparer<MultiplierControlPoint>.Default); + protected virtual bool RelativeScaleBeatLengths => false; + + /// <summary> + /// The <see cref="MultiplierControlPoint"/>s that adjust the scrolling rate of <see cref="HitObject"/>s inside this <see cref="DrawableRuleset{TObject}"/>. + /// </summary> + protected readonly SortedList<MultiplierControlPoint> ControlPoints = new SortedList<MultiplierControlPoint>(Comparer<MultiplierControlPoint>.Default); protected IScrollingInfo ScrollingInfo => scrollingInfo; [Cached(Type = typeof(IScrollingInfo))] private readonly LocalScrollingInfo scrollingInfo; - protected DrawableScrollingRuleset(Ruleset ruleset, WorkingBeatmap beatmap, IReadOnlyList<Mod> mods) + protected DrawableScrollingRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods = null) : base(ruleset, beatmap, mods) { scrollingInfo = new LocalScrollingInfo(); @@ -91,11 +95,11 @@ namespace osu.Game.Rulesets.UI.Scrolling switch (VisualisationMethod) { case ScrollVisualisationMethod.Sequential: - scrollingInfo.Algorithm = new SequentialScrollAlgorithm(controlPoints); + scrollingInfo.Algorithm = new SequentialScrollAlgorithm(ControlPoints); break; case ScrollVisualisationMethod.Overlapping: - scrollingInfo.Algorithm = new OverlappingScrollAlgorithm(controlPoints); + scrollingInfo.Algorithm = new OverlappingScrollAlgorithm(ControlPoints); break; case ScrollVisualisationMethod.Constant: @@ -107,68 +111,67 @@ namespace osu.Game.Rulesets.UI.Scrolling [BackgroundDependencyLoader] private void load() { - // Calculate default multiplier control points + double lastObjectTime = Objects.LastOrDefault()?.GetEndTime() ?? double.MaxValue; + double baseBeatLength = TimingControlPoint.DEFAULT_BEAT_LENGTH; + + if (RelativeScaleBeatLengths) + { + IReadOnlyList<TimingControlPoint> timingPoints = Beatmap.ControlPointInfo.TimingPoints; + double maxDuration = 0; + + for (int i = 0; i < timingPoints.Count; i++) + { + if (timingPoints[i].Time > lastObjectTime) + break; + + double endTime = i < timingPoints.Count - 1 ? timingPoints[i + 1].Time : lastObjectTime; + double duration = endTime - timingPoints[i].Time; + + if (duration > maxDuration) + { + maxDuration = duration; + // The slider multiplier is post-multiplied to determine the final velocity, but for relative scale beat lengths + // the multiplier should not affect the effective timing point (the longest in the beatmap), so it is factored out here + baseBeatLength = timingPoints[i].BeatLength / Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier; + } + } + } + + // Merge sequences of timing and difficulty control points to create the aggregate "multiplier" control point var lastTimingPoint = new TimingControlPoint(); var lastDifficultyPoint = new DifficultyControlPoint(); - - // Merge timing + difficulty points var allPoints = new SortedList<ControlPoint>(Comparer<ControlPoint>.Default); allPoints.AddRange(Beatmap.ControlPointInfo.TimingPoints); allPoints.AddRange(Beatmap.ControlPointInfo.DifficultyPoints); - // Generate the timing points, making non-timing changes use the previous timing change + // Generate the timing points, making non-timing changes use the previous timing change and vice-versa var timingChanges = allPoints.Select(c => { - var timingPoint = c as TimingControlPoint; - var difficultyPoint = c as DifficultyControlPoint; - - if (timingPoint != null) + if (c is TimingControlPoint timingPoint) lastTimingPoint = timingPoint; - - if (difficultyPoint != null) + else if (c is DifficultyControlPoint difficultyPoint) lastDifficultyPoint = difficultyPoint; return new MultiplierControlPoint(c.Time) { Velocity = Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier, + BaseBeatLength = baseBeatLength, TimingPoint = lastTimingPoint, DifficultyPoint = lastDifficultyPoint }; }); - double lastObjectTime = (Objects.LastOrDefault() as IHasEndTime)?.EndTime ?? Objects.LastOrDefault()?.StartTime ?? double.MaxValue; - - // Perform some post processing of the timing changes + // Trim unwanted sequences of timing changes timingChanges = timingChanges // Collapse sections after the last hit object .Where(s => s.StartTime <= lastObjectTime) // Collapse sections with the same start time .GroupBy(s => s.StartTime).Select(g => g.Last()).OrderBy(s => s.StartTime); - controlPoints.AddRange(timingChanges); + ControlPoints.AddRange(timingChanges); - // If we have no control points, add a default one - if (controlPoints.Count == 0) - controlPoints.Add(new MultiplierControlPoint { Velocity = Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier }); - } - - public bool OnPressed(GlobalAction action) - { - if (!UserScrollSpeedAdjustment) - return false; - - switch (action) - { - case GlobalAction.IncreaseScrollSpeed: - this.TransformBindableTo(TimeRange, TimeRange.Value - time_span_step, 200, Easing.OutQuint); - return true; - - case GlobalAction.DecreaseScrollSpeed: - this.TransformBindableTo(TimeRange, TimeRange.Value + time_span_step, 200, Easing.OutQuint); - return true; - } - - return false; + if (ControlPoints.Count == 0) + ControlPoints.Add(new MultiplierControlPoint { Velocity = Beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier }); } protected override void LoadComplete() @@ -179,7 +182,44 @@ namespace osu.Game.Rulesets.UI.Scrolling throw new ArgumentException($"{nameof(Playfield)} must be a {nameof(ScrollingPlayfield)} when using {nameof(DrawableScrollingRuleset<TObject>)}."); } - public bool OnReleased(GlobalAction action) => false; + /// <summary> + /// Adjusts the scroll speed of <see cref="HitObject"/>s. + /// </summary> + /// <param name="amount">The amount to adjust by. Greater than 0 if the scroll speed should be increased, less than 0 if it should be decreased.</param> + protected virtual void AdjustScrollSpeed(int amount) => this.TransformBindableTo(TimeRange, TimeRange.Value - amount * time_span_step, 200, Easing.OutQuint); + + public bool OnPressed(GlobalAction action) + { + if (!UserScrollSpeedAdjustment) + return false; + + switch (action) + { + case GlobalAction.IncreaseScrollSpeed: + scheduleScrollSpeedAdjustment(1); + return true; + + case GlobalAction.DecreaseScrollSpeed: + scheduleScrollSpeedAdjustment(-1); + return true; + } + + return false; + } + + private ScheduledDelegate scheduledScrollSpeedAdjustment; + + public void OnReleased(GlobalAction action) + { + scheduledScrollSpeedAdjustment?.Cancel(); + scheduledScrollSpeedAdjustment = null; + } + + private void scheduleScrollSpeedAdjustment(int amount) + { + scheduledScrollSpeedAdjustment?.Cancel(); + scheduledScrollSpeedAdjustment = this.BeginKeyRepeat(Scheduler, () => AdjustScrollSpeed(amount)); + } private class LocalScrollingInfo : IScrollingInfo { diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index 069e2d1a0b..bf64175468 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -1,29 +1,41 @@ // 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. +using System.Collections.Generic; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Layout; +using osu.Framework.Threading; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; +using osuTK; namespace osu.Game.Rulesets.UI.Scrolling { public class ScrollingHitObjectContainer : HitObjectContainer { private readonly IBindable<double> timeRange = new BindableDouble(); - private readonly IBindable<ScrollingDirection> direction = new Bindable<ScrollingDirection>(); + private readonly Dictionary<DrawableHitObject, InitialState> hitObjectInitialStateCache = new Dictionary<DrawableHitObject, InitialState>(); [Resolved] private IScrollingInfo scrollingInfo { get; set; } - private Cached initialStateCache = new Cached(); + // Responds to changes in the layout. When the layout changes, all hit object states must be recomputed. + private readonly LayoutValue layoutCache = new LayoutValue(Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo); + + // A combined cache across all hit object states to reduce per-update iterations. + // When invalidated, one or more (but not necessarily all) hitobject states must be re-validated. + private readonly Cached combinedObjCache = new Cached(); public ScrollingHitObjectContainer() { RelativeSizeAxes = Axes.Both; + + AddLayout(layoutCache); } [BackgroundDependencyLoader] @@ -32,30 +44,144 @@ namespace osu.Game.Rulesets.UI.Scrolling direction.BindTo(scrollingInfo.Direction); timeRange.BindTo(scrollingInfo.TimeRange); - direction.ValueChanged += _ => initialStateCache.Invalidate(); - timeRange.ValueChanged += _ => initialStateCache.Invalidate(); + direction.ValueChanged += _ => layoutCache.Invalidate(); + timeRange.ValueChanged += _ => layoutCache.Invalidate(); } public override void Add(DrawableHitObject hitObject) { - initialStateCache.Invalidate(); + combinedObjCache.Invalidate(); + hitObject.DefaultsApplied += onDefaultsApplied; base.Add(hitObject); } public override bool Remove(DrawableHitObject hitObject) { var result = base.Remove(hitObject); + if (result) - initialStateCache.Invalidate(); + { + combinedObjCache.Invalidate(); + hitObjectInitialStateCache.Remove(hitObject); + + hitObject.DefaultsApplied -= onDefaultsApplied; + } + return result; } - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) + public override void Clear(bool disposeChildren = true) { - if ((invalidation & (Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo)) > 0) - initialStateCache.Invalidate(); + foreach (var h in Objects) + h.DefaultsApplied -= onDefaultsApplied; - return base.Invalidate(invalidation, source, shallPropagate); + base.Clear(disposeChildren); + + combinedObjCache.Invalidate(); + hitObjectInitialStateCache.Clear(); + } + + /// <summary> + /// Given a position in screen space, return the time within this column. + /// </summary> + public double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) + { + // convert to local space of column so we can snap and fetch correct location. + Vector2 localPosition = ToLocalSpace(screenSpacePosition); + + float position = 0; + + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Up: + case ScrollingDirection.Down: + position = localPosition.Y; + break; + + case ScrollingDirection.Right: + case ScrollingDirection.Left: + position = localPosition.X; + break; + } + + flipPositionIfRequired(ref position); + + return scrollingInfo.Algorithm.TimeAt(position, Time.Current, scrollingInfo.TimeRange.Value, getLength()); + } + + /// <summary> + /// Given a time, return the screen space position within this column. + /// </summary> + public Vector2 ScreenSpacePositionAtTime(double time) + { + var pos = scrollingInfo.Algorithm.PositionAt(time, Time.Current, scrollingInfo.TimeRange.Value, getLength()); + + flipPositionIfRequired(ref pos); + + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Up: + case ScrollingDirection.Down: + return ToScreenSpace(new Vector2(getBreadth() / 2, pos)); + + default: + return ToScreenSpace(new Vector2(pos, getBreadth() / 2)); + } + } + + private float getLength() + { + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Left: + case ScrollingDirection.Right: + return DrawWidth; + + default: + return DrawHeight; + } + } + + private float getBreadth() + { + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Up: + case ScrollingDirection.Down: + return DrawWidth; + + default: + return DrawHeight; + } + } + + private void flipPositionIfRequired(ref float position) + { + // We're dealing with screen coordinates in which the position decreases towards the centre of the screen resulting in an increase in start time. + // The scrolling algorithm instead assumes a top anchor meaning an increase in time corresponds to an increase in position, + // so when scrolling downwards the coordinates need to be flipped. + + switch (scrollingInfo.Direction.Value) + { + case ScrollingDirection.Down: + position = DrawHeight - position; + break; + + case ScrollingDirection.Right: + position = DrawWidth - position; + break; + } + } + + private void onDefaultsApplied(DrawableHitObject drawableObject) + { + // The cache may not exist if the hitobject state hasn't been computed yet (e.g. if the hitobject was added + defaults applied in the same frame). + // In such a case, combinedObjCache will take care of updating the hitobject. + if (hitObjectInitialStateCache.TryGetValue(drawableObject, out var state)) + { + combinedObjCache.Invalidate(); + state.Cache.Invalidate(); + } } private float scrollLength; @@ -64,7 +190,18 @@ namespace osu.Game.Rulesets.UI.Scrolling { base.Update(); - if (!initialStateCache.IsValid) + if (!layoutCache.IsValid) + { + foreach (var state in hitObjectInitialStateCache.Values) + state.Cache.Invalidate(); + combinedObjCache.Invalidate(); + + scrollingInfo.Algorithm.Reset(); + + layoutCache.Validate(); + } + + if (!combinedObjCache.IsValid) { switch (direction.Value) { @@ -78,30 +215,76 @@ namespace osu.Game.Rulesets.UI.Scrolling break; } - scrollingInfo.Algorithm.Reset(); - foreach (var obj in Objects) - computeInitialStateRecursive(obj); - initialStateCache.Validate(); + { + if (!hitObjectInitialStateCache.TryGetValue(obj, out var state)) + state = hitObjectInitialStateCache[obj] = new InitialState(new Cached()); + + if (state.Cache.IsValid) + continue; + + state.ScheduledComputation?.Cancel(); + state.ScheduledComputation = computeInitialStateRecursive(obj); + + computeLifetimeStartRecursive(obj); + + state.Cache.Validate(); + } + + combinedObjCache.Validate(); } } - private void computeInitialStateRecursive(DrawableHitObject hitObject) + private void computeLifetimeStartRecursive(DrawableHitObject hitObject) { - hitObject.LifetimeStart = scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, timeRange.Value); + hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); - if (hitObject.HitObject is IHasEndTime endTime) + foreach (var obj in hitObject.NestedHitObjects) + computeLifetimeStartRecursive(obj); + } + + private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject) + { + float originAdjustment = 0.0f; + + // calculate the dimension of the part of the hitobject that should already be visible + // when the hitobject origin first appears inside the scrolling container + switch (direction.Value) + { + case ScrollingDirection.Up: + originAdjustment = hitObject.OriginPosition.Y; + break; + + case ScrollingDirection.Down: + originAdjustment = hitObject.DrawHeight - hitObject.OriginPosition.Y; + break; + + case ScrollingDirection.Left: + originAdjustment = hitObject.OriginPosition.X; + break; + + case ScrollingDirection.Right: + originAdjustment = hitObject.DrawWidth - hitObject.OriginPosition.X; + break; + } + + return scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, originAdjustment, timeRange.Value, scrollLength); + } + + private ScheduledDelegate computeInitialStateRecursive(DrawableHitObject hitObject) => hitObject.Schedule(() => + { + if (hitObject.HitObject is IHasDuration e) { switch (direction.Value) { case ScrollingDirection.Up: case ScrollingDirection.Down: - hitObject.Height = scrollingInfo.Algorithm.GetLength(hitObject.HitObject.StartTime, endTime.EndTime, timeRange.Value, scrollLength); + hitObject.Height = scrollingInfo.Algorithm.GetLength(hitObject.HitObject.StartTime, e.EndTime, timeRange.Value, scrollLength); break; case ScrollingDirection.Left: case ScrollingDirection.Right: - hitObject.Width = scrollingInfo.Algorithm.GetLength(hitObject.HitObject.StartTime, endTime.EndTime, timeRange.Value, scrollLength); + hitObject.Width = scrollingInfo.Algorithm.GetLength(hitObject.HitObject.StartTime, e.EndTime, timeRange.Value, scrollLength); break; } } @@ -113,7 +296,7 @@ namespace osu.Game.Rulesets.UI.Scrolling // Nested hitobjects don't need to scroll, but they do need accurate positions updatePosition(obj, hitObject.HitObject.StartTime); } - } + }); protected override void UpdateAfterChildrenLife() { @@ -145,5 +328,19 @@ namespace osu.Game.Rulesets.UI.Scrolling break; } } + + private class InitialState + { + [NotNull] + public readonly Cached Cache; + + [CanBeNull] + public ScheduledDelegate ScheduledComputation; + + public InitialState(Cached cache) + { + Cache = cache; + } + } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs index bf2203e176..9dac3f4de1 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Drawables; +using osuTK; namespace osu.Game.Rulesets.UI.Scrolling { @@ -15,14 +16,26 @@ namespace osu.Game.Rulesets.UI.Scrolling protected readonly IBindable<ScrollingDirection> Direction = new Bindable<ScrollingDirection>(); [Resolved] - private IScrollingInfo scrollingInfo { get; set; } + protected IScrollingInfo ScrollingInfo { get; private set; } [BackgroundDependencyLoader] private void load() { - Direction.BindTo(scrollingInfo.Direction); + Direction.BindTo(ScrollingInfo.Direction); } + /// <summary> + /// Given a position in screen space, return the time within this column. + /// </summary> + public virtual double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) => + ((ScrollingHitObjectContainer)HitObjectContainer).TimeAtScreenSpacePosition(screenSpacePosition); + + /// <summary> + /// Given a time, return the screen space position within this column. + /// </summary> + public virtual Vector2 ScreenSpacePositionAtTime(double time) + => ((ScrollingHitObjectContainer)HitObjectContainer).ScreenSpacePositionAtTime(time); + protected sealed override HitObjectContainer CreateHitObjectContainer() => new ScrollingHitObjectContainer(); } } diff --git a/osu.Game/Scoring/HitResultDisplayStatistic.cs b/osu.Game/Scoring/HitResultDisplayStatistic.cs new file mode 100644 index 0000000000..d43d8bf0ba --- /dev/null +++ b/osu.Game/Scoring/HitResultDisplayStatistic.cs @@ -0,0 +1,41 @@ +// 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. + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Scoring +{ + /// <summary> + /// Compiled result data for a specific <see cref="HitResult"/> in a score. + /// </summary> + public class HitResultDisplayStatistic + { + /// <summary> + /// The associated result type. + /// </summary> + public HitResult Result { get; } + + /// <summary> + /// The count of successful hits of this type. + /// </summary> + public int Count { get; } + + /// <summary> + /// The maximum achievable hits of this type. May be null if undetermined. + /// </summary> + public int? MaxCount { get; } + + /// <summary> + /// A custom display name for the result type. May be provided by rulesets to give better clarity. + /// </summary> + public string DisplayName { get; } + + public HitResultDisplayStatistic(HitResult result, int count, int? maxCount, string displayName) + { + Result = result; + Count = count; + MaxCount = maxCount; + DisplayName = displayName; + } + } +} diff --git a/osu.Game/Scoring/Legacy/DatabasedLegacyScoreParser.cs b/osu.Game/Scoring/Legacy/DatabasedLegacyScoreDecoder.cs similarity index 64% rename from osu.Game/Scoring/Legacy/DatabasedLegacyScoreParser.cs rename to osu.Game/Scoring/Legacy/DatabasedLegacyScoreDecoder.cs index 77edd24612..9b590f56dd 100644 --- a/osu.Game/Scoring/Legacy/DatabasedLegacyScoreParser.cs +++ b/osu.Game/Scoring/Legacy/DatabasedLegacyScoreDecoder.cs @@ -7,21 +7,21 @@ using osu.Game.Rulesets; namespace osu.Game.Scoring.Legacy { /// <summary> - /// A <see cref="LegacyScoreParser"/> which retrieves the applicable <see cref="Beatmap"/> and <see cref="Ruleset"/> + /// A <see cref="LegacyScoreDecoder"/> which retrieves the applicable <see cref="Beatmap"/> and <see cref="Ruleset"/> /// for the score from the database. /// </summary> - public class DatabasedLegacyScoreParser : LegacyScoreParser + public class DatabasedLegacyScoreDecoder : LegacyScoreDecoder { private readonly RulesetStore rulesets; private readonly BeatmapManager beatmaps; - public DatabasedLegacyScoreParser(RulesetStore rulesets, BeatmapManager beatmaps) + public DatabasedLegacyScoreDecoder(RulesetStore rulesets, BeatmapManager beatmaps) { this.rulesets = rulesets; this.beatmaps = beatmaps; } protected override Ruleset GetRuleset(int rulesetId) => rulesets.GetRuleset(rulesetId).CreateInstance(); - protected override WorkingBeatmap GetBeatmap(string md5Hash) => beatmaps.GetWorkingBeatmap(beatmaps.QueryBeatmap(b => b.MD5Hash == md5Hash)); + protected override WorkingBeatmap GetBeatmap(string md5Hash) => beatmaps.GetWorkingBeatmap(beatmaps.QueryBeatmap(b => !b.BeatmapSet.DeletePending && b.MD5Hash == md5Hash)); } } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreParser.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs similarity index 78% rename from osu.Game/Scoring/Legacy/LegacyScoreParser.cs rename to osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 0fdbd56c92..97cb5ca7ab 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreParser.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -13,13 +13,12 @@ using osu.Game.Replays.Legacy; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Replays; -using osu.Game.Rulesets.Scoring; using osu.Game.Users; using SharpCompress.Compressors.LZMA; namespace osu.Game.Scoring.Legacy { - public abstract class LegacyScoreParser + public abstract class LegacyScoreDecoder { private IBeatmap currentBeatmap; private Ruleset currentRuleset; @@ -28,37 +27,35 @@ namespace osu.Game.Scoring.Legacy { var score = new Score { - ScoreInfo = new ScoreInfo(), Replay = new Replay() }; + WorkingBeatmap workingBeatmap; + using (SerializationReader sr = new SerializationReader(stream)) { currentRuleset = GetRuleset(sr.ReadByte()); - var scoreInfo = new LegacyScoreInfo { Ruleset = currentRuleset.RulesetInfo }; + var scoreInfo = new ScoreInfo { Ruleset = currentRuleset.RulesetInfo }; score.ScoreInfo = scoreInfo; var version = sr.ReadInt32(); - var workingBeatmap = GetBeatmap(sr.ReadString()); + workingBeatmap = GetBeatmap(sr.ReadString()); if (workingBeatmap is DummyWorkingBeatmap) throw new BeatmapNotFoundException(); - currentBeatmap = workingBeatmap.Beatmap; - scoreInfo.Beatmap = currentBeatmap.BeatmapInfo; - scoreInfo.User = new User { Username = sr.ReadString() }; // MD5Hash sr.ReadString(); - scoreInfo.Count300 = sr.ReadUInt16(); - scoreInfo.Count100 = sr.ReadUInt16(); - scoreInfo.Count50 = sr.ReadUInt16(); - scoreInfo.CountGeki = sr.ReadUInt16(); - scoreInfo.CountKatu = sr.ReadUInt16(); - scoreInfo.CountMiss = sr.ReadUInt16(); + scoreInfo.SetCount300(sr.ReadUInt16()); + scoreInfo.SetCount100(sr.ReadUInt16()); + scoreInfo.SetCount50(sr.ReadUInt16()); + scoreInfo.SetCountGeki(sr.ReadUInt16()); + scoreInfo.SetCountKatu(sr.ReadUInt16()); + scoreInfo.SetCountMiss(sr.ReadUInt16()); scoreInfo.TotalScore = sr.ReadInt32(); scoreInfo.MaxCombo = sr.ReadUInt16(); @@ -66,7 +63,10 @@ namespace osu.Game.Scoring.Legacy /* score.Perfect = */ sr.ReadBoolean(); - scoreInfo.Mods = currentRuleset.ConvertLegacyMods((LegacyMods)sr.ReadInt32()).ToArray(); + scoreInfo.Mods = currentRuleset.ConvertFromLegacyMods((LegacyMods)sr.ReadInt32()).ToArray(); + + currentBeatmap = workingBeatmap.GetPlayableBeatmap(currentRuleset.RulesetInfo, scoreInfo.Mods); + scoreInfo.Beatmap = currentBeatmap.BeatmapInfo; /* score.HpGraphString = */ sr.ReadString(); @@ -80,6 +80,9 @@ namespace osu.Game.Scoring.Legacy else if (version >= 20121008) scoreInfo.OnlineScoreID = sr.ReadInt32(); + if (scoreInfo.OnlineScoreID <= 0) + scoreInfo.OnlineScoreID = null; + if (compressedReplay?.Length > 0) { using (var replayInStream = new MemoryStream(compressedReplay)) @@ -110,17 +113,21 @@ namespace osu.Game.Scoring.Legacy CalculateAccuracy(score.ScoreInfo); + // before returning for database import, we must restore the database-sourced BeatmapInfo. + // if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception. + score.ScoreInfo.Beatmap = workingBeatmap.BeatmapInfo; + return score; } protected void CalculateAccuracy(ScoreInfo score) { - score.Statistics.TryGetValue(HitResult.Miss, out int countMiss); - score.Statistics.TryGetValue(HitResult.Meh, out int count50); - score.Statistics.TryGetValue(HitResult.Good, out int count100); - score.Statistics.TryGetValue(HitResult.Great, out int count300); - score.Statistics.TryGetValue(HitResult.Perfect, out int countGeki); - score.Statistics.TryGetValue(HitResult.Ok, out int countKatu); + int countMiss = score.GetCountMiss() ?? 0; + int count50 = score.GetCount50() ?? 0; + int count100 = score.GetCount100() ?? 0; + int count300 = score.GetCount300() ?? 0; + int countGeki = score.GetCountGeki() ?? 0; + int countKatu = score.GetCountKatu() ?? 0; switch (score.Ruleset.ID) { @@ -215,10 +222,13 @@ namespace osu.Game.Scoring.Legacy private void readLegacyReplay(Replay replay, StreamReader reader) { float lastTime = 0; + ReplayFrame currentFrame = null; - foreach (var l in reader.ReadToEnd().Split(',')) + var frames = reader.ReadToEnd().Split(','); + + for (var i = 0; i < frames.Length; i++) { - var split = l.Split('|'); + var split = frames[i].Split('|'); if (split.Length < 4) continue; @@ -230,30 +240,41 @@ namespace osu.Game.Scoring.Legacy } var diff = Parsing.ParseFloat(split[0]); + var mouseX = Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE); + var mouseY = Parsing.ParseFloat(split[2], Parsing.MAX_COORDINATE_VALUE); + lastTime += diff; + if (i < 2 && mouseX == 256 && mouseY == -500) + // at the start of the replay, stable places two replay frames, at time 0 and SkipBoundary - 1, respectively. + // both frames use a position of (256, -500). + // ignore these frames as they serve no real purpose (and can even mislead ruleset-specific handlers - see mania) + continue; + // Todo: At some point we probably want to rewind and play back the negative-time frames // but for now we'll achieve equal playback to stable by skipping negative frames if (diff < 0) continue; - replay.Frames.Add(convertFrame(new LegacyReplayFrame(lastTime, - Parsing.ParseFloat(split[1], Parsing.MAX_COORDINATE_VALUE), - Parsing.ParseFloat(split[2], Parsing.MAX_COORDINATE_VALUE), - (ReplayButtonState)Parsing.ParseInt(split[3])))); + currentFrame = convertFrame(new LegacyReplayFrame(lastTime, + mouseX, + mouseY, + (ReplayButtonState)Parsing.ParseInt(split[3])), currentFrame); + + replay.Frames.Add(currentFrame); } } - private ReplayFrame convertFrame(LegacyReplayFrame legacyFrame) + private ReplayFrame convertFrame(LegacyReplayFrame currentFrame, ReplayFrame lastFrame) { var convertible = currentRuleset.CreateConvertibleReplayFrame(); if (convertible == null) throw new InvalidOperationException($"Legacy replay cannot be converted for the ruleset: {currentRuleset.Description}"); - convertible.ConvertFrom(legacyFrame, currentBeatmap); + convertible.FromLegacy(currentFrame, currentBeatmap, lastFrame); var frame = (ReplayFrame)convertible; - frame.Time = legacyFrame.Time; + frame.Time = currentFrame.Time; return frame; } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs new file mode 100644 index 0000000000..db7e51e833 --- /dev/null +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -0,0 +1,114 @@ +// 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. + +using System; +using System.IO; +using System.Linq; +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; + +namespace osu.Game.Scoring.Legacy +{ + public class LegacyScoreEncoder + { + public const int LATEST_VERSION = 128; + + private readonly Score score; + private readonly IBeatmap beatmap; + + public LegacyScoreEncoder(Score score, IBeatmap beatmap) + { + this.score = score; + this.beatmap = beatmap; + + if (score.ScoreInfo.Beatmap.RulesetID < 0 || score.ScoreInfo.Beatmap.RulesetID > 3) + throw new ArgumentException("Only scores in the osu, taiko, catch, or mania rulesets can be encoded to the legacy score format.", nameof(score)); + } + + public void Encode(Stream stream) + { + using (SerializationWriter sw = new SerializationWriter(stream)) + { + sw.Write((byte)(score.ScoreInfo.Ruleset.ID ?? 0)); + sw.Write(LATEST_VERSION); + sw.Write(score.ScoreInfo.Beatmap.MD5Hash); + sw.Write(score.ScoreInfo.UserString); + sw.Write($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}".ComputeMD5Hash()); + sw.Write((ushort)(score.ScoreInfo.GetCount300() ?? 0)); + sw.Write((ushort)(score.ScoreInfo.GetCount100() ?? 0)); + sw.Write((ushort)(score.ScoreInfo.GetCount50() ?? 0)); + sw.Write((ushort)(score.ScoreInfo.GetCountGeki() ?? 0)); + sw.Write((ushort)(score.ScoreInfo.GetCountKatu() ?? 0)); + sw.Write((ushort)(score.ScoreInfo.GetCountMiss() ?? 0)); + sw.Write((int)(score.ScoreInfo.TotalScore)); + sw.Write((ushort)score.ScoreInfo.MaxCombo); + sw.Write(score.ScoreInfo.Combo == score.ScoreInfo.MaxCombo); + sw.Write((int)score.ScoreInfo.Ruleset.CreateInstance().ConvertToLegacyMods(score.ScoreInfo.Mods)); + + sw.Write(getHpGraphFormatted()); + sw.Write(score.ScoreInfo.Date.DateTime); + sw.WriteByteArray(createReplayData()); + sw.Write((long)0); + writeModSpecificData(score.ScoreInfo, sw); + } + } + + private void writeModSpecificData(ScoreInfo score, SerializationWriter sw) + { + } + + private byte[] createReplayData() + { + var content = new ASCIIEncoding().GetBytes(replayStringContent); + + using (var outStream = new MemoryStream()) + { + using (var lzma = new LzmaStream(new LzmaEncoderProperties(false, 1 << 21, 255), false, outStream)) + { + outStream.Write(lzma.Properties); + + long fileSize = content.Length; + for (int i = 0; i < 8; i++) + outStream.WriteByte((byte)(fileSize >> (8 * i))); + + lzma.Write(content); + } + + return outStream.ToArray(); + } + } + + private string replayStringContent + { + get + { + StringBuilder replayData = new StringBuilder(); + + if (score.Replay != null) + { + LegacyReplayFrame lastF = new LegacyReplayFrame(0, 0, 0, ReplayButtonState.None); + + foreach (var f in score.Replay.Frames.OfType<IConvertibleReplayFrame>().Select(f => f.ToLegacy(beatmap))) + { + replayData.Append(FormattableString.Invariant($"{f.Time - lastF.Time}|{f.MouseX ?? 0}|{f.MouseY ?? 0}|{(int)f.ButtonState},")); + lastF = f; + } + } + + replayData.AppendFormat(@"{0}|{1}|{2}|{3},", -12345, 0, 0, 0); + return replayData.ToString(); + } + } + + private string getHpGraphFormatted() + { + // todo: implement, maybe? + return string.Empty; + } + } +} diff --git a/osu.Game/Scoring/Legacy/LegacyScoreInfo.cs b/osu.Game/Scoring/Legacy/LegacyScoreInfo.cs deleted file mode 100644 index e66f93ec8d..0000000000 --- a/osu.Game/Scoring/Legacy/LegacyScoreInfo.cs +++ /dev/null @@ -1,118 +0,0 @@ -// 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. - -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Scoring.Legacy -{ - public class LegacyScoreInfo : ScoreInfo - { - private int countGeki; - - public int CountGeki - { - get => countGeki; - set - { - countGeki = value; - - switch (Ruleset?.ID ?? RulesetID) - { - case 3: - Statistics[HitResult.Perfect] = value; - break; - } - } - } - - private int count300; - - public int Count300 - { - get => count300; - set - { - count300 = value; - - switch (Ruleset?.ID ?? RulesetID) - { - case 0: - case 1: - case 3: - Statistics[HitResult.Great] = value; - break; - - case 2: - Statistics[HitResult.Perfect] = value; - break; - } - } - } - - private int countKatu; - - public int CountKatu - { - get => countKatu; - set - { - countKatu = value; - - switch (Ruleset?.ID ?? RulesetID) - { - case 3: - Statistics[HitResult.Good] = value; - break; - } - } - } - - private int count100; - - public int Count100 - { - get => count100; - set - { - count100 = value; - - switch (Ruleset?.ID ?? RulesetID) - { - case 0: - case 1: - Statistics[HitResult.Good] = value; - break; - - case 3: - Statistics[HitResult.Ok] = value; - break; - } - } - } - - private int count50; - - public int Count50 - { - get => count50; - set - { - count50 = value; - - switch (Ruleset?.ID ?? RulesetID) - { - case 0: - case 3: - Statistics[HitResult.Meh] = value; - break; - } - } - } - - public int CountMiss - { - get => Statistics[HitResult.Miss]; - set => Statistics[HitResult.Miss] = value; - } - } -} diff --git a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs new file mode 100644 index 0000000000..b58f65800d --- /dev/null +++ b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs @@ -0,0 +1,139 @@ +// 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. + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Scoring.Legacy +{ + public static class ScoreInfoExtensions + { + public static int? GetCountGeki(this ScoreInfo scoreInfo) + { + switch (scoreInfo.Ruleset?.ID ?? scoreInfo.RulesetID) + { + case 3: + return getCount(scoreInfo, HitResult.Perfect); + } + + return null; + } + + public static void SetCountGeki(this ScoreInfo scoreInfo, int value) + { + switch (scoreInfo.Ruleset?.ID ?? scoreInfo.RulesetID) + { + case 3: + scoreInfo.Statistics[HitResult.Perfect] = value; + break; + } + } + + public static int? GetCount300(this ScoreInfo scoreInfo) => getCount(scoreInfo, HitResult.Great); + + public static void SetCount300(this ScoreInfo scoreInfo, int value) => scoreInfo.Statistics[HitResult.Great] = value; + + public static int? GetCountKatu(this ScoreInfo scoreInfo) + { + switch (scoreInfo.Ruleset?.ID ?? scoreInfo.RulesetID) + { + case 3: + return getCount(scoreInfo, HitResult.Good); + + case 2: + return getCount(scoreInfo, HitResult.SmallTickMiss); + } + + return null; + } + + public static void SetCountKatu(this ScoreInfo scoreInfo, int value) + { + switch (scoreInfo.Ruleset?.ID ?? scoreInfo.RulesetID) + { + case 3: + scoreInfo.Statistics[HitResult.Good] = value; + break; + + case 2: + scoreInfo.Statistics[HitResult.SmallTickMiss] = value; + break; + } + } + + public static int? GetCount100(this ScoreInfo scoreInfo) + { + switch (scoreInfo.Ruleset?.ID ?? scoreInfo.RulesetID) + { + case 0: + case 1: + case 3: + return getCount(scoreInfo, HitResult.Ok); + + case 2: + return getCount(scoreInfo, HitResult.LargeTickHit); + } + + return null; + } + + public static void SetCount100(this ScoreInfo scoreInfo, int value) + { + switch (scoreInfo.Ruleset?.ID ?? scoreInfo.RulesetID) + { + case 0: + case 1: + case 3: + scoreInfo.Statistics[HitResult.Ok] = value; + break; + + case 2: + scoreInfo.Statistics[HitResult.LargeTickHit] = value; + break; + } + } + + public static int? GetCount50(this ScoreInfo scoreInfo) + { + switch (scoreInfo.Ruleset?.ID ?? scoreInfo.RulesetID) + { + case 0: + case 3: + return getCount(scoreInfo, HitResult.Meh); + + case 2: + return getCount(scoreInfo, HitResult.SmallTickHit); + } + + return null; + } + + public static void SetCount50(this ScoreInfo scoreInfo, int value) + { + switch (scoreInfo.Ruleset?.ID ?? scoreInfo.RulesetID) + { + case 0: + case 3: + scoreInfo.Statistics[HitResult.Meh] = value; + break; + + case 2: + scoreInfo.Statistics[HitResult.SmallTickHit] = value; + break; + } + } + + public static int? GetCountMiss(this ScoreInfo scoreInfo) => + getCount(scoreInfo, HitResult.Miss); + + public static void SetCountMiss(this ScoreInfo scoreInfo, int value) => + scoreInfo.Statistics[HitResult.Miss] = value; + + private static int? getCount(ScoreInfo scoreInfo, HitResult result) + { + if (scoreInfo.Statistics.TryGetValue(result, out var existing)) + return existing; + + return null; + } + } +} diff --git a/osu.Game/Scoring/LegacyDatabasedScore.cs b/osu.Game/Scoring/LegacyDatabasedScore.cs index 172e08e2d0..8908775472 100644 --- a/osu.Game/Scoring/LegacyDatabasedScore.cs +++ b/osu.Game/Scoring/LegacyDatabasedScore.cs @@ -16,10 +16,13 @@ namespace osu.Game.Scoring { ScoreInfo = score; - var replayFilename = score.Files.First(f => f.Filename.EndsWith(".osr", StringComparison.InvariantCultureIgnoreCase)).FileInfo.StoragePath; + var replayFilename = score.Files.FirstOrDefault(f => f.Filename.EndsWith(".osr", StringComparison.InvariantCultureIgnoreCase))?.FileInfo.StoragePath; + + if (replayFilename == null) + return; using (var stream = store.GetStream(replayFilename)) - Replay = new DatabasedLegacyScoreParser(rulesets, beatmaps).Parse(stream).Replay; + Replay = new DatabasedLegacyScoreDecoder(rulesets, beatmaps).Parse(stream).Replay; } } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 266725a739..f5192f3a40 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -7,12 +7,14 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Converters; +using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Users; using osu.Game.Rulesets.Scoring; +using osu.Game.Users; +using osu.Game.Utils; namespace osu.Game.Scoring { @@ -31,6 +33,9 @@ namespace osu.Game.Scoring [Column(TypeName = "DECIMAL(1,4)")] public double Accuracy { get; set; } + [JsonIgnore] + public string DisplayAccuracy => Accuracy.FormatAccuracy(); + [JsonProperty(@"pp")] public double? PP { get; set; } @@ -89,7 +94,7 @@ namespace osu.Game.Scoring if (mods == null) return null; - return modsJson = JsonConvert.SerializeObject(mods); + return modsJson = JsonConvert.SerializeObject(mods.Select(m => new DeserializedMod { Acronym = m.Acronym })); } set { @@ -111,23 +116,19 @@ namespace osu.Game.Scoring get => User?.Username; set { - if (User == null) - User = new User(); - + User ??= new User(); User.Username = value; } } [JsonIgnore] [Column("UserID")] - public long? UserID + public int? UserID { get => User?.Id ?? 1; set { - if (User == null) - User = new User(); - + User ??= new User(); User.Id = value ?? 1; } } @@ -164,6 +165,10 @@ namespace osu.Game.Scoring } } + [NotMapped] + [JsonIgnore] + public List<HitEvent> HitEvents { get; set; } + [JsonIgnore] public List<ScoreFileInfo> Files { get; set; } @@ -173,6 +178,79 @@ namespace osu.Game.Scoring [JsonIgnore] public bool DeletePending { get; set; } + /// <summary> + /// The position of this score, starting at 1. + /// </summary> + [NotMapped] + [JsonProperty("position")] + public int? Position { get; set; } + + private bool isLegacyScore; + + /// <summary> + /// Whether this <see cref="ScoreInfo"/> represents a legacy (osu!stable) score. + /// </summary> + [JsonIgnore] + [NotMapped] + public bool IsLegacyScore + { + get + { + if (isLegacyScore) + return true; + + // The above check will catch legacy online scores that have an appropriate UserString + UserId. + // For non-online scores such as those imported in, a heuristic is used based on the following table: + // + // Mode | UserString | UserId + // --------------- | ---------- | --------- + // stable | <username> | 1 + // lazer | <username> | <userid> + // lazer (offline) | Guest | 1 + + return ID > 0 && UserID == 1 && UserString != "Guest"; + } + set => isLegacyScore = value; + } + + public IEnumerable<HitResultDisplayStatistic> GetStatisticsForDisplay() + { + foreach (var r in Ruleset.CreateInstance().GetHitResults()) + { + int value = Statistics.GetOrDefault(r.result); + + switch (r.result) + { + case HitResult.SmallTickHit: + { + int total = value + Statistics.GetOrDefault(HitResult.SmallTickMiss); + if (total > 0) + yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); + + break; + } + + case HitResult.LargeTickHit: + { + int total = value + Statistics.GetOrDefault(HitResult.LargeTickMiss); + if (total > 0) + yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); + + break; + } + + case HitResult.SmallTickMiss: + case HitResult.LargeTickMiss: + break; + + default: + yield return new HitResultDisplayStatistic(r.result, value, null, r.displayName); + + break; + } + } + } + [Serializable] protected class DeserializedMod : IMod { @@ -183,6 +261,21 @@ namespace osu.Game.Scoring public override string ToString() => $"{User} playing {Beatmap}"; - public bool Equals(ScoreInfo other) => other?.OnlineScoreID == OnlineScoreID; + public bool Equals(ScoreInfo other) + { + if (other == null) + return false; + + if (ID != 0 && other.ID != 0) + return ID == other.ID; + + if (OnlineScoreID.HasValue && other.OnlineScoreID.HasValue) + return OnlineScoreID == other.OnlineScoreID; + + if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash)) + return Hash == other.Hash; + + return ReferenceEquals(this, other); + } } } diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 8475158c78..cf1d123c06 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -6,22 +6,28 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Linq.Expressions; +using System.Threading; +using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Database; using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring.Legacy; namespace osu.Game.Scoring { public class ScoreManager : DownloadableArchiveModelManager<ScoreInfo, ScoreFileInfo> { - public override string[] HandledExtensions => new[] { ".osr" }; + public override IEnumerable<string> HandledExtensions => new[] { ".osr" }; protected override string[] HashableFileTypes => new[] { ".osr" }; @@ -30,11 +36,20 @@ namespace osu.Game.Scoring private readonly RulesetStore rulesets; private readonly Func<BeatmapManager> beatmaps; - public ScoreManager(RulesetStore rulesets, Func<BeatmapManager> beatmaps, Storage storage, IAPIProvider api, IDatabaseContextFactory contextFactory, IIpcHost importHost = null) + [CanBeNull] + private readonly Func<BeatmapDifficultyCache> difficulties; + + [CanBeNull] + private readonly OsuConfigManager configManager; + + public ScoreManager(RulesetStore rulesets, Func<BeatmapManager> beatmaps, Storage storage, IAPIProvider api, IDatabaseContextFactory contextFactory, IIpcHost importHost = null, + Func<BeatmapDifficultyCache> difficulties = null, OsuConfigManager configManager = null) : base(storage, contextFactory, api, new ScoreStore(contextFactory, storage), importHost) { this.rulesets = rulesets; this.beatmaps = beatmaps; + this.difficulties = difficulties; + this.configManager = configManager; } protected override ScoreInfo CreateModel(ArchiveReader archive) @@ -42,13 +57,13 @@ namespace osu.Game.Scoring if (archive == null) return null; - using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr")))) + using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)))) { try { - return new DatabasedLegacyScoreParser(rulesets, beatmaps()).Parse(stream).ScoreInfo; + return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo; } - catch (LegacyScoreParser.BeatmapNotFoundException e) + catch (LegacyScoreDecoder.BeatmapNotFoundException e) { Logger.Log(e.Message, LoggingTarget.Information, LogLevel.Error); return null; @@ -57,7 +72,7 @@ namespace osu.Game.Scoring } protected override IEnumerable<string> GetStableImportPaths(Storage stableStorage) - => stableStorage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.InvariantCultureIgnoreCase) ?? false)); + => stableStorage.GetFiles(ImportFromStablePath).Where(p => HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false)); public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store); @@ -69,6 +84,136 @@ namespace osu.Game.Scoring protected override ArchiveDownloadRequest<ScoreInfo> CreateDownloadRequest(ScoreInfo score, bool minimiseDownload) => new DownloadReplayRequest(score); - protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable<ScoreInfo> items) => items.Any(s => s.OnlineScoreID == model.OnlineScoreID && s.Files.Any()); + protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable<ScoreInfo> items) + => base.CheckLocalAvailability(model, items) + || (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID)); + + /// <summary> + /// Retrieves a bindable that represents the total score of a <see cref="ScoreInfo"/>. + /// </summary> + /// <remarks> + /// Responds to changes in the currently-selected <see cref="ScoringMode"/>. + /// </remarks> + /// <param name="score">The <see cref="ScoreInfo"/> to retrieve the bindable for.</param> + /// <returns>The bindable containing the total score.</returns> + public Bindable<long> GetBindableTotalScore(ScoreInfo score) + { + var bindable = new TotalScoreBindable(score, difficulties); + configManager?.BindWith(OsuSetting.ScoreDisplayMode, bindable.ScoringMode); + return bindable; + } + + /// <summary> + /// Retrieves a bindable that represents the formatted total score string of a <see cref="ScoreInfo"/>. + /// </summary> + /// <remarks> + /// Responds to changes in the currently-selected <see cref="ScoringMode"/>. + /// </remarks> + /// <param name="score">The <see cref="ScoreInfo"/> to retrieve the bindable for.</param> + /// <returns>The bindable containing the formatted total score string.</returns> + public Bindable<string> GetBindableTotalScoreString(ScoreInfo score) => new TotalScoreStringBindable(GetBindableTotalScore(score)); + + /// <summary> + /// Provides the total score of a <see cref="ScoreInfo"/>. Responds to changes in the currently-selected <see cref="ScoringMode"/>. + /// </summary> + private class TotalScoreBindable : Bindable<long> + { + public readonly Bindable<ScoringMode> ScoringMode = new Bindable<ScoringMode>(); + + private readonly ScoreInfo score; + private readonly Func<BeatmapDifficultyCache> difficulties; + + /// <summary> + /// Creates a new <see cref="TotalScoreBindable"/>. + /// </summary> + /// <param name="score">The <see cref="ScoreInfo"/> to provide the total score of.</param> + /// <param name="difficulties">A function to retrieve the <see cref="BeatmapDifficultyCache"/>.</param> + public TotalScoreBindable(ScoreInfo score, Func<BeatmapDifficultyCache> difficulties) + { + this.score = score; + this.difficulties = difficulties; + + ScoringMode.BindValueChanged(onScoringModeChanged, true); + } + + private IBindable<StarDifficulty> difficultyBindable; + private CancellationTokenSource difficultyCancellationSource; + + private void onScoringModeChanged(ValueChangedEvent<ScoringMode> mode) + { + difficultyCancellationSource?.Cancel(); + difficultyCancellationSource = null; + + if (score.Beatmap == null) + { + Value = score.TotalScore; + return; + } + + int beatmapMaxCombo; + + if (score.IsLegacyScore) + { + // 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) + { + if (score.Beatmap.ID == 0 || difficulties == null) + { + // We don't have enough information (max combo) to compute the score, so use the provided score. + Value = score.TotalScore; + return; + } + + // 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); + + return; + } + + beatmapMaxCombo = score.Beatmap.MaxCombo.Value; + } + else + { + // This score is guaranteed to be an osu!lazer score. + // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. + beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType<HitResult>().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetOrDefault(r)).Sum(); + } + + updateScore(beatmapMaxCombo); + } + + private void updateScore(int beatmapMaxCombo) + { + if (beatmapMaxCombo == 0) + { + Value = 0; + return; + } + + var ruleset = score.Ruleset.CreateInstance(); + var scoreProcessor = ruleset.CreateScoreProcessor(); + + scoreProcessor.Mods.Value = score.Mods; + + Value = (long)Math.Round(scoreProcessor.GetScore(ScoringMode.Value, beatmapMaxCombo, score.Accuracy, (double)score.MaxCombo / beatmapMaxCombo, score.Statistics)); + } + } + + /// <summary> + /// Provides the total score of a <see cref="ScoreInfo"/> as a formatted string. Responds to changes in the currently-selected <see cref="ScoringMode"/>. + /// </summary> + private class TotalScoreStringBindable : Bindable<string> + { + // ReSharper disable once PrivateFieldCanBeConvertedToLocalVariable (need to hold a reference) + private readonly IBindable<long> totalScore; + + public TotalScoreStringBindable(IBindable<long> totalScore) + { + this.totalScore = totalScore; + this.totalScore.BindValueChanged(v => Value = v.NewValue.ToString("N0"), true); + } + } } } diff --git a/osu.Game/Scoring/ScorePerformanceCache.cs b/osu.Game/Scoring/ScorePerformanceCache.cs new file mode 100644 index 0000000000..5f66c13d2f --- /dev/null +++ b/osu.Game/Scoring/ScorePerformanceCache.cs @@ -0,0 +1,70 @@ +// 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. + +using System; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Game.Beatmaps; +using osu.Game.Database; + +namespace osu.Game.Scoring +{ + /// <summary> + /// A component which performs and acts as a central cache for performance calculations of locally databased scores. + /// Currently not persisted between game sessions. + /// </summary> + public class ScorePerformanceCache : MemoryCachingComponent<ScorePerformanceCache.PerformanceCacheLookup, double?> + { + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + + protected override bool CacheNullValues => false; + + /// <summary> + /// Calculates performance for the given <see cref="ScoreInfo"/>. + /// </summary> + /// <param name="score">The score to do the calculation on. </param> + /// <param name="token">An optional <see cref="CancellationToken"/> to cancel the operation.</param> + public Task<double?> CalculatePerformanceAsync([NotNull] ScoreInfo score, CancellationToken token = default) => + GetAsync(new PerformanceCacheLookup(score), token); + + protected override async Task<double?> ComputeValueAsync(PerformanceCacheLookup lookup, CancellationToken token = default) + { + var score = lookup.ScoreInfo; + + var attributes = await difficultyCache.GetDifficultyAsync(score.Beatmap, score.Ruleset, score.Mods, token); + + // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. + if (attributes.Attributes == null) + return null; + + token.ThrowIfCancellationRequested(); + + var calculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(attributes.Attributes, score); + + return calculator?.Calculate(); + } + + public readonly struct PerformanceCacheLookup + { + public readonly ScoreInfo ScoreInfo; + + public PerformanceCacheLookup(ScoreInfo info) + { + ScoreInfo = info; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + + hash.Add(ScoreInfo.Hash); + hash.Add(ScoreInfo.ID); + + return hash.ToHashCode(); + } + } + } +} diff --git a/osu.Game/Scoring/ScoreStore.cs b/osu.Game/Scoring/ScoreStore.cs index 9627481f4d..f5c5cd5dad 100644 --- a/osu.Game/Scoring/ScoreStore.cs +++ b/osu.Game/Scoring/ScoreStore.cs @@ -18,6 +18,8 @@ namespace osu.Game.Scoring protected override IQueryable<ScoreInfo> AddIncludesForConsumption(IQueryable<ScoreInfo> query) => base.AddIncludesForConsumption(query) .Include(s => s.Beatmap) + .Include(s => s.Beatmap).ThenInclude(b => b.Metadata) + .Include(s => s.Beatmap).ThenInclude(b => b.BeatmapSet).ThenInclude(s => s.Metadata) .Include(s => s.Ruleset); } } diff --git a/osu.Game/Screens/BackgroundScreen.cs b/osu.Game/Screens/BackgroundScreen.cs index bbe162cf7c..0f3615b7a9 100644 --- a/osu.Game/Screens/BackgroundScreen.cs +++ b/osu.Game/Screens/BackgroundScreen.cs @@ -11,8 +11,11 @@ namespace osu.Game.Screens { public abstract class BackgroundScreen : Screen, IEquatable<BackgroundScreen> { - protected BackgroundScreen() + private readonly bool animateOnEnter; + + protected BackgroundScreen(bool animateOnEnter = true) { + this.animateOnEnter = animateOnEnter; Anchor = Anchor.Centre; Origin = Anchor.Centre; } @@ -27,7 +30,7 @@ namespace osu.Game.Screens protected override bool OnKeyDown(KeyDownEvent e) { - //we don't want to handle escape key. + // we don't want to handle escape key. return false; } @@ -39,11 +42,14 @@ namespace osu.Game.Screens public override void OnEntering(IScreen last) { - this.FadeOut(); - this.MoveToX(x_movement_amount); + if (animateOnEnter) + { + this.FadeOut(); + this.MoveToX(x_movement_amount); - this.FadeIn(transition_length, Easing.InOutQuart); - this.MoveToX(0, transition_length, Easing.InOutQuart); + this.FadeIn(transition_length, Easing.InOutQuart); + this.MoveToX(0, transition_length, Easing.InOutQuart); + } base.OnEntering(last); } diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs index 5225740d0b..b08455be95 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenBeatmap.cs @@ -6,7 +6,6 @@ using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Backgrounds; @@ -37,7 +36,9 @@ namespace osu.Game.Screens.Backgrounds /// <summary> /// The amount of blur to be applied in addition to user-specified blur. /// </summary> - public readonly Bindable<float> BlurAmount = new Bindable<float>(); + public readonly Bindable<float> BlurAmount = new BindableFloat(); + + internal readonly IBindable<bool> IsBreakTime = new Bindable<bool>(); private readonly DimmableBackground dimmable; @@ -48,8 +49,12 @@ namespace osu.Game.Screens.Backgrounds Beatmap = beatmap; InternalChild = dimmable = CreateFadeContainer(); + dimmable.EnableUserDim.BindTo(EnableUserDim); + dimmable.IsBreakTime.BindTo(IsBreakTime); dimmable.BlurAmount.BindTo(BlurAmount); + + StoryboardReplacesBackground.BindTo(dimmable.StoryboardReplacesBackground); } [BackgroundDependencyLoader] @@ -74,7 +79,7 @@ namespace osu.Game.Screens.Backgrounds Schedule(() => { - if ((Background as BeatmapBackground)?.Beatmap == beatmap) + if ((Background as BeatmapBackground)?.Beatmap.BeatmapInfo.BackgroundEquals(beatmap?.BeatmapInfo) ?? false) return; cancellationSource?.Cancel(); @@ -97,7 +102,6 @@ namespace osu.Game.Screens.Backgrounds b.Depth = newDepth; dimmable.Background = Background = b; - StoryboardReplacesBackground.BindTo(dimmable.StoryboardReplacesBackground); } public override bool Equals(BackgroundScreen other) @@ -107,22 +111,6 @@ namespace osu.Game.Screens.Backgrounds return base.Equals(other) && beatmap == otherBeatmapBackground.Beatmap; } - protected class BeatmapBackground : Background - { - public readonly WorkingBeatmap Beatmap; - - public BeatmapBackground(WorkingBeatmap beatmap) - { - Beatmap = beatmap; - } - - [BackgroundDependencyLoader] - private void load(TextureStore textures) - { - Sprite.Texture = Beatmap?.Background ?? textures.Get(@"Backgrounds/bg1"); - } - } - public class DimmableBackground : UserDimContainer { /// <summary> @@ -131,7 +119,7 @@ namespace osu.Game.Screens.Backgrounds /// <remarks> /// Used in contexts where there can potentially be both user and screen-specified blurring occuring at the same time, such as in <see cref="PlayerLoader"/> /// </remarks> - public readonly Bindable<float> BlurAmount = new Bindable<float>(); + public readonly Bindable<float> BlurAmount = new BindableFloat(); public Background Background { diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenCustom.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenCustom.cs index 0cb41bc562..49c7934ed9 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenCustom.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenCustom.cs @@ -17,10 +17,10 @@ namespace osu.Game.Screens.Backgrounds public override bool Equals(BackgroundScreen other) { - var backgroundScreenCustom = other as BackgroundScreenCustom; - if (backgroundScreenCustom == null) return false; + if (other is BackgroundScreenCustom backgroundScreenCustom) + return base.Equals(other) && textureName == backgroundScreenCustom.textureName; - return base.Equals(other) && textureName == backgroundScreenCustom.textureName; + return false; } } } diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index 55338ea01a..8beb955824 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -1,11 +1,14 @@ // 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. +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Graphics.Backgrounds; using osu.Game.Online.API; using osu.Game.Skinning; @@ -19,24 +22,40 @@ namespace osu.Game.Screens.Backgrounds private int currentDisplay; private const int background_count = 7; - - private string backgroundName => $@"Menu/menu-background-{currentDisplay % background_count + 1}"; - private Bindable<User> user; private Bindable<Skin> skin; + private Bindable<BackgroundSource> mode; + private Bindable<IntroSequence> introSequence; + private readonly SeasonalBackgroundLoader seasonalBackgroundLoader = new SeasonalBackgroundLoader(); + + [Resolved] + private IBindable<WorkingBeatmap> beatmap { get; set; } + + public BackgroundScreenDefault(bool animateOnEnter = true) + : base(animateOnEnter) + { + } [BackgroundDependencyLoader] - private void load(IAPIProvider api, SkinManager skinManager) + private void load(IAPIProvider api, SkinManager skinManager, OsuConfigManager config) { user = api.LocalUser.GetBoundCopy(); skin = skinManager.CurrentSkin.GetBoundCopy(); + mode = config.GetBindable<BackgroundSource>(OsuSetting.MenuBackgroundSource); + introSequence = config.GetBindable<IntroSequence>(OsuSetting.IntroSequence); + + AddInternal(seasonalBackgroundLoader); user.ValueChanged += _ => Next(); skin.ValueChanged += _ => Next(); + mode.ValueChanged += _ => Next(); + beatmap.ValueChanged += _ => Next(); + introSequence.ValueChanged += _ => Next(); + seasonalBackgroundLoader.SeasonalBackgroundChanged += Next; currentDisplay = RNG.Next(0, background_count); - display(createBackground()); + Next(); } private void display(Background newBackground) @@ -49,19 +68,53 @@ namespace osu.Game.Screens.Backgrounds } private ScheduledDelegate nextTask; + private CancellationTokenSource cancellationTokenSource; public void Next() { nextTask?.Cancel(); - nextTask = Scheduler.AddDelayed(() => { LoadComponentAsync(createBackground(), display); }, 100); + cancellationTokenSource?.Cancel(); + cancellationTokenSource = new CancellationTokenSource(); + nextTask = Scheduler.AddDelayed(() => LoadComponentAsync(createBackground(), display, cancellationTokenSource.Token), 100); } private Background createBackground() { Background newBackground; + string backgroundName; + + var seasonalBackground = seasonalBackgroundLoader.LoadNextBackground(); + + if (seasonalBackground != null) + { + seasonalBackground.Depth = currentDisplay; + return seasonalBackground; + } + + switch (introSequence.Value) + { + case IntroSequence.Welcome: + backgroundName = "Intro/Welcome/menu-background"; + break; + + default: + backgroundName = $@"Menu/menu-background-{currentDisplay % background_count + 1}"; + break; + } if (user.Value?.IsSupporter ?? false) - newBackground = new SkinnedBackground(skin.Value, backgroundName); + { + switch (mode.Value) + { + case BackgroundSource.Beatmap: + newBackground = new BeatmapBackground(beatmap.Value, backgroundName); + break; + + default: + newBackground = new SkinnedBackground(skin.Value, backgroundName); + break; + } + } else newBackground = new Background(backgroundName); diff --git a/osu.Game/Screens/Charts/ChartInfo.cs b/osu.Game/Screens/Charts/ChartInfo.cs deleted file mode 100644 index d9a9ad5eeb..0000000000 --- a/osu.Game/Screens/Charts/ChartInfo.cs +++ /dev/null @@ -1,9 +0,0 @@ -// 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. - -namespace osu.Game.Screens.Charts -{ - public class ChartInfo : ScreenWhiteBox - { - } -} diff --git a/osu.Game/Screens/Charts/ChartListing.cs b/osu.Game/Screens/Charts/ChartListing.cs deleted file mode 100644 index 18bba6433f..0000000000 --- a/osu.Game/Screens/Charts/ChartListing.cs +++ /dev/null @@ -1,16 +0,0 @@ -// 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. - -using System; -using System.Collections.Generic; - -namespace osu.Game.Screens.Charts -{ - public class ChartListing : ScreenWhiteBox - { - protected override IEnumerable<Type> PossibleChildren => new[] - { - typeof(ChartInfo) - }; - } -} diff --git a/osu.Game/Screens/Direct/OnlineListing.cs b/osu.Game/Screens/Direct/OnlineListing.cs deleted file mode 100644 index 8376383674..0000000000 --- a/osu.Game/Screens/Direct/OnlineListing.cs +++ /dev/null @@ -1,9 +0,0 @@ -// 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. - -namespace osu.Game.Screens.Direct -{ - public class OnlineListing : ScreenWhiteBox - { - } -} diff --git a/osu.Game/Screens/Edit/BindableBeatDivisor.cs b/osu.Game/Screens/Edit/BindableBeatDivisor.cs index ea3b68e3bd..d9477dd4bc 100644 --- a/osu.Game/Screens/Edit/BindableBeatDivisor.cs +++ b/osu.Game/Screens/Edit/BindableBeatDivisor.cs @@ -4,10 +4,13 @@ using System; using System.Linq; using osu.Framework.Bindables; +using osu.Framework.Graphics.Colour; +using osu.Game.Graphics; +using osuTK.Graphics; namespace osu.Game.Screens.Edit { - public class BindableBeatDivisor : BindableNumber<int> + public class BindableBeatDivisor : BindableInt { public static readonly int[] VALID_DIVISORS = { 1, 2, 3, 4, 6, 8, 12, 16 }; @@ -26,7 +29,10 @@ namespace osu.Game.Screens.Edit set { if (!VALID_DIVISORS.Contains(value)) - throw new ArgumentOutOfRangeException($"Provided divisor is not in {nameof(VALID_DIVISORS)}"); + { + // If it doesn't match, value will be 0, but will be clamped to the valid range via DefaultMinValue + value = Array.FindLast(VALID_DIVISORS, d => d < value); + } base.Value = value; } @@ -35,5 +41,63 @@ namespace osu.Game.Screens.Edit protected override int DefaultMinValue => VALID_DIVISORS.First(); protected override int DefaultMaxValue => VALID_DIVISORS.Last(); protected override int DefaultPrecision => 1; + + /// <summary> + /// Retrieves the appropriate colour for a beat divisor. + /// </summary> + /// <param name="beatDivisor">The beat divisor.</param> + /// <param name="colours">The set of colours.</param> + /// <returns>The applicable colour from <paramref name="colours"/> for <paramref name="beatDivisor"/>.</returns> + public static ColourInfo GetColourFor(int beatDivisor, OsuColour colours) + { + switch (beatDivisor) + { + case 1: + return Color4.White; + + case 2: + return colours.Red; + + case 4: + return colours.Blue; + + case 8: + return colours.Yellow; + + case 16: + return colours.PurpleDark; + + case 3: + return colours.Purple; + + case 6: + return colours.YellowDark; + + case 12: + return colours.YellowDarker; + + default: + return Color4.Red; + } + } + + /// <summary> + /// Retrieves the applicable divisor for a specific beat index. + /// </summary> + /// <param name="index">The 0-based beat index.</param> + /// <param name="beatDivisor">The beat divisor.</param> + /// <returns>The applicable divisor.</returns> + public static int GetDivisorForBeatIndex(int index, int beatDivisor) + { + int beat = index % beatDivisor; + + foreach (var divisor in BindableBeatDivisor.VALID_DIVISORS) + { + if ((beat * divisor) % beatDivisor == 0) + return divisor; + } + + return 0; + } } } diff --git a/osu.Game/Screens/Edit/ClipboardContent.cs b/osu.Game/Screens/Edit/ClipboardContent.cs new file mode 100644 index 0000000000..b2edbedccc --- /dev/null +++ b/osu.Game/Screens/Edit/ClipboardContent.cs @@ -0,0 +1,27 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using osu.Game.IO.Serialization; +using osu.Game.IO.Serialization.Converters; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Edit +{ + public class ClipboardContent : IJsonSerializable + { + [JsonConverter(typeof(TypedListConverter<HitObject>))] + public IList<HitObject> HitObjects; + + public ClipboardContent() + { + } + + public ClipboardContent(EditorBeatmap editorBeatmap) + { + HitObjects = editorBeatmap.SelectedHitObjects.ToList(); + } + } +} diff --git a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs index cb5078a479..08091fc3f7 100644 --- a/osu.Game/Screens/Edit/Components/BottomBarContainer.cs +++ b/osu.Game/Screens/Edit/Components/BottomBarContainer.cs @@ -18,7 +18,8 @@ namespace osu.Game.Screens.Edit.Components private const float contents_padding = 15; protected readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>(); - protected Track Track => Beatmap.Value.Track; + + protected readonly IBindable<Track> Track = new Bindable<Track>(); private readonly Drawable background; private readonly Container content; @@ -42,9 +43,11 @@ namespace osu.Game.Screens.Edit.Components } [BackgroundDependencyLoader] - private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours) + private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours, EditorClock clock) { Beatmap.BindTo(beatmap); + Track.BindTo(clock.Track); + background.Colour = colours.Gray1; } } diff --git a/osu.Game/Screens/Edit/Components/CircularButton.cs b/osu.Game/Screens/Edit/Components/CircularButton.cs index 931c7d03a0..40b5ac663a 100644 --- a/osu.Game/Screens/Edit/Components/CircularButton.cs +++ b/osu.Game/Screens/Edit/Components/CircularButton.cs @@ -20,6 +20,7 @@ namespace osu.Game.Screens.Edit.Components { base.Update(); Content.CornerRadius = DrawHeight / 2f; + Content.CornerExponent = 2; } } } diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs index 752615245e..c6787a1fb1 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs @@ -3,6 +3,7 @@ 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; @@ -26,7 +27,7 @@ namespace osu.Game.Screens.Edit.Components.Menus MaskingContainer.CornerRadius = 0; ItemsContainer.Padding = new MarginPadding { Left = 100 }; - BackgroundColour = OsuColour.FromHex("111"); + BackgroundColour = Color4Extensions.FromHex("111"); ScreenSelectionTabControl tabControl; AddRangeInternal(new Drawable[] @@ -162,30 +163,27 @@ namespace osu.Game.Screens.Edit.Components.Menus protected override Framework.Graphics.UserInterface.Menu CreateSubMenu() => new SubMenu(); - protected override DrawableMenuItem CreateDrawableMenuItem(MenuItem item) => new DrawableSubMenuItem(item); - - private class DrawableSubMenuItem : DrawableOsuMenuItem + protected override DrawableMenuItem CreateDrawableMenuItem(MenuItem item) { - public DrawableSubMenuItem(MenuItem item) + switch (item) + { + case EditorMenuItemSpacer spacer: + return new DrawableSpacer(spacer); + } + + return base.CreateDrawableMenuItem(item); + } + + private class DrawableSpacer : DrawableOsuMenuItem + { + public DrawableSpacer(MenuItem item) : base(item) { } - protected override bool OnHover(HoverEvent e) - { - if (Item is EditorMenuItemSpacer) - return true; + protected override bool OnHover(HoverEvent e) => true; - return base.OnHover(e); - } - - protected override bool OnClick(ClickEvent e) - { - if (Item is EditorMenuItemSpacer) - return true; - - return base.OnClick(e); - } + protected override bool OnClick(ClickEvent e) => true; } } } diff --git a/osu.Game/Screens/Edit/Components/Menus/ScreenSelectionTabControl.cs b/osu.Game/Screens/Edit/Components/Menus/ScreenSelectionTabControl.cs index 089da4f222..b8bc5cdf36 100644 --- a/osu.Game/Screens/Edit/Components/Menus/ScreenSelectionTabControl.cs +++ b/osu.Game/Screens/Edit/Components/Menus/ScreenSelectionTabControl.cs @@ -32,8 +32,6 @@ namespace osu.Game.Screens.Edit.Components.Menus Height = 1, Colour = Color4.White.Opacity(0.2f), }); - - Current.Value = EditorScreenMode.Compose; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Edit/Components/PlaybackControl.cs b/osu.Game/Screens/Edit/Components/PlaybackControl.cs index 8d4ad0efa9..9739f2876a 100644 --- a/osu.Game/Screens/Edit/Components/PlaybackControl.cs +++ b/osu.Game/Screens/Edit/Components/PlaybackControl.cs @@ -1,16 +1,18 @@ // 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. +using System.Linq; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; -using osu.Framework.Timing; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -22,15 +24,14 @@ namespace osu.Game.Screens.Edit.Components { private IconButton playButton; - private IAdjustableClock adjustableClock; + [Resolved] + private EditorClock editorClock { get; set; } + + private readonly BindableNumber<double> tempo = new BindableDouble(1); [BackgroundDependencyLoader] - private void load(IAdjustableClock adjustableClock) + private void load() { - this.adjustableClock = adjustableClock; - - PlaybackTabControl tabs; - Children = new Drawable[] { playButton = new IconButton @@ -57,11 +58,18 @@ namespace osu.Game.Screens.Edit.Components RelativeSizeAxes = Axes.Both, Height = 0.5f, Padding = new MarginPadding { Left = 45 }, - Child = tabs = new PlaybackTabControl(), + Child = new PlaybackTabControl { Current = tempo }, } }; - tabs.Current.ValueChanged += tempo => Beatmap.Value.Track.Tempo.Value = tempo.NewValue; + Track.BindValueChanged(tr => tr.NewValue?.AddAdjustment(AdjustableProperty.Tempo, tempo), true); + } + + protected override void Dispose(bool isDisposing) + { + Track.Value?.RemoveAdjustment(AdjustableProperty.Tempo, tempo); + + base.Dispose(isDisposing); } protected override bool OnKeyDown(KeyDownEvent e) @@ -78,17 +86,17 @@ namespace osu.Game.Screens.Edit.Components private void togglePause() { - if (adjustableClock.IsRunning) - adjustableClock.Stop(); + if (editorClock.IsRunning) + editorClock.Stop(); else - adjustableClock.Start(); + editorClock.Start(); } protected override void Update() { base.Update(); - playButton.Icon = adjustableClock.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; + playButton.Icon = editorClock.IsRunning ? FontAwesome.Regular.PauseCircle : FontAwesome.Regular.PlayCircle; } private class PlaybackTabControl : OsuTabControl<double> @@ -105,6 +113,8 @@ namespace osu.Game.Screens.Edit.Components TabContainer.Spacing = Vector2.Zero; tempo_values.ForEach(AddItem); + + Current.Value = tempo_values.Last(); } public class PlaybackTabItem : TabItem<double> diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs index 70c0cf623e..0cf7b83f3b 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs @@ -5,11 +5,9 @@ using System; 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.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -30,30 +28,17 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons private Color4 selectedBackgroundColour; private Color4 selectedBubbleColour; - private readonly Drawable bubble; + private Drawable icon; private readonly RadioButton button; public DrawableRadioButton(RadioButton button) { this.button = button; - Text = button.Text; - Action = button.Action; + Text = button.Item.ToString(); + Action = button.Select; RelativeSizeAxes = Axes.X; - - bubble = new CircularContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Scale = new Vector2(0.5f), - X = 10, - Masking = true, - Blending = BlendingMode.Additive, - Child = new Box { RelativeSizeAxes = Axes.Both } - }; } [BackgroundDependencyLoader] @@ -74,7 +59,14 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons Colour = Color4.Black.Opacity(0.5f) }; - Add(bubble); + Add(icon = (button.CreateIcon?.Invoke() ?? new Circle()).With(b => + { + b.Blending = BlendingParameters.Additive; + b.Anchor = Anchor.CentreLeft; + b.Origin = Anchor.CentreLeft; + b.Size = new Vector2(20); + b.X = 10; + })); } protected override void LoadComplete() @@ -97,20 +89,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons return; BackgroundColour = button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour; - bubble.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; - } - - protected override bool OnClick(ClickEvent e) - { - if (button.Selected.Value) - return true; - - if (!Enabled.Value) - return true; - - button.Selected.Value = true; - - return base.OnClick(e); + icon.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; } protected override SpriteText CreateText() => new OsuSpriteText diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs index 3692c0437b..a7b0fb05e3 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Bindables; +using osu.Framework.Graphics; namespace osu.Game.Screens.Edit.Components.RadioButtons { @@ -15,33 +16,43 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons public readonly BindableBool Selected; /// <summary> - /// The text that should be displayed in this button. + /// The item related to this button. /// </summary> - public string Text; + public object Item; /// <summary> - /// The <see cref="Action"/> that should be invoked when this button is selected. + /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. /// </summary> - public Action Action; + public readonly Func<Drawable> CreateIcon; - public RadioButton(string text, Action action) + private readonly Action action; + + public RadioButton(object item, Action action, Func<Drawable> createIcon = null) { - Text = text; - Action = action; + Item = item; + CreateIcon = createIcon; + this.action = action; Selected = new BindableBool(); } - public RadioButton(string text) - : this(text, null) + public RadioButton(string item) + : this(item, null) { - Text = text; - Action = null; + Item = item; + action = null; } /// <summary> /// Selects this <see cref="RadioButton"/>. /// </summary> - public void Select() => Selected.Value = true; + public void Select() + { + if (!Selected.Value) + { + Selected.Value = true; + action?.Invoke(); + } + } /// <summary> /// Deselects this <see cref="RadioButton"/>. diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs new file mode 100644 index 0000000000..c72fff5c91 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -0,0 +1,112 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Components.TernaryButtons +{ + internal class DrawableTernaryButton : TriangleButton + { + private Color4 defaultBackgroundColour; + private Color4 defaultBubbleColour; + private Color4 selectedBackgroundColour; + private Color4 selectedBubbleColour; + + private Drawable icon; + + public readonly TernaryButton Button; + + public DrawableTernaryButton(TernaryButton button) + { + Button = button; + + Text = button.Description; + + RelativeSizeAxes = Axes.X; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + defaultBackgroundColour = colours.Gray3; + defaultBubbleColour = defaultBackgroundColour.Darken(0.5f); + selectedBackgroundColour = colours.BlueDark; + selectedBubbleColour = selectedBackgroundColour.Lighten(0.5f); + + Triangles.Alpha = 0; + + Content.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 2, + Offset = new Vector2(0, 1), + Colour = Color4.Black.Opacity(0.5f) + }; + + Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => + { + b.Blending = BlendingParameters.Additive; + b.Anchor = Anchor.CentreLeft; + b.Origin = Anchor.CentreLeft; + b.Size = new Vector2(20); + b.X = 10; + })); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Button.Bindable.BindValueChanged(selected => updateSelectionState(), true); + + Action = onAction; + } + + private void onAction() + { + Button.Toggle(); + } + + private void updateSelectionState() + { + if (!IsLoaded) + return; + + switch (Button.Bindable.Value) + { + case TernaryState.Indeterminate: + icon.Colour = selectedBubbleColour.Darken(0.5f); + BackgroundColour = selectedBackgroundColour.Darken(0.5f); + break; + + case TernaryState.False: + icon.Colour = defaultBubbleColour; + BackgroundColour = defaultBackgroundColour; + break; + + case TernaryState.True: + icon.Colour = selectedBubbleColour; + BackgroundColour = selectedBackgroundColour; + break; + } + } + + protected override SpriteText CreateText() => new OsuSpriteText + { + Depth = -1, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + X = 40f + }; + } +} diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs new file mode 100644 index 0000000000..7f64695bde --- /dev/null +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/TernaryButton.cs @@ -0,0 +1,44 @@ +// 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. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Edit.Components.TernaryButtons +{ + public class TernaryButton + { + public readonly Bindable<TernaryState> Bindable; + + public readonly string Description; + + /// <summary> + /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. + /// </summary> + public readonly Func<Drawable> CreateIcon; + + public TernaryButton(Bindable<TernaryState> bindable, string description, Func<Drawable> createIcon = null) + { + Bindable = bindable; + Description = description; + CreateIcon = createIcon; + } + + public void Toggle() + { + switch (Bindable.Value) + { + case TernaryState.False: + case TernaryState.Indeterminate: + Bindable.Value = TernaryState.True; + break; + + case TernaryState.True: + Bindable.Value = TernaryState.False; + break; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs index 0391074b11..0a8c339559 100644 --- a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs +++ b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs @@ -3,9 +3,8 @@ using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; -using System; using osu.Framework.Allocation; -using osu.Framework.Timing; +using osu.Game.Extensions; using osu.Game.Graphics; namespace osu.Game.Screens.Edit.Components @@ -14,7 +13,8 @@ namespace osu.Game.Screens.Edit.Components { private readonly OsuSpriteText trackTimer; - private IAdjustableClock adjustableClock; + [Resolved] + private EditorClock editorClock { get; set; } public TimeInfoContainer() { @@ -22,25 +22,20 @@ namespace osu.Game.Screens.Edit.Components { trackTimer = new OsuSpriteText { - Origin = Anchor.BottomLeft, - RelativePositionAxes = Axes.Y, - Font = OsuFont.GetFont(size: 22, fixedWidth: true), - Y = 0.5f, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + // intentionally fudged centre to avoid movement of the number portion when + // going negative. + X = -35, + Font = OsuFont.GetFont(size: 25, fixedWidth: true), } }; } - [BackgroundDependencyLoader] - private void load(IAdjustableClock adjustableClock) - { - this.adjustableClock = adjustableClock; - } - protected override void Update() { base.Update(); - - trackTimer.Text = TimeSpan.FromMilliseconds(adjustableClock.CurrentTime).ToString(@"mm\:ss\:fff"); + trackTimer.Text = editorClock.CurrentTime.ToEditorFormattedString(); } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs index 102955657e..e76ab71e54 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs @@ -1,70 +1,51 @@ // 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. +using System.Collections.Specialized; using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Bindables; using osu.Game.Beatmaps; 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 { /// <summary> /// The part of the timeline that displays the control points. /// </summary> - public class ControlPointPart : TimelinePart + public class ControlPointPart : TimelinePart<GroupVisualisation> { + private readonly IBindableList<ControlPointGroup> controlPointGroups = new BindableList<ControlPointGroup>(); + protected override void LoadBeatmap(WorkingBeatmap beatmap) { base.LoadBeatmap(beatmap); - ControlPointInfo cpi = beatmap.Beatmap.ControlPointInfo; - - cpi.TimingPoints.ForEach(addTimingPoint); - - // Consider all non-timing points as the same type - cpi.SamplePoints.Select(c => (ControlPoint)c) - .Concat(cpi.EffectPoints) - .Concat(cpi.DifficultyPoints) - .Distinct() - // Non-timing points should not be added where there are timing points - .Where(c => cpi.TimingPointAt(c.Time).Time != c.Time) - .ForEach(addNonTimingPoint); - } - - private void addTimingPoint(ControlPoint controlPoint) => Add(new TimingPointVisualisation(controlPoint)); - private void addNonTimingPoint(ControlPoint controlPoint) => Add(new NonTimingPointVisualisation(controlPoint)); - - private class TimingPointVisualisation : ControlPointVisualisation - { - public TimingPointVisualisation(ControlPoint controlPoint) - : base(controlPoint) + controlPointGroups.UnbindAll(); + controlPointGroups.BindTo(beatmap.Beatmap.ControlPointInfo.Groups); + controlPointGroups.BindCollectionChanged((sender, args) => { - } + switch (args.Action) + { + case NotifyCollectionChangedAction.Reset: + Clear(); + break; - [BackgroundDependencyLoader] - private void load(OsuColour colours) => Colour = colours.YellowDark; - } + case NotifyCollectionChangedAction.Add: + foreach (var group in args.NewItems.OfType<ControlPointGroup>()) + Add(new GroupVisualisation(group)); + break; - private class NonTimingPointVisualisation : ControlPointVisualisation - { - public NonTimingPointVisualisation(ControlPoint controlPoint) - : base(controlPoint) - { - } + case NotifyCollectionChangedAction.Remove: + foreach (var group in args.OldItems.OfType<ControlPointGroup>()) + { + var matching = Children.SingleOrDefault(gv => gv.Group == group); - [BackgroundDependencyLoader] - private void load(OsuColour colours) => Colour = colours.Green; - } + matching?.Expire(); + } - private abstract class ControlPointVisualisation : PointVisualisation - { - protected ControlPointVisualisation(ControlPoint controlPoint) - : base(controlPoint.Time) - { - } + break; + } + }, 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 new file mode 100644 index 0000000000..93fe6f9989 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs @@ -0,0 +1,46 @@ +// 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. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +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 readonly ControlPointGroup Group; + + private readonly IBindableList<ControlPoint> controlPoints = new BindableList<ControlPoint>(); + + [Resolved] + private OsuColour colours { get; set; } + + public GroupVisualisation(ControlPointGroup group) + : base(group.Time) + { + Group = group; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + controlPoints.BindTo(Group.ControlPoints); + controlPoints.BindCollectionChanged((_, __) => + { + if (controlPoints.Count == 0) + { + Colour = Color4.Transparent; + return; + } + + Colour = controlPoints.Any(c => c is TimingControlPoint) ? colours.YellowDark : colours.Green; + }, true); + } + } +} diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs index 07d307f293..9e9ac93d23 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs @@ -1,6 +1,7 @@ // 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. +using System; using osuTK; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -8,7 +9,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Threading; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -19,24 +19,22 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// </summary> public class MarkerPart : TimelinePart { - private readonly Drawable marker; + private Drawable marker; - private readonly IAdjustableClock adjustableClock; + [Resolved] + private EditorClock editorClock { get; set; } - public MarkerPart(IAdjustableClock adjustableClock) + [BackgroundDependencyLoader] + private void load() { - this.adjustableClock = adjustableClock; - Add(marker = new MarkerVisualisation()); } protected override bool OnDragStart(DragStartEvent e) => true; - protected override bool OnDragEnd(DragEndEvent e) => true; - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) { seekToPosition(e.ScreenSpaceMousePosition); - return true; } protected override bool OnMouseDown(MouseDownEvent e) @@ -59,15 +57,15 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts if (Beatmap.Value == null) return; - float markerPos = MathHelper.Clamp(ToLocalSpace(screenPosition).X, 0, DrawWidth); - adjustableClock.Seek(markerPos / DrawWidth * Beatmap.Value.Track.Length); + float markerPos = Math.Clamp(ToLocalSpace(screenPosition).X, 0, DrawWidth); + editorClock.SeekTo(markerPos / DrawWidth * editorClock.TrackLength); }); } protected override void Update() { base.Update(); - marker.X = (float)adjustableClock.CurrentTime; + marker.X = (float)editorClock.CurrentTime; } protected override void LoadBeatmap(WorkingBeatmap beatmap) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs index 26d9614631..5b8f7c747b 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osuTK; using osu.Framework.Graphics; @@ -11,30 +12,41 @@ using osu.Game.Beatmaps; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { + public class TimelinePart : TimelinePart<Drawable> + { + } + /// <summary> /// Represents a part of the summary timeline.. /// </summary> - public abstract class TimelinePart : CompositeDrawable + public class TimelinePart<T> : Container<T> where T : Drawable { protected readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>(); - private readonly Container timeline; + protected readonly IBindable<Track> Track = new Bindable<Track>(); - protected TimelinePart() + private readonly Container<T> content; + + protected override Container<T> Content => content; + + public TimelinePart(Container<T> content = null) { - AddInternal(timeline = new Container { RelativeSizeAxes = Axes.Both }); + AddInternal(this.content = content ?? new Container<T> { RelativeSizeAxes = Axes.Both }); Beatmap.ValueChanged += b => { updateRelativeChildSize(); LoadBeatmap(b.NewValue); }; + + Track.ValueChanged += _ => updateRelativeChildSize(); } [BackgroundDependencyLoader] - private void load(IBindable<WorkingBeatmap> beatmap) + private void load(IBindable<WorkingBeatmap> beatmap, EditorClock clock) { Beatmap.BindTo(beatmap); + Track.BindTo(clock.Track); } private void updateRelativeChildSize() @@ -42,19 +54,17 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts // the track may not be loaded completely (only has a length once it is). if (!Beatmap.Value.Track.IsLoaded) { - timeline.RelativeChildSize = Vector2.One; + content.RelativeChildSize = Vector2.One; Schedule(updateRelativeChildSize); return; } - timeline.RelativeChildSize = new Vector2((float)Math.Max(1, Beatmap.Value.Track.Length), 1); + content.RelativeChildSize = new Vector2((float)Math.Max(1, Beatmap.Value.Track.Length), 1); } - protected void Add(Drawable visualisation) => timeline.Add(visualisation); - protected virtual void LoadBeatmap(WorkingBeatmap beatmap) { - timeline.Clear(); + content.Clear(); } } } diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs index 20db2cac21..02cd4bccb4 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/SummaryTimeline.cs @@ -6,7 +6,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Timing; using osu.Game.Graphics; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; @@ -18,11 +17,11 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary public class SummaryTimeline : BottomBarContainer { [BackgroundDependencyLoader] - private void load(OsuColour colours, IAdjustableClock adjustableClock) + private void load(OsuColour colours) { Children = new Drawable[] { - new MarkerPart(adjustableClock) { RelativeSizeAxes = Axes.Both }, + new MarkerPart { RelativeSizeAxes = Axes.Both }, new ControlPointPart { Anchor = Anchor.Centre, 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 9c00cce57a..b0ecffdd24 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs @@ -1,9 +1,9 @@ // 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. -using osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; +using osuTK; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations { @@ -12,16 +12,23 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations /// </summary> public class PointVisualisation : Box { - protected PointVisualisation(double startTime) + public const float WIDTH = 1; + + public PointVisualisation(double startTime) + : this() + { + X = (float)startTime; + } + + public PointVisualisation() { Origin = Anchor.TopCentre; - RelativeSizeAxes = Axes.Y; - Width = 1; - EdgeSmoothness = new Vector2(1, 0); - RelativePositionAxes = Axes.X; - X = (float)startTime; + RelativeSizeAxes = Axes.Y; + + Width = WIDTH; + EdgeSmoothness = new Vector2(WIDTH, 0); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index c615656d60..2dec3fd22e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -188,6 +188,9 @@ namespace osu.Game.Screens.Edit.Compose.Components { private Marker marker; + [Resolved] + private OsuColour colours { get; set; } + private readonly BindableBeatDivisor beatDivisor; private readonly int[] availableDivisors; @@ -204,22 +207,28 @@ namespace osu.Game.Screens.Edit.Compose.Components { foreach (var t in availableDivisors) { - AddInternal(new Tick(t) + AddInternal(new Tick { Anchor = Anchor.TopLeft, Origin = Anchor.TopCentre, RelativePositionAxes = Axes.X, + Colour = BindableBeatDivisor.GetColourFor(t, colours), X = getMappedPosition(t) }); } AddInternal(marker = new Marker()); + } - CurrentNumber.ValueChanged += div => + protected override void LoadComplete() + { + base.LoadComplete(); + + CurrentNumber.BindValueChanged(div => { marker.MoveToX(getMappedPosition(div.NewValue), 100, Easing.OutQuint); marker.Flash(); - }; + }, true); } protected override void UpdateValue(float value) @@ -253,10 +262,10 @@ namespace osu.Game.Screens.Edit.Compose.Components return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { marker.Active = false; - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) @@ -265,10 +274,14 @@ namespace osu.Game.Screens.Edit.Compose.Components return true; } - protected override bool OnDrag(DragEvent e) + protected override void OnDrag(DragEvent e) + { + handleMouseInput(e.ScreenSpaceMousePosition); + } + + protected override void OnDragEnd(DragEndEvent e) { handleMouseInput(e.ScreenSpaceMousePosition); - return true; } private void handleMouseInput(Vector2 screenSpaceMousePosition) @@ -280,15 +293,12 @@ namespace osu.Game.Screens.Edit.Compose.Components OnUserChange(Current.Value); } - private float getMappedPosition(float divisor) => (float)Math.Pow((divisor - 1) / (availableDivisors.Last() - 1), 0.90f); + private float getMappedPosition(float divisor) => MathF.Pow((divisor - 1) / (availableDivisors.Last() - 1), 0.90f); private class Tick : CompositeDrawable { - private readonly int divisor; - - public Tick(int divisor) + public Tick() { - this.divisor = divisor; Size = new Vector2(2.5f, 10); InternalChild = new Box { RelativeSizeAxes = Axes.Both }; @@ -296,42 +306,6 @@ namespace osu.Game.Screens.Edit.Compose.Components CornerRadius = 0.5f; Masking = true; } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Colour = getColourForDivisor(divisor, colours); - } - - private ColourInfo getColourForDivisor(int divisor, OsuColour colours) - { - switch (divisor) - { - case 2: - return colours.BlueLight; - - case 4: - return colours.Blue; - - case 8: - return colours.BlueDarker; - - case 16: - return colours.PurpleDark; - - case 3: - return colours.YellowLight; - - case 6: - return colours.Yellow; - - case 12: - return colours.YellowDarker; - - default: - return Color4.White; - } - } } private class Marker : CompositeDrawable @@ -360,7 +334,7 @@ namespace osu.Game.Screens.Edit.Compose.Components Origin = Anchor.BottomCentre, Anchor = Anchor.BottomCentre, Colour = ColourInfo.GradientVertical(Color4.White.Opacity(0.2f), Color4.White), - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, }, new EquilateralTriangle { diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index a1e62cd38b..b67f6a6ba6 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -1,35 +1,54 @@ // 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. -using System.Collections.Generic; +using System; +using System.Collections.Specialized; +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.Graphics.Primitives; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.Input.States; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osuTK; +using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { - public class BlueprintContainer : CompositeDrawable + /// <summary> + /// A container which provides a "blueprint" display of hitobjects. + /// Includes selection and manipulation support via a <see cref="Components.SelectionHandler"/>. + /// </summary> + public abstract class BlueprintContainer : CompositeDrawable, IKeyBindingHandler<PlatformAction> { - private SelectionBlueprintContainer selectionBlueprints; + protected DragBox DragBox { get; private set; } - private Container<PlacementBlueprint> placementBlueprintContainer; - private PlacementBlueprint currentPlacement; + public Container<SelectionBlueprint> SelectionBlueprints { get; private set; } - private SelectionHandler selectionHandler; + protected SelectionHandler SelectionHandler { get; private set; } - private IEnumerable<SelectionBlueprint> selections => selectionBlueprints.Children.Where(c => c.IsAlive); + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } [Resolved] - private HitObjectComposer composer { get; set; } + private EditorClock editorClock { get; set; } - public BlueprintContainer() + [Resolved] + protected EditorBeatmap Beatmap { get; private set; } + + private readonly BindableList<HitObject> selectedHitObjects = new BindableList<HitObject>(); + + [Resolved(canBeNull: true)] + private IPositionSnapProvider snapProvider { get; set; } + + protected BlueprintContainer() { RelativeSizeAxes = Axes.Both; } @@ -37,71 +56,200 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load() { - selectionHandler = composer.CreateSelectionHandler(); - selectionHandler.DeselectAll = deselectAll; + SelectionHandler = CreateSelectionHandler(); + SelectionHandler.DeselectAll = deselectAll; - var dragBox = new DragBox(select); - dragBox.DragEnd += () => selectionHandler.UpdateVisibility(); - - InternalChildren = new[] + AddRangeInternal(new[] { - dragBox, - selectionHandler, - selectionBlueprints = new SelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }, - placementBlueprintContainer = new Container<PlacementBlueprint> { RelativeSizeAxes = Axes.Both }, - dragBox.CreateProxy() - }; + DragBox = CreateDragBox(selectBlueprintsFromDragRectangle), + SelectionHandler, + SelectionBlueprints = CreateSelectionBlueprintContainer(), + SelectionHandler.CreateProxy(), + DragBox.CreateProxy().With(p => p.Depth = float.MinValue) + }); - foreach (var obj in composer.HitObjects) + foreach (var obj in Beatmap.HitObjects) AddBlueprintFor(obj); - } - private HitObjectCompositionTool currentTool; - - /// <summary> - /// The current placement tool. - /// </summary> - public HitObjectCompositionTool CurrentTool - { - get => currentTool; - set + selectedHitObjects.BindTo(Beatmap.SelectedHitObjects); + selectedHitObjects.CollectionChanged += (selectedObjects, args) => { - if (currentTool == value) - return; + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var o in args.NewItems) + SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Select(); + break; - currentTool = value; + case NotifyCollectionChangedAction.Remove: + foreach (var o in args.OldItems) + SelectionBlueprints.FirstOrDefault(b => b.HitObject == o)?.Deselect(); - refreshTool(); - } + break; + } + }; } - /// <summary> - /// Adds a blueprint for a <see cref="DrawableHitObject"/> which adds movement support. - /// </summary> - /// <param name="hitObject">The <see cref="DrawableHitObject"/> to create a blueprint for.</param> - public void AddBlueprintFor(DrawableHitObject hitObject) + protected override void LoadComplete() { - refreshTool(); + base.LoadComplete(); - var blueprint = composer.CreateBlueprintFor(hitObject); - if (blueprint == null) + Beatmap.HitObjectAdded += AddBlueprintFor; + Beatmap.HitObjectRemoved += removeBlueprintFor; + } + + protected virtual Container<SelectionBlueprint> CreateSelectionBlueprintContainer() => + new Container<SelectionBlueprint> { RelativeSizeAxes = Axes.Both }; + + /// <summary> + /// Creates a <see cref="Components.SelectionHandler"/> which outlines <see cref="DrawableHitObject"/>s and handles movement of selections. + /// </summary> + protected virtual SelectionHandler CreateSelectionHandler() => new SelectionHandler(); + + /// <summary> + /// Creates a <see cref="SelectionBlueprint"/> for a specific <see cref="DrawableHitObject"/>. + /// </summary> + /// <param name="hitObject">The <see cref="DrawableHitObject"/> to create the overlay for.</param> + protected virtual SelectionBlueprint CreateBlueprintFor(HitObject hitObject) => null; + + protected virtual DragBox CreateDragBox(Action<RectangleF> performSelect) => new DragBox(performSelect); + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (!beginClickSelection(e)) return true; + + prepareSelectionMovement(); + + return e.Button == MouseButton.Left; + } + + private SelectionBlueprint clickedBlueprint; + + protected override bool OnClick(ClickEvent e) + { + if (e.Button == MouseButton.Right) + return false; + + // store for double-click handling + 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) + return true; + + deselectAll(); + return true; + } + + protected override bool OnDoubleClick(DoubleClickEvent e) + { + if (e.Button == MouseButton.Right) + 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) + return false; + + editorClock?.SeekTo(clickedBlueprint.HitObject.StartTime); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + // Special case for when a drag happened instead of a click + Schedule(() => endClickSelection()); + + finishSelectionMovement(); + } + + protected override bool OnDragStart(DragStartEvent e) + { + if (e.Button == MouseButton.Right) + return false; + + if (movementBlueprint != null) + { + isDraggingBlueprint = true; + changeHandler?.BeginChange(); + return true; + } + + if (DragBox.HandleDrag(e)) + { + DragBox.Show(); + return true; + } + + return false; + } + + protected override void OnDrag(DragEvent e) + { + if (e.Button == MouseButton.Right) return; - blueprint.Selected += onBlueprintSelected; - blueprint.Deselected += onBlueprintDeselected; - blueprint.SelectionRequested += onSelectionRequested; - blueprint.DragRequested += onDragRequested; + if (DragBox.State == Visibility.Visible) + DragBox.HandleDrag(e); - selectionBlueprints.Add(blueprint); + moveCurrentSelection(e); } - /// <summary> - /// Removes a blueprint for a <see cref="DrawableHitObject"/>. - /// </summary> - /// <param name="hitObject">The <see cref="DrawableHitObject"/> for which to remove the blueprint.</param> - public void RemoveBlueprintFor(DrawableHitObject hitObject) + protected override void OnDragEnd(DragEndEvent e) { - var blueprint = selectionBlueprints.Single(m => m.HitObject == hitObject); + if (e.Button == MouseButton.Right) + return; + + if (isDraggingBlueprint) + { + // handle positional change etc. + foreach (var obj in selectedHitObjects) + Beatmap.Update(obj); + + changeHandler?.EndChange(); + isDraggingBlueprint = false; + } + + if (DragBox.State == Visibility.Visible) + DragBox.Hide(); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Escape: + if (!SelectionHandler.SelectedBlueprints.Any()) + return false; + + deselectAll(); + return true; + } + + return false; + } + + public bool OnPressed(PlatformAction action) + { + switch (action.ActionType) + { + case PlatformActionType.SelectAll: + selectAll(); + return true; + } + + return false; + } + + public void OnReleased(PlatformAction action) + { + } + + #region Blueprint Addition/Removal + + private void removeBlueprintFor(HitObject hitObject) + { + var blueprint = SelectionBlueprints.SingleOrDefault(m => m.HitObject == hitObject); if (blueprint == null) return; @@ -109,100 +257,207 @@ namespace osu.Game.Screens.Edit.Compose.Components blueprint.Selected -= onBlueprintSelected; blueprint.Deselected -= onBlueprintDeselected; - blueprint.SelectionRequested -= onSelectionRequested; - blueprint.DragRequested -= onDragRequested; - selectionBlueprints.Remove(blueprint); + SelectionBlueprints.Remove(blueprint); + + if (movementBlueprint == blueprint) + finishSelectionMovement(); } - protected override bool OnClick(ClickEvent e) + protected virtual void AddBlueprintFor(HitObject hitObject) { - deselectAll(); - return true; + var blueprint = CreateBlueprintFor(hitObject); + if (blueprint == null) + return; + + blueprint.Selected += onBlueprintSelected; + blueprint.Deselected += onBlueprintDeselected; + + if (Beatmap.SelectedHitObjects.Contains(hitObject)) + blueprint.Select(); + + SelectionBlueprints.Add(blueprint); } - protected override void Update() - { - base.Update(); + #endregion - if (currentPlacement != null) + #region Selection + + /// <summary> + /// Whether a blueprint was selected by a previous click event. + /// </summary> + private bool clickSelectionBegan; + + /// <summary> + /// Attempts to select any hovered blueprints. + /// </summary> + /// <param name="e">The input event that triggered this selection.</param> + /// <returns>Whether a selection was performed.</returns> + private bool beginClickSelection(MouseButtonEvent e) + { + foreach (SelectionBlueprint blueprint in SelectionBlueprints.AliveChildren) { - if (composer.CursorInPlacementArea) - currentPlacement.State = PlacementState.Shown; - else if (currentPlacement?.PlacementBegun == false) - currentPlacement.State = PlacementState.Hidden; + if (!blueprint.IsHovered) continue; + + if (SelectionHandler.HandleSelectionRequested(blueprint, e)) + return clickSelectionBegan = true; } + + return false; } /// <summary> - /// Refreshes the current placement tool. + /// Finishes the current blueprint selection. /// </summary> - private void refreshTool() + /// <returns>Whether a click selection was active.</returns> + private bool endClickSelection() { - placementBlueprintContainer.Clear(); - currentPlacement = null; + if (!clickSelectionBegan) + return false; - var blueprint = CurrentTool?.CreatePlacementBlueprint(); - if (blueprint != null) - placementBlueprintContainer.Child = currentPlacement = blueprint; + clickSelectionBegan = false; + return true; } /// <summary> /// Select all masks in a given rectangle selection area. /// </summary> /// <param name="rect">The rectangle to perform a selection on in screen-space coordinates.</param> - private void select(RectangleF rect) + private void selectBlueprintsFromDragRectangle(RectangleF rect) { - foreach (var blueprint in selections.ToList()) + foreach (var blueprint in SelectionBlueprints) { - if (blueprint.IsPresent && rect.Contains(blueprint.SelectionPoint)) - blueprint.Select(); - else - blueprint.Deselect(); + // only run when utmost necessary to avoid unnecessary rect computations. + bool isValidForSelection() => blueprint.IsAlive && blueprint.IsPresent && rect.Contains(blueprint.ScreenSpaceSelectionPoint); + + switch (blueprint.State) + { + case SelectionState.NotSelected: + if (isValidForSelection()) + blueprint.Select(); + 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()) + blueprint.Deselect(); + break; + } } } + /// <summary> + /// Selects all <see cref="SelectionBlueprint"/>s. + /// </summary> + private void selectAll() => SelectionBlueprints.ToList().ForEach(m => m.Select()); + /// <summary> /// Deselects all selected <see cref="SelectionBlueprint"/>s. /// </summary> - private void deselectAll() => selections.ToList().ForEach(m => m.Deselect()); + private void deselectAll() => SelectionHandler.SelectedBlueprints.ToList().ForEach(m => m.Deselect()); private void onBlueprintSelected(SelectionBlueprint blueprint) { - selectionHandler.HandleSelected(blueprint); - selectionBlueprints.ChangeChildDepth(blueprint, 1); + SelectionHandler.HandleSelected(blueprint); + SelectionBlueprints.ChangeChildDepth(blueprint, 1); } private void onBlueprintDeselected(SelectionBlueprint blueprint) { - selectionHandler.HandleDeselected(blueprint); - selectionBlueprints.ChangeChildDepth(blueprint, 0); + SelectionHandler.HandleDeselected(blueprint); + SelectionBlueprints.ChangeChildDepth(blueprint, 0); } - private void onSelectionRequested(SelectionBlueprint blueprint, InputState state) => selectionHandler.HandleSelectionRequested(blueprint, state); + #endregion - private void onDragRequested(SelectionBlueprint blueprint, DragEvent dragEvent) => selectionHandler.HandleDrag(blueprint, dragEvent); + #region Selection Movement - private class SelectionBlueprintContainer : Container<SelectionBlueprint> + private Vector2? movementBlueprintOriginalPosition; + private SelectionBlueprint movementBlueprint; + private bool isDraggingBlueprint; + + /// <summary> + /// Attempts to begin the movement of any selected blueprints. + /// </summary> + private void prepareSelectionMovement() { - protected override int Compare(Drawable x, Drawable y) - { - if (!(x is SelectionBlueprint xBlueprint) || !(y is SelectionBlueprint yBlueprint)) - return base.Compare(x, y); + if (!SelectionHandler.SelectedBlueprints.Any()) + return; - return Compare(xBlueprint, yBlueprint); + // Any selected blueprint that is hovered can begin the movement of the group, however only the earliest hitobject 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 + movementBlueprint = SelectionHandler.SelectedBlueprints.OrderBy(b => b.HitObject.StartTime).First(); + movementBlueprintOriginalPosition = movementBlueprint.ScreenSpaceSelectionPoint; // todo: unsure if correct + } + + /// <summary> + /// Moves the current selected blueprints. + /// </summary> + /// <param name="e">The <see cref="DragEvent"/> defining the movement event.</param> + /// <returns>Whether a movement was active.</returns> + private bool moveCurrentSelection(DragEvent e) + { + if (movementBlueprint == null) + return false; + + Debug.Assert(movementBlueprintOriginalPosition != null); + + HitObject draggedObject = movementBlueprint.HitObject; + + // The final movement position, relative to movementBlueprintOriginalPosition. + Vector2 movePosition = movementBlueprintOriginalPosition.Value + e.ScreenSpaceMousePosition - e.ScreenSpaceMouseDownPosition; + + // Retrieve a snapped position. + var result = snapProvider.SnapScreenSpacePositionToValidTime(movePosition); + + // Move the hitobjects. + if (!SelectionHandler.HandleMovement(new MoveSelectionEvent(movementBlueprint, result.ScreenSpacePosition))) + return true; + + if (result.Time.HasValue) + { + // Apply the start time at the newly snapped-to position + double offset = result.Time.Value - draggedObject.StartTime; + + foreach (HitObject obj in Beatmap.SelectedHitObjects) + { + obj.StartTime += offset; + Beatmap.Update(obj); + } } - public int Compare(SelectionBlueprint x, SelectionBlueprint y) - { - // dpeth is used to denote selected status (we always want selected blueprints to handle input first). - int d = x.Depth.CompareTo(y.Depth); - if (d != 0) - return d; + return true; + } - // Put earlier hitobjects towards the end of the list, so they handle input first - int i = y.HitObject.HitObject.StartTime.CompareTo(x.HitObject.HitObject.StartTime); - return i == 0 ? CompareReverseChildID(x, y) : i; + /// <summary> + /// Finishes the current movement of selected blueprints. + /// </summary> + /// <returns>Whether a movement was active.</returns> + private bool finishSelectionMovement() + { + if (movementBlueprint == null) + return false; + + movementBlueprintOriginalPosition = null; + movementBlueprint = null; + + return true; + } + + #endregion + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (Beatmap != null) + { + Beatmap.HitObjectAdded -= AddBlueprintFor; + Beatmap.HitObjectRemoved -= removeBlueprintFor; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs new file mode 100644 index 0000000000..730f482f83 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -0,0 +1,85 @@ +// 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. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public abstract class CircularDistanceSnapGrid : DistanceSnapGrid + { + protected CircularDistanceSnapGrid(Vector2 startPosition, double startTime, double? endTime = null) + : base(startPosition, startTime, endTime) + { + } + + protected override void CreateContent() + { + const float crosshair_thickness = 1; + const float crosshair_max_size = 10; + + AddRangeInternal(new[] + { + new Box + { + Origin = Anchor.Centre, + Position = StartPosition, + Width = crosshair_thickness, + EdgeSmoothness = new Vector2(1), + Height = Math.Min(crosshair_max_size, DistanceSpacing * 2), + }, + new Box + { + Origin = Anchor.Centre, + Position = StartPosition, + EdgeSmoothness = new Vector2(1), + Width = Math.Min(crosshair_max_size, DistanceSpacing * 2), + Height = crosshair_thickness, + } + }); + + float dx = Math.Max(StartPosition.X, DrawWidth - StartPosition.X); + float dy = Math.Max(StartPosition.Y, DrawHeight - StartPosition.Y); + float maxDistance = new Vector2(dx, dy).Length; + int requiredCircles = Math.Min(MaxIntervals, (int)(maxDistance / DistanceSpacing)); + + for (int i = 0; i < requiredCircles; i++) + { + float radius = (i + 1) * DistanceSpacing * 2; + + AddInternal(new CircularProgress + { + Origin = Anchor.Centre, + Position = StartPosition, + Current = { Value = 1 }, + Size = new Vector2(radius), + InnerRadius = 4 * 1f / radius, + Colour = GetColourForIndexFromPlacement(i) + }); + } + } + + public override (Vector2 position, double time) GetSnappedPosition(Vector2 position) + { + if (MaxIntervals == 0) + return (StartPosition, StartTime); + + Vector2 direction = position - StartPosition; + if (direction == Vector2.Zero) + direction = new Vector2(0.001f, 0.001f); + + float distance = direction.Length; + + float radius = DistanceSpacing; + int radialCount = Math.Clamp((int)MathF.Round(distance / radius), 1, MaxIntervals); + + Vector2 normalisedDirection = direction * new Vector2(1f / distance); + Vector2 snappedPosition = StartPosition + normalisedDirection * radialCount * radius; + + return (snappedPosition, StartTime + SnapProvider.GetSnappedDurationFromDistance(StartTime, (snappedPosition - StartPosition).Length)); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs new file mode 100644 index 0000000000..1527d20f54 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -0,0 +1,261 @@ +// 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. + +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.Sprites; +using osu.Framework.Input; +using osu.Game.Audio; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Screens.Edit.Components.TernaryButtons; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// <summary> + /// A blueprint container generally displayed as an overlay to a ruleset's playfield. + /// </summary> + public class ComposeBlueprintContainer : BlueprintContainer + { + [Resolved] + private HitObjectComposer composer { get; set; } + + private PlacementBlueprint currentPlacement; + + private readonly Container<PlacementBlueprint> placementBlueprintContainer; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + private InputManager inputManager; + + private readonly IEnumerable<DrawableHitObject> drawableHitObjects; + + public ComposeBlueprintContainer(IEnumerable<DrawableHitObject> drawableHitObjects) + { + this.drawableHitObjects = drawableHitObjects; + + placementBlueprintContainer = new Container<PlacementBlueprint> + { + RelativeSizeAxes = Axes.Both + }; + } + + [BackgroundDependencyLoader] + private void load() + { + TernaryStates = CreateTernaryButtons().ToArray(); + + AddInternal(placementBlueprintContainer); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + + // updates to selected are handled for us by SelectionHandler. + NewCombo.BindTo(SelectionHandler.SelectionNewComboState); + + // we are responsible for current placement blueprint updated based on state changes. + NewCombo.ValueChanged += _ => updatePlacementNewCombo(); + + // we own SelectionHandler so don't need to worry about making bindable copies (for simplicity) + foreach (var kvp in SelectionHandler.SelectionSampleStates) + { + kvp.Value.BindValueChanged(_ => updatePlacementSamples()); + } + } + + private void updatePlacementNewCombo() + { + if (currentPlacement?.HitObject is IHasComboInformation c) + c.NewCombo = NewCombo.Value == TernaryState.True; + } + + private void updatePlacementSamples() + { + if (currentPlacement == null) return; + + foreach (var kvp in SelectionHandler.SelectionSampleStates) + sampleChanged(kvp.Key, kvp.Value.Value); + } + + private void sampleChanged(string sampleName, TernaryState state) + { + if (currentPlacement == null) return; + + var samples = currentPlacement.HitObject.Samples; + + var existingSample = samples.FirstOrDefault(s => s.Name == sampleName); + + switch (state) + { + case TernaryState.False: + if (existingSample != null) + samples.Remove(existingSample); + break; + + case TernaryState.True: + if (existingSample == null) + samples.Add(new HitSampleInfo { Name = sampleName }); + break; + } + } + + public readonly Bindable<TernaryState> NewCombo = new Bindable<TernaryState> { Description = "New Combo" }; + + /// <summary> + /// A collection of states which will be displayed to the user in the toolbox. + /// </summary> + public TernaryButton[] TernaryStates { get; private set; } + + /// <summary> + /// Create all ternary states required to be displayed to the user. + /// </summary> + protected virtual IEnumerable<TernaryButton> CreateTernaryButtons() + { + //TODO: this should only be enabled (visible?) for rulesets that provide combo-supporting HitObjects. + yield return new TernaryButton(NewCombo, "New combo", () => new SpriteIcon { Icon = FontAwesome.Regular.DotCircle }); + + foreach (var kvp in SelectionHandler.SelectionSampleStates) + yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => getIconForSample(kvp.Key)); + } + + private Drawable getIconForSample(string sampleName) + { + switch (sampleName) + { + case HitSampleInfo.HIT_CLAP: + return new SpriteIcon { Icon = FontAwesome.Solid.Hands }; + + case HitSampleInfo.HIT_WHISTLE: + return new SpriteIcon { Icon = FontAwesome.Solid.Bullhorn }; + + case HitSampleInfo.HIT_FINISH: + return new SpriteIcon { Icon = FontAwesome.Solid.DrumSteelpan }; + } + + return null; + } + + #region Placement + + /// <summary> + /// Refreshes the current placement tool. + /// </summary> + private void refreshTool() + { + removePlacement(); + createPlacement(); + } + + private void updatePlacementPosition() + { + var snapResult = composer.SnapScreenSpacePositionToValidTime(inputManager.CurrentState.Mouse.Position); + + currentPlacement.UpdatePosition(snapResult); + } + + #endregion + + protected override void Update() + { + base.Update(); + + if (composer.CursorInPlacementArea) + createPlacement(); + else if (currentPlacement?.PlacementActive == false) + removePlacement(); + + if (currentPlacement != null) + { + updatePlacementPosition(); + } + } + + protected sealed override SelectionBlueprint CreateBlueprintFor(HitObject hitObject) + { + var drawable = drawableHitObjects.FirstOrDefault(d => d.HitObject == hitObject); + + if (drawable == null) + return null; + + return CreateBlueprintFor(drawable); + } + + public virtual OverlaySelectionBlueprint CreateBlueprintFor(DrawableHitObject hitObject) => null; + + protected override void AddBlueprintFor(HitObject hitObject) + { + refreshTool(); + + base.AddBlueprintFor(hitObject); + + // on successful placement, the new combo button should be reset as this is the most common user interaction. + if (Beatmap.SelectedHitObjects.Count == 0) + NewCombo.Value = TernaryState.False; + } + + private void createPlacement() + { + if (currentPlacement != null) return; + + var blueprint = CurrentTool?.CreatePlacementBlueprint(); + + if (blueprint != null) + { + // doing this post-creations as adding the default hit sample should be the case regardless of the ruleset. + blueprint.HitObject.Samples.Add(new HitSampleInfo { Name = HitSampleInfo.HIT_NORMAL }); + + placementBlueprintContainer.Child = currentPlacement = blueprint; + + // Fixes a 1-frame position discrepancy due to the first mouse move event happening in the next frame + updatePlacementPosition(); + + updatePlacementSamples(); + + updatePlacementNewCombo(); + } + } + + private void removePlacement() + { + if (currentPlacement == null) return; + + currentPlacement.EndPlacement(false); + currentPlacement.Expire(); + currentPlacement = null; + } + + private HitObjectCompositionTool currentTool; + + /// <summary> + /// The current placement tool. + /// </summary> + public HitObjectCompositionTool CurrentTool + { + get => currentTool; + + set + { + if (currentTool == value) + return; + + currentTool = value; + + refreshTool(); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs new file mode 100644 index 0000000000..8a92a2011d --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -0,0 +1,138 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; +using osu.Game.Graphics; +using osu.Game.Rulesets.Edit; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// <summary> + /// A grid which takes user input and returns a quantized ("snapped") position and time. + /// </summary> + public abstract class DistanceSnapGrid : CompositeDrawable + { + /// <summary> + /// The spacing between each tick of the beat snapping grid. + /// </summary> + protected float DistanceSpacing { get; private set; } + + /// <summary> + /// The maximum number of distance snapping intervals allowed. + /// </summary> + protected int MaxIntervals { get; private set; } + + /// <summary> + /// The position which the grid should start. + /// The first beat snapping tick is located at <see cref="StartPosition"/> + <see cref="DistanceSpacing"/> away from this point. + /// </summary> + protected readonly Vector2 StartPosition; + + /// <summary> + /// The snapping time at <see cref="StartPosition"/>. + /// </summary> + protected readonly double StartTime; + + [Resolved] + protected OsuColour Colours { get; private set; } + + [Resolved] + protected IPositionSnapProvider SnapProvider { get; private set; } + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } + + private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); + private readonly double? endTime; + + /// <summary> + /// Creates a new <see cref="DistanceSnapGrid"/>. + /// </summary> + /// <param name="startPosition">The position at which the grid should start. The first tick is located one distance spacing length away from this point.</param> + /// <param name="startTime">The snapping time at <see cref="StartPosition"/>.</param> + /// <param name="endTime">The time at which the snapping grid should end. If null, the grid will continue until the bounds of the screen are exceeded.</param> + protected DistanceSnapGrid(Vector2 startPosition, double startTime, double? endTime = null) + { + this.endTime = endTime; + StartPosition = startPosition; + StartTime = startTime; + + RelativeSizeAxes = Axes.Both; + + AddLayout(gridCache); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatDivisor.BindValueChanged(_ => updateSpacing(), true); + } + + private void updateSpacing() + { + DistanceSpacing = SnapProvider.GetBeatSnapDistanceAt(StartTime); + + if (endTime == null) + MaxIntervals = int.MaxValue; + else + { + // +1 is added since a snapped hitobject may have its start time slightly less than the snapped time due to floating point errors + double maxDuration = endTime.Value - StartTime + 1; + MaxIntervals = (int)(maxDuration / SnapProvider.DistanceToDuration(StartTime, DistanceSpacing)); + } + + gridCache.Invalidate(); + } + + protected override void Update() + { + base.Update(); + + if (!gridCache.IsValid) + { + ClearInternal(); + CreateContent(); + gridCache.Validate(); + } + } + + /// <summary> + /// Creates the content which visualises the grid ticks. + /// </summary> + protected abstract void CreateContent(); + + /// <summary> + /// Snaps a position to this grid. + /// </summary> + /// <param name="position">The original position in coordinate space local to this <see cref="DistanceSnapGrid"/>.</param> + /// <returns>A tuple containing the snapped position in coordinate space local to this <see cref="DistanceSnapGrid"/> and the respective time value.</returns> + public abstract (Vector2 position, double time) GetSnappedPosition(Vector2 position); + + /// <summary> + /// Retrieves the applicable colour for a beat index. + /// </summary> + /// <param name="placementIndex">The 0-based beat index from the point of placement.</param> + /// <returns>The applicable colour.</returns> + protected ColourInfo GetColourForIndexFromPlacement(int placementIndex) + { + var timingPoint = beatmap.ControlPointInfo.TimingPointAt(StartTime); + var beatLength = timingPoint.BeatLength / beatDivisor.Value; + var beatIndex = (int)Math.Round((StartTime - timingPoint.Time) / beatLength); + + var colour = BindableBeatDivisor.GetColourFor(BindableBeatDivisor.GetDivisorForBeatIndex(beatIndex + placementIndex + 1, beatDivisor.Value), Colours); + + int repeatIndex = placementIndex / beatDivisor.Value; + return colour.MultiplyAlpha(0.5f / (repeatIndex + 1)); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs index 143615148a..eaee2cd1e2 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DragBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DragBox.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -15,16 +16,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// <summary> /// A box that displays the drag selection and provides selection events for users to handle. /// </summary> - public class DragBox : CompositeDrawable + public class DragBox : CompositeDrawable, IStateful<Visibility> { - private readonly Action<RectangleF> performSelection; + protected readonly Action<RectangleF> PerformSelection; - /// <summary> - /// Invoked when the drag selection has finished. - /// </summary> - public event Action DragEnd; - - private Drawable box; + protected Drawable Box; /// <summary> /// Creates a new <see cref="DragBox"/>. @@ -32,7 +28,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// <param name="performSelection">A delegate that performs drag selection.</param> public DragBox(Action<RectangleF> performSelection) { - this.performSelection = performSelection; + PerformSelection = performSelection; RelativeSizeAxes = Axes.Both; AlwaysPresent = true; @@ -42,26 +38,29 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load() { - InternalChild = box = new Container - { - Masking = true, - BorderColour = Color4.White, - BorderThickness = SelectionHandler.BORDER_RADIUS, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.1f - } - }; + InternalChild = Box = CreateBox(); } - protected override bool OnDragStart(DragStartEvent e) + protected virtual Drawable CreateBox() => new Container { - this.FadeIn(250, Easing.OutQuint); - return true; - } + Masking = true, + BorderColour = Color4.White, + BorderThickness = SelectionBox.BORDER_RADIUS, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f + } + }; - protected override bool OnDrag(DragEvent e) + private RectangleF? dragRectangle; + + /// <summary> + /// Handle a forwarded mouse event. + /// </summary> + /// <param name="e">The mouse event.</param> + /// <returns>Whether the event should be handled and blocking.</returns> + public virtual bool HandleDrag(MouseButtonEvent e) { var dragPosition = e.ScreenSpaceMousePosition; var dragStartPosition = e.ScreenSpaceMouseDownPosition; @@ -69,23 +68,48 @@ namespace osu.Game.Screens.Edit.Compose.Components var dragQuad = new Quad(dragStartPosition.X, dragStartPosition.Y, dragPosition.X - dragStartPosition.X, dragPosition.Y - dragStartPosition.Y); // We use AABBFloat instead of RectangleF since it handles negative sizes for us - var dragRectangle = dragQuad.AABBFloat; + var rec = dragQuad.AABBFloat; + dragRectangle = rec; - var topLeft = ToLocalSpace(dragRectangle.TopLeft); - var bottomRight = ToLocalSpace(dragRectangle.BottomRight); + var topLeft = ToLocalSpace(rec.TopLeft); + var bottomRight = ToLocalSpace(rec.BottomRight); - box.Position = topLeft; - box.Size = bottomRight - topLeft; - - performSelection?.Invoke(dragRectangle); + Box.Position = topLeft; + Box.Size = bottomRight - topLeft; return true; } - protected override bool OnDragEnd(DragEndEvent e) + private Visibility state; + + public Visibility State { - this.FadeOut(250, Easing.OutQuint); - DragEnd?.Invoke(); - return true; + get => state; + set + { + if (value == state) return; + + state = value; + this.FadeTo(state == Visibility.Hidden ? 0 : 1, 250, Easing.OutQuint); + StateChanged?.Invoke(state); + } } + + protected override void Update() + { + base.Update(); + + if (dragRectangle != null) + PerformSelection?.Invoke(dragRectangle.Value); + } + + public override void Hide() + { + State = Visibility.Hidden; + dragRectangle = null; + } + + public override void Show() => State = Visibility.Visible; + + public event Action<Visibility> StateChanged; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorPlayfieldBorder.cs b/osu.Game/Screens/Edit/Compose/Components/EditorPlayfieldBorder.cs deleted file mode 100644 index 4d956336b7..0000000000 --- a/osu.Game/Screens/Edit/Compose/Components/EditorPlayfieldBorder.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osuTK.Graphics; - -namespace osu.Game.Screens.Edit.Compose.Components -{ - /// <summary> - /// Provides a border around the playfield. - /// </summary> - public class EditorPlayfieldBorder : CompositeDrawable - { - public EditorPlayfieldBorder() - { - RelativeSizeAxes = Axes.Both; - - Masking = true; - BorderColour = Color4.White; - BorderThickness = 2; - - InternalChild = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - }; - } - } -} diff --git a/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs new file mode 100644 index 0000000000..0792d0f80e --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/MoveSelectionEvent.cs @@ -0,0 +1,37 @@ +// 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. + +using osu.Game.Rulesets.Edit; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// <summary> + /// An event which occurs when a <see cref="OverlaySelectionBlueprint"/> is moved. + /// </summary> + public class MoveSelectionEvent + { + /// <summary> + /// The <see cref="SelectionBlueprint"/> that triggered this <see cref="MoveSelectionEvent"/>. + /// </summary> + public readonly SelectionBlueprint Blueprint; + + /// <summary> + /// The expected screen-space position of the hitobject at the current cursor position. + /// </summary> + public readonly Vector2 ScreenSpacePosition; + + /// <summary> + /// The distance between <see cref="ScreenSpacePosition"/> and the hitobject's current position, in the coordinate-space of the hitobject's parent. + /// </summary> + public readonly Vector2 InstantDelta; + + public MoveSelectionEvent(SelectionBlueprint blueprint, Vector2 screenSpacePosition) + { + Blueprint = blueprint; + ScreenSpacePosition = screenSpacePosition; + + InstantDelta = Blueprint.GetInstantDelta(ScreenSpacePosition); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs new file mode 100644 index 0000000000..742d433760 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -0,0 +1,257 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + public class SelectionBox : CompositeDrawable + { + public Func<float, bool> OnRotation; + public Func<Vector2, Anchor, bool> OnScale; + public Func<Direction, bool> OnFlip; + public Func<bool> OnReverse; + + public Action OperationStarted; + public Action OperationEnded; + + private bool canReverse; + + /// <summary> + /// Whether pattern reversing support should be enabled. + /// </summary> + public bool CanReverse + { + get => canReverse; + set + { + if (canReverse == value) return; + + canReverse = value; + recreate(); + } + } + + private bool canRotate; + + /// <summary> + /// Whether rotation support should be enabled. + /// </summary> + public bool CanRotate + { + get => canRotate; + set + { + if (canRotate == value) return; + + canRotate = value; + recreate(); + } + } + + private bool canScaleX; + + /// <summary> + /// Whether vertical scale support should be enabled. + /// </summary> + public bool CanScaleX + { + get => canScaleX; + set + { + if (canScaleX == value) return; + + canScaleX = value; + recreate(); + } + } + + private bool canScaleY; + + /// <summary> + /// Whether horizontal scale support should be enabled. + /// </summary> + public bool CanScaleY + { + get => canScaleY; + set + { + if (canScaleY == value) return; + + canScaleY = value; + recreate(); + } + } + + private FillFlowContainer buttons; + + public const float BORDER_RADIUS = 3; + + [Resolved] + private OsuColour colours { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Both; + + recreate(); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat || !e.ControlPressed) + return false; + + switch (e.Key) + { + case Key.G: + return CanReverse && OnReverse?.Invoke() == true; + + case Key.H: + return CanScaleX && OnFlip?.Invoke(Direction.Horizontal) == true; + + case Key.J: + return CanScaleY && OnFlip?.Invoke(Direction.Vertical) == true; + } + + return base.OnKeyDown(e); + } + + private void recreate() + { + if (LoadState < LoadState.Loading) + return; + + InternalChildren = new Drawable[] + { + new Container + { + Masking = true, + BorderThickness = BORDER_RADIUS, + BorderColour = colours.YellowDark, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + + AlwaysPresent = true, + Alpha = 0 + }, + } + }, + buttons = new FillFlowContainer + { + Y = 20, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre + } + }; + + if (CanScaleX) addXScaleComponents(); + if (CanScaleX && CanScaleY) addFullScaleComponents(); + if (CanScaleY) addYScaleComponents(); + if (CanRotate) addRotationComponents(); + if (CanReverse) addButton(FontAwesome.Solid.Backward, "Reverse pattern (Ctrl-G)", () => OnReverse?.Invoke()); + } + + 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(e.Delta.X), + OperationStarted = operationStarted, + OperationEnded = operationEnded + } + }); + } + + private void addYScaleComponents() + { + addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically (Ctrl-J)", () => OnFlip?.Invoke(Direction.Vertical)); + + addDragHandle(Anchor.TopCentre); + addDragHandle(Anchor.BottomCentre); + } + + private void addFullScaleComponents() + { + addDragHandle(Anchor.TopLeft); + addDragHandle(Anchor.TopRight); + addDragHandle(Anchor.BottomLeft); + addDragHandle(Anchor.BottomRight); + } + + private void addXScaleComponents() + { + addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally (Ctrl-H)", () => OnFlip?.Invoke(Direction.Horizontal)); + + addDragHandle(Anchor.CentreLeft); + addDragHandle(Anchor.CentreRight); + } + + private void addButton(IconUsage icon, string tooltip, Action action) + { + buttons.Add(new SelectionBoxDragHandleButton(icon, tooltip) + { + OperationStarted = operationStarted, + OperationEnded = operationEnded, + Action = action + }); + } + + private void addDragHandle(Anchor anchor) => AddInternal(new SelectionBoxDragHandle + { + Anchor = anchor, + HandleDrag = e => OnScale?.Invoke(e.Delta, anchor), + OperationStarted = operationStarted, + OperationEnded = operationEnded + }); + + private int activeOperations; + + private void operationEnded() + { + if (--activeOperations == 0) + OperationEnded?.Invoke(); + } + + private void operationStarted() + { + if (activeOperations++ == 0) + OperationStarted?.Invoke(); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs new file mode 100644 index 0000000000..921b4eb042 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandle.cs @@ -0,0 +1,105 @@ +// 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. + +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 Action OperationStarted; + public Action OperationEnded; + + public Action<DragEvent> 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(); + return true; + } + + protected override void OnDrag(DragEvent e) + { + HandleDrag?.Invoke(e); + base.OnDrag(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + HandlingMouse = false; + OperationEnded?.Invoke(); + + UpdateHoverState(); + base.OnDragEnd(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + HandlingMouse = false; + UpdateHoverState(); + base.OnMouseUp(e); + } + + protected virtual void UpdateHoverState() + { + circle.Colour = HandlingMouse ? colours.GrayF : (IsHovered ? colours.Red : colours.YellowDark); + this.ScaleTo(HandlingMouse || IsHovered ? 1.5f : 1, 100, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleButton.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleButton.cs new file mode 100644 index 0000000000..74ae949389 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxDragHandleButton.cs @@ -0,0 +1,66 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// <summary> + /// A drag "handle" which shares the visual appearance but behaves more like a clickable button. + /// </summary> + public sealed class SelectionBoxDragHandleButton : SelectionBoxDragHandle, IHasTooltip + { + private SpriteIcon icon; + + private readonly IconUsage iconUsage; + + public Action Action; + + public SelectionBoxDragHandleButton(IconUsage iconUsage, string tooltip) + { + this.iconUsage = iconUsage; + + TooltipText = tooltip; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load() + { + Size *= 2; + AddInternal(icon = new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + Icon = iconUsage, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } + + protected override bool OnClick(ClickEvent e) + { + OperationStarted?.Invoke(); + Action?.Invoke(); + OperationEnded?.Invoke(); + return true; + } + + protected override void UpdateHoverState() + { + base.UpdateHoverState(); + icon.Colour = !HandlingMouse && IsHovered ? Color4.White : Color4.Black; + } + + public string TooltipText { get; } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 11e649168f..21810379cc 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -4,16 +4,25 @@ 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.UserInterface; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.Input.States; +using osu.Game.Audio; 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; @@ -22,19 +31,24 @@ namespace osu.Game.Screens.Edit.Compose.Components /// <summary> /// A component which outlines <see cref="DrawableHitObject"/>s and handles movement of selections. /// </summary> - public class SelectionHandler : CompositeDrawable + public class SelectionHandler : CompositeDrawable, IKeyBindingHandler<PlatformAction>, IHasContextMenu { - public const float BORDER_RADIUS = 2; - - protected IEnumerable<SelectionBlueprint> SelectedBlueprints => selectedBlueprints; + public IEnumerable<SelectionBlueprint> SelectedBlueprints => selectedBlueprints; private readonly List<SelectionBlueprint> selectedBlueprints; - protected IEnumerable<HitObject> SelectedHitObjects => selectedBlueprints.Select(b => b.HitObject.HitObject); + public int SelectedCount => selectedBlueprints.Count; - private Drawable outline; + private Drawable content; + + private OsuSpriteText selectionDetailsText; + + protected SelectionBox SelectionBox { get; private set; } [Resolved] - private IPlacementHandler placementHandler { get; set; } + protected EditorBeatmap EditorBeatmap { get; private set; } + + [Resolved(CanBeNull = true)] + protected IEditorChangeHandler ChangeHandler { get; private set; } public SelectionHandler() { @@ -48,45 +62,123 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - InternalChild = outline = new Container + createStateBindables(); + + InternalChild = content = new Container { - Masking = true, - BorderThickness = BORDER_RADIUS, - BorderColour = colours.Yellow, - Child = new Box + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - AlwaysPresent = true, - Alpha = 0 + // 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(), } }; } + public SelectionBox CreateSelectionBox() + => new SelectionBox + { + OperationStarted = OnOperationBegan, + OperationEnded = OnOperationEnded, + + OnRotation = HandleRotation, + OnScale = HandleScale, + OnFlip = HandleFlip, + OnReverse = HandleReverse, + }; + + /// <summary> + /// Fired when a drag operation ends from the selection box. + /// </summary> + protected virtual void OnOperationBegan() + { + ChangeHandler.BeginChange(); + } + + /// <summary> + /// Fired when a drag operation begins from the selection box. + /// </summary> + protected virtual void OnOperationEnded() + { + ChangeHandler.EndChange(); + } + #region User Input Handling /// <summary> - /// Handles the selected <see cref="DrawableHitObject"/>s being dragged. + /// Handles the selected <see cref="DrawableHitObject"/>s being moved. /// </summary> - /// <param name="blueprint">The <see cref="SelectionBlueprint"/> that received the drag event.</param> - /// <param name="dragEvent">The drag event.</param> - public virtual void HandleDrag(SelectionBlueprint blueprint, DragEvent dragEvent) - { - } + /// <remarks> + /// Just returning true is enough to allow <see cref="HitObject.StartTime"/> updates to take place. + /// Custom implementation is only required if other attributes are to be considered, like changing columns. + /// </remarks> + /// <param name="moveEvent">The move event.</param> + /// <returns> + /// Whether any <see cref="DrawableHitObject"/>s could be moved. + /// Returning true will also propagate StartTime changes provided by the closest <see cref="IPositionSnapProvider.SnapScreenSpacePositionToValidTime"/>. + /// </returns> + public virtual bool HandleMovement(MoveSelectionEvent moveEvent) => false; - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.Repeat) - return base.OnKeyDown(e); + /// <summary> + /// Handles the selected <see cref="DrawableHitObject"/>s being rotated. + /// </summary> + /// <param name="angle">The delta angle to apply to the selection.</param> + /// <returns>Whether any <see cref="DrawableHitObject"/>s could be rotated.</returns> + public virtual bool HandleRotation(float angle) => false; - switch (e.Key) + /// <summary> + /// Handles the selected <see cref="DrawableHitObject"/>s being scaled. + /// </summary> + /// <param name="scale">The delta scale to apply, in playfield local coordinates.</param> + /// <param name="anchor">The point of reference where the scale is originating from.</param> + /// <returns>Whether any <see cref="DrawableHitObject"/>s could be scaled.</returns> + public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false; + + /// <summary> + /// Handles the selected <see cref="DrawableHitObject"/>s being flipped. + /// </summary> + /// <param name="direction">The direction to flip</param> + /// <returns>Whether any <see cref="DrawableHitObject"/>s could be flipped.</returns> + public virtual bool HandleFlip(Direction direction) => false; + + /// <summary> + /// Handles the selected <see cref="DrawableHitObject"/>s being reversed pattern-wise. + /// </summary> + /// <returns>Whether any <see cref="DrawableHitObject"/>s could be reversed.</returns> + public virtual bool HandleReverse() => false; + + public bool OnPressed(PlatformAction action) + { + switch (action.ActionMethod) { - case Key.Delete: - foreach (var h in selectedBlueprints.ToList()) - placementHandler.Delete(h.HitObject.HitObject); + case PlatformActionMethod.Delete: + deleteSelected(); return true; } - return base.OnKeyDown(e); + return false; + } + + public void OnReleased(PlatformAction action) + { } #endregion @@ -102,7 +194,14 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Handle a blueprint becoming selected. /// </summary> /// <param name="blueprint">The blueprint.</param> - internal void HandleSelected(SelectionBlueprint blueprint) => selectedBlueprints.Add(blueprint); + internal void HandleSelected(SelectionBlueprint blueprint) + { + selectedBlueprints.Add(blueprint); + + // 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); + } /// <summary> /// Handle a blueprint becoming deselected. @@ -112,48 +211,79 @@ namespace osu.Game.Screens.Edit.Compose.Components { selectedBlueprints.Remove(blueprint); - // We don't want to update visibility if > 0, since we may be deselecting blueprints during drag-selection - if (selectedBlueprints.Count == 0) - UpdateVisibility(); + EditorBeatmap.SelectedHitObjects.Remove(blueprint.HitObject); } /// <summary> /// Handle a blueprint requesting selection. /// </summary> /// <param name="blueprint">The blueprint.</param> - /// <param name="state">The input state at the point of selection.</param> - internal void HandleSelectionRequested(SelectionBlueprint blueprint, InputState state) + /// <param name="e">The mouse event responsible for selection.</param> + /// <returns>Whether a selection was performed.</returns> + internal bool HandleSelectionRequested(SelectionBlueprint blueprint, MouseButtonEvent e) { - if (state.Keyboard.ControlPressed) + if (e.ShiftPressed && e.Button == MouseButton.Right) { - if (blueprint.IsSelected) - blueprint.Deselect(); - else - blueprint.Select(); + handleQuickDeletion(blueprint); + return false; } + + if (e.ControlPressed && e.Button == MouseButton.Left) + blueprint.ToggleSelection(); else - { - if (blueprint.IsSelected) - return; + ensureSelected(blueprint); - DeselectAll?.Invoke(); - blueprint.Select(); - } + return true; + } - UpdateVisibility(); + private void handleQuickDeletion(SelectionBlueprint blueprint) + { + if (blueprint.HandleQuickDeletion()) + return; + + if (!blueprint.IsSelected) + EditorBeatmap.Remove(blueprint.HitObject); + else + deleteSelected(); + } + + private void ensureSelected(SelectionBlueprint blueprint) + { + if (blueprint.IsSelected) + return; + + DeselectAll?.Invoke(); + blueprint.Select(); + } + + private void deleteSelected() + { + EditorBeatmap.RemoveRange(selectedBlueprints.Select(b => b.HitObject)); } #endregion + #region Outline Display + /// <summary> /// Updates whether this <see cref="SelectionHandler"/> is visible. /// </summary> - internal void UpdateVisibility() + private void updateVisibility() + { + int count = selectedBlueprints.Count; + + selectionDetailsText.Text = count > 0 ? count.ToString() : string.Empty; + + this.FadeTo(count > 0 ? 1 : 0); + OnSelectionChanged(); + } + + /// <summary> + /// Triggered whenever the set of selected objects changes. + /// Should update the selection box's state to match supported operations. + /// </summary> + protected virtual void OnSelectionChanged() { - if (selectedBlueprints.Count > 0) - Show(); - else - Hide(); } protected override void Update() @@ -176,8 +306,206 @@ namespace osu.Game.Screens.Edit.Compose.Components topLeft -= new Vector2(5); bottomRight += new Vector2(5); - outline.Size = bottomRight - topLeft; - outline.Position = topLeft; + content.Size = bottomRight - topLeft; + content.Position = topLeft; } + + #endregion + + #region Sample Changes + + /// <summary> + /// Adds a hit sample to all selected <see cref="HitObject"/>s. + /// </summary> + /// <param name="sampleName">The name of the hit sample.</param> + 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 { Name = sampleName }); + } + + EditorBeatmap.EndChange(); + } + + /// <summary> + /// Set the new combo state of all selected <see cref="HitObject"/>s. + /// </summary> + /// <param name="state">Whether to set or unset.</param> + /// <exception cref="InvalidOperationException">Throws if any selected object doesn't implement <see cref="IHasComboInformation"/></exception> + 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(); + } + + /// <summary> + /// Removes a hit sample from all selected <see cref="HitObject"/>s. + /// </summary> + /// <param name="sampleName">The name of the hit sample.</param> + 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 + + /// <summary> + /// The state of "new combo" for all selected hitobjects. + /// </summary> + public readonly Bindable<TernaryState> SelectionNewComboState = new Bindable<TernaryState>(); + + /// <summary> + /// The state of each sample type for all selected hitobjects. Keys match with <see cref="HitSampleInfo"/> constant specifications. + /// </summary> + public readonly Dictionary<string, Bindable<TernaryState>> SelectionSampleStates = new Dictionary<string, Bindable<TernaryState>>(); + + /// <summary> + /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) + /// </summary> + private void createStateBindables() + { + foreach (var sampleName in HitSampleInfo.AllAdditions) + { + var bindable = new Bindable<TernaryState> + { + 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); + }; + } + + /// <summary> + /// Called when context menu ternary states may need to be recalculated (selection changed or hitobject updated). + /// </summary> + protected virtual void UpdateTernaryStates() + { + SelectionNewComboState.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects.OfType<IHasComboInformation>(), h => h.NewCombo); + + foreach (var (sampleName, bindable) in SelectionSampleStates) + { + bindable.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects, h => h.Samples.Any(s => s.Name == sampleName)); + } + } + + /// <summary> + /// Given a selection target and a function of truth, retrieve the correct ternary state for display. + /// </summary> + protected TernaryState GetStateFromSelection<T>(IEnumerable<T> selection, Func<T, bool> func) + { + if (selection.Any(func)) + return selection.All(func) ? TernaryState.True : TernaryState.Indeterminate; + + return TernaryState.False; + } + + #endregion + + #region Context Menu + + public MenuItem[] ContextMenuItems + { + get + { + if (!selectedBlueprints.Any(b => b.IsHovered)) + return Array.Empty<MenuItem>(); + + var items = new List<MenuItem>(); + + 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); + + 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), + }); + + return items.ToArray(); + } + } + + /// <summary> + /// Provide context menu items relevant to current selection. Calling base is not required. + /// </summary> + /// <param name="selection">The current selection.</param> + /// <returns>The relevant menu items.</returns> + protected virtual IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint> selection) + => Enumerable.Empty<MenuItem>(); + + #endregion } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs index 0d4c48b5f6..8c8b38d9ea 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/CentreMarker.cs @@ -12,14 +12,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public class CentreMarker : CompositeDrawable { - private const float triangle_width = 20; + private const float triangle_width = 15; private const float triangle_height = 10; private const float bar_width = 2; public CentreMarker() { RelativeSizeAxes = Axes.Y; - Size = new Vector2(20, 1); + Size = new Vector2(triangle_width, 1); Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -39,6 +39,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Origin = Anchor.BottomCentre, Size = new Vector2(triangle_width, triangle_height), Scale = new Vector2(1, -1) + }, + new Triangle + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Size = new Vector2(triangle_width, triangle_height), } }; } @@ -46,7 +52,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [BackgroundDependencyLoader] private void load(OsuColour colours) { - Colour = colours.Red; + Colour = colours.RedDark; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs new file mode 100644 index 0000000000..510ba8c094 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -0,0 +1,67 @@ +// 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. + +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 + { + private readonly DifficultyControlPoint difficultyPoint; + + private OsuSpriteText speedMultiplierText; + private readonly BindableNumber<double> speedMultiplier; + + public DifficultyPointPiece(DifficultyControlPoint difficultyPoint) + { + this.difficultyPoint = difficultyPoint; + speedMultiplier = difficultyPoint.SpeedMultiplierBindable.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + 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); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs new file mode 100644 index 0000000000..0f11fb1126 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -0,0 +1,85 @@ +// 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. + +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 SamplePointPiece : CompositeDrawable + { + private readonly SampleControlPoint samplePoint; + + private readonly Bindable<string> bank; + private readonly BindableNumber<int> volume; + + private OsuSpriteText text; + private Box volumeBox; + + public SamplePointPiece(SampleControlPoint samplePoint) + { + this.samplePoint = samplePoint; + volume = samplePoint.SampleVolumeBindable.GetBoundCopy(); + bank = samplePoint.SampleBankBindable.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Origin = Anchor.TopLeft; + Anchor = Anchor.TopLeft; + + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + + Color4 colour = samplePoint.GetRepresentingColour(colours); + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Y, + Width = 20, + 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, + }, + } + }, + 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); + 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 748c9e2ba3..7233faa955 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -1,63 +1,37 @@ // 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. +using System; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; +using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; -using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Rulesets.Edit; +using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class Timeline : ZoomableScrollContainer + [Cached(typeof(IPositionSnapProvider))] + [Cached] + public class Timeline : ZoomableScrollContainer, IPositionSnapProvider { public readonly Bindable<bool> WaveformVisible = new Bindable<bool>(); + + public readonly Bindable<bool> ControlPointsVisible = new Bindable<bool>(); + + public readonly Bindable<bool> TicksVisible = new Bindable<bool>(); + public readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>(); - private IAdjustableClock adjustableClock; - - public Timeline() - { - ZoomDuration = 200; - ZoomEasing = Easing.OutQuint; - Zoom = 10; - ScrollbarVisible = false; - } - - private WaveformGraph waveform; - - [BackgroundDependencyLoader] - private void load(IBindable<WorkingBeatmap> beatmap, IAdjustableClock adjustableClock, OsuColour colours) - { - this.adjustableClock = adjustableClock; - - Child = waveform = new WaveformGraph - { - RelativeSizeAxes = Axes.Both, - Colour = colours.Blue.Opacity(0.2f), - LowColour = colours.BlueLighter, - MidColour = colours.BlueDark, - HighColour = colours.BlueDarker, - Depth = float.MaxValue - }; - - // We don't want the centre marker to scroll - AddInternal(new CentreMarker()); - - WaveformVisible.ValueChanged += visible => waveform.FadeTo(visible.NewValue ? 1 : 0, 200, Easing.OutQuint); - - Beatmap.BindTo(beatmap); - Beatmap.BindValueChanged(b => - { - waveform.Waveform = b.NewValue.Waveform; - track = b.NewValue.Track; - }, true); - } + [Resolved] + private EditorClock editorClock { get; set; } /// <summary> /// The timeline's scroll position in the last frame. @@ -81,6 +55,77 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private Track track; + public Timeline() + { + ZoomDuration = 200; + ZoomEasing = Easing.OutQuint; + ScrollbarVisible = false; + } + + private WaveformGraph waveform; + + private TimelineTickDisplay ticks; + + private TimelineControlPointDisplay controlPoints; + + private Bindable<float> waveformOpacity; + + [BackgroundDependencyLoader] + private void load(IBindable<WorkingBeatmap> beatmap, OsuColour colours, OsuConfigManager config) + { + AddRange(new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Children = new Drawable[] + { + waveform = new WaveformGraph + { + RelativeSizeAxes = Axes.Both, + BaseColour = colours.Blue.Opacity(0.2f), + LowColour = colours.BlueLighter, + MidColour = colours.BlueDark, + HighColour = colours.BlueDarker, + }, + ticks = new TimelineTickDisplay(), + controlPoints = new TimelineControlPointDisplay(), + } + }, + }); + + // We don't want the centre marker to scroll + AddInternal(new CentreMarker { Depth = float.MaxValue }); + + waveformOpacity = config.GetBindable<float>(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 => + { + waveform.Waveform = b.NewValue.Waveform; + track = b.NewValue.Track; + + // todo: i don't think this is safe, the track may not be loaded yet. + if (track.Length > 0) + { + MaxZoom = getZoomLevelForVisibleMilliseconds(500); + MinZoom = getZoomLevelForVisibleMilliseconds(10000); + Zoom = getZoomLevelForVisibleMilliseconds(2000); + } + }, true); + } + + private void updateWaveformOpacity() => + waveform.FadeTo(WaveformVisible.Value ? waveformOpacity.Value : 0, 200, Easing.OutQuint); + + private float getZoomLevelForVisibleMilliseconds(double milliseconds) => Math.Max(1, (float)(track.Length / milliseconds)); + protected override void Update() { base.Update(); @@ -89,7 +134,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Content.Margin = new MarginPadding { Horizontal = DrawWidth / 2 }; // This needs to happen after transforms are updated, but before the scroll position is updated in base.UpdateAfterChildren - if (adjustableClock.IsRunning) + if (editorClock.IsRunning) scrollToTrackTime(); } @@ -99,21 +144,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (handlingDragInput) seekTrackToCurrent(); - else if (!adjustableClock.IsRunning) + else if (!editorClock.IsRunning) { // The track isn't running. There are two cases we have to be wary of: // 1) The user flick-drags on this timeline: We want the track to follow us // 2) The user changes the track time through some other means (scrolling in the editor or overview timeline): We want to follow the track time // The simplest way to cover both cases is by checking whether the scroll position has changed and the audio hasn't been changed externally - if (Current != lastScrollPosition && adjustableClock.CurrentTime == lastTrackTime) + if (Current != lastScrollPosition && editorClock.CurrentTime == lastTrackTime) seekTrackToCurrent(); else scrollToTrackTime(); } lastScrollPosition = Current; - lastTrackTime = adjustableClock.CurrentTime; + lastTrackTime = editorClock.CurrentTime; } private void seekTrackToCurrent() @@ -121,15 +166,20 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (!track.IsLoaded) return; - adjustableClock.Seek(Current / Content.DrawWidth * track.Length); + editorClock.Seek(Current / Content.DrawWidth * track.Length); } private void scrollToTrackTime() { - if (!track.IsLoaded) + if (!track.IsLoaded || track.Length == 0) return; - ScrollTo((float)(adjustableClock.CurrentTime / track.Length) * Content.DrawWidth, false); + // covers the case where the user starts playback after a drag is in progress. + // we want to ensure the clock is always stopped during drags to avoid weird audio playback. + if (handlingDragInput) + editorClock.Stop(); + + ScrollTo((float)(editorClock.CurrentTime / track.Length) * Content.DrawWidth, false); } protected override bool OnMouseDown(MouseDownEvent e) @@ -143,24 +193,46 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return false; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { endUserDrag(); - return base.OnMouseUp(e); + base.OnMouseUp(e); } private void beginUserDrag() { handlingDragInput = true; - trackWasPlaying = adjustableClock.IsRunning; - adjustableClock.Stop(); + trackWasPlaying = editorClock.IsRunning; + editorClock.Stop(); } private void endUserDrag() { handlingDragInput = false; if (trackWasPlaying) - adjustableClock.Start(); + editorClock.Start(); } + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private IBeatSnapProvider beatSnapProvider { get; set; } + + public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => + new SnapResult(screenSpacePosition, beatSnapProvider.SnapTime(getTimeFromPosition(Content.ToLocalSpace(screenSpacePosition)))); + + private double getTimeFromPosition(Vector2 localPosition) => + (localPosition.X / Content.DrawWidth) * track.Length; + + public float GetBeatSnapDistanceAt(double referenceTime) => throw new NotImplementedException(); + + public float DurationToDistance(double referenceTime, double duration) => throw new NotImplementedException(); + + public double DistanceToDuration(double referenceTime, float distance) => throw new NotImplementedException(); + + public double GetSnappedDurationFromDistance(double referenceTime, float distance) => throw new NotImplementedException(); + + public float GetSnappedDistanceFromDistance(double referenceTime, float distance) => throw new NotImplementedException(); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs index 863a120fc3..0ec48e04c6 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineArea.cs @@ -1,35 +1,39 @@ // 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. +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineArea : CompositeDrawable + public class TimelineArea : Container { - private readonly Timeline timeline; + public readonly Timeline Timeline = new Timeline { RelativeSizeAxes = Axes.Both }; - public TimelineArea() + protected override Container<Drawable> Content => Timeline; + + [BackgroundDependencyLoader] + private void load() { Masking = true; CornerRadius = 5; - OsuCheckbox hitObjectsCheckbox; - OsuCheckbox hitSoundsCheckbox; OsuCheckbox waveformCheckbox; + OsuCheckbox controlPointsCheckbox; + OsuCheckbox ticksCheckbox; InternalChildren = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex("111") + Colour = Color4Extensions.FromHex("111") }, new GridContainer { @@ -47,7 +51,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex("222") + Colour = Color4Extensions.FromHex("222") }, new FillFlowContainer { @@ -55,14 +59,26 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Origin = Anchor.CentreLeft, AutoSizeAxes = Axes.Y, Width = 160, - Padding = new MarginPadding { Horizontal = 15 }, + Padding = new MarginPadding { Horizontal = 10 }, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 4), Children = new[] { - hitObjectsCheckbox = new OsuCheckbox { LabelText = "Hit objects" }, - hitSoundsCheckbox = new OsuCheckbox { LabelText = "Hit sounds" }, - waveformCheckbox = new OsuCheckbox { LabelText = "Waveform" } + waveformCheckbox = new OsuCheckbox + { + LabelText = "Waveform", + Current = { Value = true }, + }, + controlPointsCheckbox = new OsuCheckbox + { + LabelText = "Control Points", + Current = { Value = true }, + }, + ticksCheckbox = new OsuCheckbox + { + LabelText = "Ticks", + Current = { Value = true }, + } } } } @@ -76,7 +92,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex("333") + Colour = Color4Extensions.FromHex("333") }, new Container<TimelineButton> { @@ -107,7 +123,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } }, - timeline = new Timeline { RelativeSizeAxes = Axes.Both } + Timeline }, }, ColumnDimensions = new[] @@ -119,13 +135,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } }; - hitObjectsCheckbox.Current.Value = true; - hitSoundsCheckbox.Current.Value = true; - waveformCheckbox.Current.Value = true; - - timeline.WaveformVisible.BindTo(waveformCheckbox.Current); + Timeline.WaveformVisible.BindTo(waveformCheckbox.Current); + Timeline.ControlPointsVisible.BindTo(controlPointsCheckbox.Current); + Timeline.TicksVisible.BindTo(ticksCheckbox.Current); } - private void changeZoom(float change) => timeline.Zoom += change; + private void changeZoom(float change) => Timeline.Zoom += change; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs new file mode 100644 index 0000000000..10913a8bb9 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -0,0 +1,203 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +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.Framework.Utils; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + internal class TimelineBlueprintContainer : BlueprintContainer + { + [Resolved(CanBeNull = true)] + private Timeline timeline { get; set; } + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + private DragEvent lastDragEvent; + + private Bindable<HitObject> placement; + + private SelectionBlueprint placementBlueprint; + + public TimelineBlueprintContainer() + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Height = 0.4f; + + AddInternal(new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + DragBox.Alpha = 0; + + placement = beatmap.PlacementObject.GetBoundCopy(); + placement.ValueChanged += placementChanged; + } + + private void placementChanged(ValueChangedEvent<HitObject> obj) + { + if (obj.NewValue == null) + { + if (placementBlueprint != null) + { + SelectionBlueprints.Remove(placementBlueprint); + placementBlueprint = null; + } + } + else + { + placementBlueprint = CreateBlueprintFor(obj.NewValue); + + placementBlueprint.Colour = Color4.MediumPurple; + + SelectionBlueprints.Add(placementBlueprint); + } + } + + protected override Container<SelectionBlueprint> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; + + protected override void OnDrag(DragEvent e) + { + handleScrollViaDrag(e); + + base.OnDrag(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + base.OnDragEnd(e); + lastDragEvent = null; + } + + protected override void Update() + { + // trigger every frame so drags continue to update selection while playback is scrolling the timeline. + if (lastDragEvent != null) + OnDrag(lastDragEvent); + + base.Update(); + } + + protected override SelectionHandler CreateSelectionHandler() => new TimelineSelectionHandler(); + + protected override SelectionBlueprint CreateBlueprintFor(HitObject hitObject) => new TimelineHitObjectBlueprint(hitObject) + { + OnDragHandled = handleScrollViaDrag + }; + + protected override DragBox CreateDragBox(Action<RectangleF> performSelect) => new TimelineDragBox(performSelect); + + private void handleScrollViaDrag(DragEvent e) + { + lastDragEvent = e; + + if (lastDragEvent == null) + return; + + if (timeline != null) + { + var timelineQuad = timeline.ScreenSpaceDrawQuad; + var mouseX = e.ScreenSpaceMousePosition.X; + + // scroll if in a drag and dragging outside visible extents + if (mouseX > timelineQuad.TopRight.X) + timeline.ScrollBy((float)((mouseX - timelineQuad.TopRight.X) / 10 * Clock.ElapsedFrameTime)); + else if (mouseX < timelineQuad.TopLeft.X) + timeline.ScrollBy((float)((mouseX - timelineQuad.TopLeft.X) / 10 * Clock.ElapsedFrameTime)); + } + } + + internal class TimelineSelectionHandler : SelectionHandler + { + // for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation + public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + } + + private class TimelineDragBox : DragBox + { + // the following values hold the start and end X positions of the drag box in the timeline's local space, + // but with zoom unapplied in order to be able to compensate for positional changes + // while the timeline is being zoomed in/out. + private float? selectionStart; + private float selectionEnd; + + [Resolved] + private Timeline timeline { get; set; } + + public TimelineDragBox(Action<RectangleF> performSelect) + : base(performSelect) + { + } + + protected override Drawable CreateBox() => new Box + { + RelativeSizeAxes = Axes.Y, + Alpha = 0.3f + }; + + public override bool HandleDrag(MouseButtonEvent e) + { + selectionStart ??= e.MouseDownPosition.X / timeline.CurrentZoom; + + // only calculate end when a transition is not in progress to avoid bouncing. + if (Precision.AlmostEquals(timeline.CurrentZoom, timeline.Zoom)) + selectionEnd = e.MousePosition.X / timeline.CurrentZoom; + + updateDragBoxPosition(); + return true; + } + + private void updateDragBoxPosition() + { + if (selectionStart == null) + return; + + float rescaledStart = selectionStart.Value * timeline.CurrentZoom; + float rescaledEnd = selectionEnd * timeline.CurrentZoom; + + Box.X = Math.Min(rescaledStart, rescaledEnd); + Box.Width = Math.Abs(rescaledStart - rescaledEnd); + + PerformSelection?.Invoke(Box.ScreenSpaceDrawQuad.AABBFloat); + } + + public override void Hide() + { + base.Hide(); + selectionStart = null; + } + } + + protected class TimelineSelectionBlueprintContainer : Container<SelectionBlueprint> + { + protected override Container<SelectionBlueprint> Content { get; } + + public TimelineSelectionBlueprintContainer() + { + AddInternal(new TimelinePart<SelectionBlueprint>(Content = new Container<SelectionBlueprint> { RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both }); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs index 8865bf31ea..5550c6a748 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineButton.cs @@ -6,10 +6,13 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { @@ -52,6 +55,45 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline HoverColour = OsuColour.Gray(0.25f); FlashColour = OsuColour.Gray(0.5f); } + + private ScheduledDelegate repeatSchedule; + + /// <summary> + /// The initial delay before mouse down repeat begins. + /// </summary> + private const int repeat_initial_delay = 250; + + /// <summary> + /// The delay between mouse down repeats after the initial repeat. + /// </summary> + private const int repeat_tick_rate = 70; + + protected override bool OnClick(ClickEvent e) + { + // don't actuate a click since we are manually handling repeats. + return true; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Left) + { + Action clickAction = () => base.OnClick(new ClickEvent(e.CurrentState, e.Button)); + + // run once for initial down + clickAction(); + + Scheduler.Add(repeatSchedule = new ScheduledDelegate(clickAction, Clock.CurrentTime + repeat_initial_delay, repeat_tick_rate)); + } + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + repeatSchedule?.Cancel(); + base.OnMouseUp(e); + } } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs new file mode 100644 index 0000000000..13191df13c --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointDisplay.cs @@ -0,0 +1,58 @@ +// 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. + +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + /// <summary> + /// The part of the timeline that displays the control points. + /// </summary> + public class TimelineControlPointDisplay : TimelinePart<TimelineControlPointGroup> + { + private readonly IBindableList<ControlPointGroup> controlPointGroups = new BindableList<ControlPointGroup>(); + + public TimelineControlPointDisplay() + { + RelativeSizeAxes = Axes.Both; + } + + protected override void LoadBeatmap(WorkingBeatmap beatmap) + { + base.LoadBeatmap(beatmap); + + controlPointGroups.UnbindAll(); + controlPointGroups.BindTo(beatmap.Beatmap.ControlPointInfo.Groups); + controlPointGroups.BindCollectionChanged((sender, args) => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Reset: + Clear(); + break; + + case NotifyCollectionChangedAction.Add: + foreach (var group in args.NewItems.OfType<ControlPointGroup>()) + Add(new TimelineControlPointGroup(group)); + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var group in args.OldItems.OfType<ControlPointGroup>()) + { + var matching = Children.SingleOrDefault(gv => gv.Group == group); + + matching?.Expire(); + } + + break; + } + }, true); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs new file mode 100644 index 0000000000..fb69f16792 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs @@ -0,0 +1,62 @@ +// 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. + +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; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public class TimelineControlPointGroup : CompositeDrawable + { + public readonly ControlPointGroup Group; + + private readonly IBindableList<ControlPoint> controlPoints = new BindableList<ControlPoint>(); + + [Resolved] + private OsuColour colours { get; set; } + + public TimelineControlPointGroup(ControlPointGroup group) + { + Group = group; + + RelativePositionAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + AutoSizeAxes = Axes.X; + + X = (float)group.Time; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + controlPoints.BindTo(Group.ControlPoints); + controlPoints.BindCollectionChanged((_, __) => + { + ClearInternal(); + + foreach (var point in controlPoints) + { + switch (point) + { + case DifficultyControlPoint difficultyPoint: + AddInternal(new DifficultyPointPiece(difficultyPoint) { Depth = -2 }); + break; + + case TimingControlPoint timingPoint: + AddInternal(new TimingPointPiece(timingPoint)); + break; + + case SampleControlPoint samplePoint: + AddInternal(new SamplePointPiece(samplePoint) { Depth = -1 }); + break; + } + } + }, true); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs new file mode 100644 index 0000000000..975433d407 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -0,0 +1,420 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +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.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public class TimelineHitObjectBlueprint : SelectionBlueprint + { + private readonly Circle circle; + + [UsedImplicitly] + private readonly Bindable<double> startTime; + + public Action<DragEvent> OnDragHandled; + + private readonly DragBar dragBar; + + private readonly List<Container> shadowComponents = new List<Container>(); + + private DrawableHitObject drawableHitObject; + + private Bindable<Color4> comboColour; + + private readonly Container mainComponents; + + private readonly OsuSpriteText comboIndexText; + + private Bindable<int> comboIndex; + + private const float thickness = 5; + + private const float shadow_radius = 5; + + private const float circle_size = 24; + + public TimelineHitObjectBlueprint(HitObject hitObject) + : base(hitObject) + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + startTime = hitObject.StartTimeBindable.GetBoundCopy(); + startTime.BindValueChanged(time => X = (float)time.NewValue, true); + + RelativePositionAxes = Axes.X; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + AddRangeInternal(new Drawable[] + { + mainComponents = 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), + }, + }); + + circle = new Circle + { + Size = new Vector2(circle_size), + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + EdgeEffect = new EdgeEffectParameters + { + 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) }, + }); + + shadowComponents.Add(dragBarUnderlay); + shadowComponents.Add(extensionBar); + } + else + { + mainComponents.Add(circle); + } + + updateShadows(); + } + + [BackgroundDependencyLoader(true)] + private void load(HitObjectComposer composer) + { + if (composer != null) + { + // best effort to get the drawable representation for grabbing colour and what not. + drawableHitObject = composer.HitObjects.FirstOrDefault(d => d.HitObject == HitObject); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (HitObject is IHasComboInformation comboInfo) + { + comboIndex = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); + comboIndex.BindValueChanged(combo => + { + comboIndexText.Text = (combo.NewValue + 1).ToString(); + }, true); + } + + if (drawableHitObject != null) + { + comboColour = drawableHitObject.AccentColour.GetBoundCopy(); + comboColour.BindValueChanged(colour => + { + if (HitObject is IHasDuration) + mainComponents.Colour = ColourInfo.GradientHorizontal(drawableHitObject.AccentColour.Value, Color4.White); + else + mainComponents.Colour = drawableHitObject.AccentColour.Value; + + var col = mainComponents.Colour.TopLeft.Linear; + float brightness = col.R + col.G + col.B; + + // decide the combo index colour based on brightness? + comboIndexText.Colour = brightness > 0.5f ? Color4.Black : Color4.White; + }, true); + } + } + + protected override void Update() + { + base.Update(); + + // no bindable so we perform this every update + float duration = (float)(HitObject.GetEndTime() - HitObject.StartTime); + + if (Width != duration) + { + Width = duration; + + // kind of haphazard but yeah, no bindables. + if (HitObject is IHasRepeats repeats) + updateRepeats(repeats); + } + } + + private Container repeatsContainer; + + private void updateRepeats(IHasRepeats repeats) + { + repeatsContainer?.Expire(); + + mainComponents.Add(repeatsContainer = new Container + { + RelativeSizeAxes = Axes.Both, + }); + + for (int i = 0; i < repeats.RepeatCount; i++) + { + repeatsContainer.Add(new Circle + { + Size = new Vector2(circle_size / 2), + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + X = (float)(i + 1) / (repeats.RepeatCount + 1), + }); + } + } + + protected override bool ShouldBeConsideredForInput(Drawable child) => true; + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + base.ReceivePositionalInputAt(screenSpacePos) || + circle.ReceivePositionalInputAt(screenSpacePos) || + dragBar?.ReceivePositionalInputAt(screenSpacePos) == true; + + 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 Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.TopLeft; + + public class DragBar : Container + { + private readonly HitObject hitObject; + + [Resolved] + private Timeline timeline { get; set; } + + public Action<DragEvent> OnDragHandled; + + public override bool HandlePositionalInput => hitObject != null; + + public DragBar(HitObject hitObject) + { + this.hitObject = hitObject; + + CornerRadius = 2; + Masking = true; + Size = new Vector2(5, 1); + Anchor = Anchor.CentreRight; + Origin = Anchor.Centre; + RelativePositionAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + } + }; + } + + protected override bool OnHover(HoverEvent e) + { + updateState(); + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateState(); + base.OnHoverLost(e); + } + + private bool hasMouseDown; + + protected override bool OnMouseDown(MouseDownEvent e) + { + hasMouseDown = true; + updateState(); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + hasMouseDown = false; + updateState(); + base.OnMouseUp(e); + } + + private void updateState() + { + Colour = IsHovered || hasMouseDown ? Color4.OrangeRed : Color4.White; + } + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private IBeatSnapProvider beatSnapProvider { get; set; } + + [Resolved(CanBeNull = true)] + private IEditorChangeHandler changeHandler { get; set; } + + protected override bool OnDragStart(DragStartEvent e) + { + changeHandler?.BeginChange(); + return true; + } + + protected override void OnDrag(DragEvent e) + { + base.OnDrag(e); + + OnDragHandled?.Invoke(e); + + if (timeline.SnapScreenSpacePositionToValidTime(e.ScreenSpaceMousePosition).Time is double time) + { + switch (hitObject) + { + case IHasRepeats repeatHitObject: + // find the number of repeats which can fit in the requested time. + var lengthOfOneRepeat = repeatHitObject.Duration / (repeatHitObject.RepeatCount + 1); + var proposedCount = Math.Max(0, (int)Math.Round((time - hitObject.StartTime) / lengthOfOneRepeat) - 1); + + if (proposedCount == repeatHitObject.RepeatCount) + return; + + repeatHitObject.RepeatCount = proposedCount; + beatmap.Update(hitObject); + break; + + case IHasDuration endTimeHitObject: + var snappedTime = Math.Max(hitObject.StartTime, beatSnapProvider.SnapTime(time)); + + if (endTimeHitObject.EndTime == snappedTime) + return; + + endTimeHitObject.Duration = snappedTime - hitObject.StartTime; + beatmap.Update(hitObject); + break; + } + } + } + + protected override void OnDragEnd(DragEndEvent e) + { + base.OnDragEnd(e); + + OnDragHandled?.Invoke(null); + changeHandler?.EndChange(); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs new file mode 100644 index 0000000000..724256af8b --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs @@ -0,0 +1,169 @@ +// 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. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; +using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public class TimelineTickDisplay : TimelinePart<PointVisualisation> + { + [Resolved] + private EditorBeatmap beatmap { get; set; } + + [Resolved] + private Bindable<WorkingBeatmap> working { get; set; } + + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } + + [Resolved] + private OsuColour colours { get; set; } + + public TimelineTickDisplay() + { + RelativeSizeAxes = Axes.Both; + } + + private readonly Cached tickCache = new Cached(); + + [BackgroundDependencyLoader] + private void load() + { + beatDivisor.BindValueChanged(_ => tickCache.Invalidate()); + } + + /// <summary> + /// The visible time/position range of the timeline. + /// </summary> + private (float min, float max) visibleRange = (float.MinValue, float.MaxValue); + + /// <summary> + /// The next time/position value to the left of the display when tick regeneration needs to be run. + /// </summary> + private float? nextMinTick; + + /// <summary> + /// The next time/position value to the right of the display when tick regeneration needs to be run. + /// </summary> + private float? nextMaxTick; + + [Resolved(canBeNull: true)] + private Timeline timeline { get; set; } + + protected override void Update() + { + base.Update(); + + 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); + + if (visibleRange != newRange) + { + visibleRange = newRange; + + // actual regeneration only needs to occur if we've passed one of the known next min/max tick boundaries. + if (nextMinTick == null || nextMaxTick == null || (visibleRange.min < nextMinTick || visibleRange.max > nextMaxTick)) + tickCache.Invalidate(); + } + } + + if (!tickCache.IsValid) + createTicks(); + } + + private void createTicks() + { + int drawableIndex = 0; + int highestDivisor = BindableBeatDivisor.VALID_DIVISORS.Last(); + + nextMinTick = null; + nextMaxTick = null; + + for (var i = 0; i < beatmap.ControlPointInfo.TimingPoints.Count; i++) + { + var point = beatmap.ControlPointInfo.TimingPoints[i]; + var until = i + 1 < beatmap.ControlPointInfo.TimingPoints.Count ? beatmap.ControlPointInfo.TimingPoints[i + 1].Time : working.Value.Track.Length; + + int beat = 0; + + for (double t = point.Time; t < until; t += point.BeatLength / beatDivisor.Value) + { + float xPos = (float)t; + + if (t < visibleRange.min) + nextMinTick = xPos; + else if (t > visibleRange.max) + nextMaxTick ??= xPos; + else + { + // if this is the first beat in the beatmap, there is no next min tick + if (beat == 0 && i == 0) + nextMinTick = float.MinValue; + + var 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; + } + + beat++; + } + } + + int usedDrawables = drawableIndex; + + // save a few drawables beyond the currently used for edge cases. + while (drawableIndex < Math.Min(usedDrawables + 16, Count)) + Children[drawableIndex++].Hide(); + + // expire any excess + while (drawableIndex < Count) + Children[drawableIndex++].Expire(); + + tickCache.Validate(); + + Drawable getNextUsablePoint() + { + PointVisualisation point; + if (drawableIndex >= Count) + Add(point = new PointVisualisation()); + else + point = Children[drawableIndex]; + + drawableIndex++; + point.Show(); + + return point; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs new file mode 100644 index 0000000000..ba94916458 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimingPointPiece.cs @@ -0,0 +1,63 @@ +// 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. + +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 + { + private readonly TimingControlPoint point; + + private readonly BindableNumber<double> beatLength; + private OsuSpriteText bpmText; + + public TimingPointPiece(TimingControlPoint 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"; + }, true); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs index cffb6bedf3..f10eb0d284 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/ZoomableScrollContainer.cs @@ -2,11 +2,13 @@ // 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.Transforms; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Timing; +using osu.Framework.Utils; using osu.Game.Graphics.Containers; using osuTK; @@ -27,21 +29,29 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly Container zoomedContent; protected override Container<Drawable> Content => zoomedContent; - private float currentZoom = 1; + /// <summary> + /// The current zoom level of <see cref="ZoomableScrollContainer" />. + /// It may differ from <see cref="Zoom" /> during transitions. + /// </summary> + public float CurrentZoom => currentZoom; + + [Resolved(canBeNull: true)] + private IFrameBasedClock editorClock { get; set; } + public ZoomableScrollContainer() : base(Direction.Horizontal) { base.Content.Add(zoomedContent = new Container { RelativeSizeAxes = Axes.Y }); } - private int minZoom = 1; + private float minZoom = 1; /// <summary> /// The minimum zoom level allowed. /// </summary> - public int MinZoom + public float MinZoom { get => minZoom; set @@ -56,12 +66,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } - private int maxZoom = 60; + private float maxZoom = 60; /// <summary> /// The maximum zoom level allowed. /// </summary> - public int MaxZoom + public float MaxZoom { get => maxZoom; set @@ -84,7 +94,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline get => zoomTarget; set { - value = MathHelper.Clamp(value, MinZoom, MaxZoom); + value = Math.Clamp(value, MinZoom, MaxZoom); if (IsLoaded) setZoomTarget(value, ToSpaceOfOtherDrawable(new Vector2(DrawWidth / 2, 0), zoomedContent).X); @@ -103,12 +113,19 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override bool OnScroll(ScrollEvent e) { - if (e.IsPrecise) - // for now, we don't support zoom when using a precision scroll device. this needs gesture support. - return base.OnScroll(e); + if (e.AltPressed) + { + // zoom when holding alt. + setZoomTarget(zoomTarget + e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X); + return true; + } - setZoomTarget(zoomTarget + e.ScrollDelta.Y, zoomedContent.ToLocalSpace(e.ScreenSpaceMousePosition).X); - return true; + // can't handle scroll correctly while playing. + // the editor will handle this case for us. + if (editorClock?.IsRunning == true) + return false; + + return base.OnScroll(e); } private void updateZoomedContentWidth() => zoomedContent.Width = DrawWidth * currentZoom; @@ -117,7 +134,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private void setZoomTarget(float newZoom, float focusPoint) { - zoomTarget = MathHelper.Clamp(newZoom, MinZoom, MaxZoom); + zoomTarget = Math.Clamp(newZoom, MinZoom, MaxZoom); transformZoomTo(zoomTarget, focusPoint, ZoomDuration, ZoomEasing); } diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 5699ef0a84..5282b4d998 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -1,131 +1,58 @@ // 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. -using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; -using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components.Timeline; -using osuTK.Graphics; +using osu.Game.Skinning; namespace osu.Game.Screens.Edit.Compose { - [Cached(Type = typeof(IPlacementHandler))] - public class ComposeScreen : EditorScreen, IPlacementHandler + public class ComposeScreen : EditorScreenWithTimeline { - private const float vertical_margins = 10; - private const float horizontal_margins = 20; - - private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); - private HitObjectComposer composer; - [BackgroundDependencyLoader(true)] - private void load([CanBeNull] BindableBeatDivisor beatDivisor) - { - if (beatDivisor != null) - this.beatDivisor.BindTo(beatDivisor); - - Container composerContainer; - - Children = new Drawable[] - { - new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - new Container - { - Name = "Timeline", - RelativeSizeAxes = Axes.Both, - Children = 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 - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 5 }, - Child = new TimelineArea { RelativeSizeAxes = Axes.Both } - }, - new BeatDivisorControl(beatDivisor) { RelativeSizeAxes = Axes.Both } - }, - }, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 90), - } - }, - } - } - } - }, - new Drawable[] - { - composerContainer = new Container - { - Name = "Composer content", - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = horizontal_margins, Vertical = vertical_margins }, - } - } - }, - RowDimensions = new[] { new Dimension(GridSizeMode.Absolute, 110) } - }, - }; - - var ruleset = Beatmap.Value.BeatmapInfo.Ruleset?.CreateInstance(); - - if (ruleset == null) - { - Logger.Log("Beatmap doesn't have a ruleset assigned."); - // ExitRequested?.Invoke(); - return; - } - - composer = ruleset.CreateHitObjectComposer(); - - if (composer == null) - { - Logger.Log($"Ruleset {ruleset.Description} doesn't support hitobject composition."); - // ExitRequested?.Invoke(); - return; - } - - composerContainer.Child = composer; - } - - public void BeginPlacement(HitObject hitObject) + public ComposeScreen() + : base(EditorScreenMode.Compose) { } - public void EndPlacement(HitObject hitObject) => composer.Add(hitObject); + private Ruleset ruleset; - public void Delete(HitObject hitObject) => composer.Remove(hitObject); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + ruleset = parent.Get<IBindable<WorkingBeatmap>>().Value.BeatmapInfo.Ruleset?.CreateInstance(); + composer = ruleset?.CreateHitObjectComposer(); + + // make the composer available to the timeline and other components in this screen. + dependencies.CacheAs(composer); + + return dependencies; + } + + protected override Drawable CreateMainContent() + { + if (ruleset == null || composer == null) + return new ScreenWhiteBox.UnderConstructionMessage(ruleset == null ? "This beatmap" : $"{ruleset.Description}'s composer"); + + var beatmapSkinProvider = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin); + + // the beatmapSkinProvider is used as the fallback source here to allow the ruleset-specific skin implementation + // full access to all skin sources. + var rulesetSkinProvider = new SkinProvidingContainer(ruleset.CreateLegacySkinProvider(beatmapSkinProvider, EditorBeatmap.PlayableBeatmap)); + + // load the skinning hierarchy first. + // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. + return beatmapSkinProvider.WithChild(rulesetSkinProvider.WithChild(composer)); + } + + protected override Drawable CreateTimelineContent() => composer == null ? base.CreateTimelineContent() : new TimelineBlueprintContainer(); } } diff --git a/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs b/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs index 47a4277430..aefcbc6542 100644 --- a/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs +++ b/osu.Game/Screens/Edit/Compose/IPlacementHandler.cs @@ -17,7 +17,8 @@ namespace osu.Game.Screens.Edit.Compose /// Notifies that a placement has finished. /// </summary> /// <param name="hitObject">The <see cref="HitObject"/> that has been placed.</param> - void EndPlacement(HitObject hitObject); + /// <param name="commit">Whether the object should be committed.</param> + void EndPlacement(HitObject hitObject, bool commit); /// <summary> /// Deletes a <see cref="HitObject"/>. diff --git a/osu.Game/Screens/Edit/Design/DesignScreen.cs b/osu.Game/Screens/Edit/Design/DesignScreen.cs index 2a334e1b30..f15639733c 100644 --- a/osu.Game/Screens/Edit/Design/DesignScreen.cs +++ b/osu.Game/Screens/Edit/Design/DesignScreen.cs @@ -1,52 +1,14 @@ // 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. -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics.Sprites; -using osuTK.Graphics; - namespace osu.Game.Screens.Edit.Design { public class DesignScreen : EditorScreen { public DesignScreen() + : base(EditorScreenMode.Design) { - Add(new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.35f - }, - new Container - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.5f - }, - new Container - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding(20), - Child = new OsuSpriteText { Text = "Design screen" } - } - } - } - } - }); + Child = new ScreenWhiteBox.UnderConstructionMessage("Design mode"); } } } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 8cc227d9be..85467d3bbb 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -2,37 +2,53 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK.Graphics; -using osu.Framework.Screens; -using osu.Game.Screens.Backgrounds; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework; +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.Screens.Edit.Components.Timelines.Summary; -using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; +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; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.IO.Serialization; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Menus; +using osu.Game.Screens.Edit.Components.Timelines.Summary; using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Design; -using osuTK.Input; -using System.Collections.Generic; -using osu.Framework; -using osu.Framework.Input.Bindings; -using osu.Game.Input.Bindings; +using osu.Game.Screens.Edit.Setup; +using osu.Game.Screens.Edit.Timing; +using osu.Game.Screens.Play; using osu.Game.Users; +using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Screens.Edit { - public class Editor : OsuScreen, IKeyBindingHandler<GlobalAction> + [Cached(typeof(IBeatSnapProvider))] + [Cached(typeof(ISamplePlaybackDisabler))] + [Cached] + public class Editor : ScreenWithBeatmapBackground, IKeyBindingHandler<GlobalAction>, IKeyBindingHandler<PlatformAction>, IBeatSnapProvider, ISamplePlaybackDisabler { - protected override BackgroundScreen CreateBackground() => new BackgroundScreenCustom(@"Backgrounds/bg4"); + public override float BackgroundParallaxAmount => 0.1f; public override bool AllowBackButton => false; @@ -40,137 +56,305 @@ namespace osu.Game.Screens.Edit public override bool DisallowExternalBeatmapRulesetChanges => true; + public override bool AllowRateAdjustments => false; + + protected bool HasUnsavedChanges => lastSavedHash != changeHandler.CurrentStateHash; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + [Resolved(canBeNull: true)] + private DialogOverlay dialogOverlay { get; set; } + + public IBindable<bool> SamplePlaybackDisabled => samplePlaybackDisabled; + + private readonly Bindable<bool> samplePlaybackDisabled = new Bindable<bool>(); + + private bool exitConfirmed; + + private string lastSavedHash; + private Box bottomBackground; - private Container screenContainer; + private Container<EditorScreen> screenContainer; private EditorScreen currentScreen; private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); - private EditorClock clock; + private IBeatmap playableBeatmap; + private EditorBeatmap editorBeatmap; + private EditorChangeHandler changeHandler; + + private EditorMenuBar menuBar; + private DependencyContainer dependencies; - private GameHost host; + + private bool isNewBeatmap; protected override UserActivity InitialActivity => new UserActivity.Editing(Beatmap.Value.BeatmapInfo); protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private MusicController music { get; set; } + [BackgroundDependencyLoader] - private void load(OsuColour colours, GameHost host) + private void load(OsuColour colours, GameHost host, OsuConfigManager config) { - this.host = host; + 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. - var sourceClock = (IAdjustableClock)Beatmap.Value.Track ?? new StopwatchClock(); + // Todo: should probably be done at a DrawableRuleset level to share logic with Player. clock = new EditorClock(Beatmap.Value, beatDivisor) { IsCoupled = false }; - clock.ChangeSource(sourceClock); - dependencies.CacheAs<IFrameBasedClock>(clock); - dependencies.CacheAs<IAdjustableClock>(clock); + UpdateClockSource(); + + dependencies.CacheAs(clock); + AddInternal(clock); + + clock.SeekingOrStopped.BindValueChanged(_ => updateSampleDisabledState()); + + // todo: remove caching of this and consume via editorBeatmap? dependencies.Cache(beatDivisor); - EditorMenuBar menuBar; - - var fileMenuItems = new List<MenuItem>(); - - if (RuntimeInfo.IsDesktop) + if (Beatmap.Value is DummyWorkingBeatmap) { - fileMenuItems.Add(new EditorMenuItem("Export", MenuItemType.Standard, exportBeatmap)); - fileMenuItems.Add(new EditorMenuItemSpacer()); + isNewBeatmap = true; + Beatmap.Value = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value); } + try + { + playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset); + } + catch (Exception e) + { + Logger.Error(e, "Could not load beatmap successfully!"); + // couldn't load, hard abort! + this.Exit(); + return; + } + + AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, Beatmap.Value.Skin)); + dependencies.CacheAs(editorBeatmap); + changeHandler = new EditorChangeHandler(editorBeatmap); + dependencies.CacheAs<IEditorChangeHandler>(changeHandler); + + updateLastSavedHash(); + + OsuMenuItem undoMenuItem; + OsuMenuItem redoMenuItem; + + EditorMenuItem cutMenuItem; + EditorMenuItem copyMenuItem; + EditorMenuItem pasteMenuItem; + + var fileMenuItems = new List<MenuItem> + { + 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)); - InternalChildren = new[] + AddInternal(new OsuContextMenuContainer { - new Container + RelativeSizeAxes = Axes.Both, + Children = new[] { - Name = "Screen container", - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 40, Bottom = 60 }, - Child = screenContainer = new Container + new Container { + Name = "Screen container", RelativeSizeAxes = Axes.Both, - Masking = true - } - }, - new Container - { - Name = "Top bar", - RelativeSizeAxes = Axes.X, - Height = 40, - Child = menuBar = new EditorMenuBar - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - Items = new[] - { - new MenuItem("File") - { - Items = fileMenuItems - } - } - } - }, - new Container - { - Name = "Bottom bar", - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = 60, - Children = new Drawable[] - { - bottomBackground = new Box { RelativeSizeAxes = Axes.Both }, - new Container + Padding = new MarginPadding { Top = 40, Bottom = 60 }, + Child = screenContainer = new Container<EditorScreen> { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Vertical = 5, Horizontal = 10 }, - Child = new GridContainer + Masking = true + } + }, + new Container + { + Name = "Top bar", + RelativeSizeAxes = Axes.X, + Height = 40, + Child = menuBar = new EditorMenuBar + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Mode = { Value = isNewBeatmap ? EditorScreenMode.SongSetup : EditorScreenMode.Compose }, + Items = new[] + { + new MenuItem("File") + { + Items = fileMenuItems + }, + new MenuItem("Edit") + { + Items = new[] + { + undoMenuItem = new EditorMenuItem("Undo", MenuItemType.Standard, Undo), + redoMenuItem = new EditorMenuItem("Redo", MenuItemType.Standard, Redo), + new EditorMenuItemSpacer(), + cutMenuItem = new EditorMenuItem("Cut", MenuItemType.Standard, Cut), + copyMenuItem = new EditorMenuItem("Copy", MenuItemType.Standard, Copy), + pasteMenuItem = new EditorMenuItem("Paste", MenuItemType.Standard, Paste), + } + }, + new MenuItem("View") + { + Items = new[] + { + new WaveformOpacityMenu(config) + } + } + } + } + }, + new Container + { + Name = "Bottom bar", + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = 60, + Children = new Drawable[] + { + bottomBackground = new Box { RelativeSizeAxes = Axes.Both }, + new Container { RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] + Padding = new MarginPadding { Vertical = 5, Horizontal = 10 }, + Child = new GridContainer { - new Dimension(GridSizeMode.Absolute, 220), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 220) - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 10 }, - Child = new TimeInfoContainer { RelativeSizeAxes = Axes.Both }, - }, - new SummaryTimeline - { - RelativeSizeAxes = Axes.Both, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = 10 }, - Child = new PlaybackControl { RelativeSizeAxes = Axes.Both }, - } + new Dimension(GridSizeMode.Absolute, 220), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 220) }, - } - }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = 10 }, + Child = new TimeInfoContainer { RelativeSizeAxes = Axes.Both }, + }, + new SummaryTimeline + { + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 10 }, + Child = new PlaybackControl { RelativeSizeAxes = Axes.Both }, + } + }, + } + }, + } } - } - }, - }; + }, + } + }); + + changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); + changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); + + editorBeatmap.SelectedHitObjects.BindCollectionChanged((_, __) => + { + var hasObjects = editorBeatmap.SelectedHitObjects.Count > 0; + + cutMenuItem.Action.Disabled = !hasObjects; + copyMenuItem.Action.Disabled = !hasObjects; + }, true); + + clipboard.BindValueChanged(content => pasteMenuItem.Action.Disabled = string.IsNullOrEmpty(content.NewValue)); menuBar.Mode.ValueChanged += onModeChanged; bottomBackground.Colour = colours.Gray2; } + /// <summary> + /// If the beatmap's track has changed, this method must be called to keep the editor in a valid state. + /// </summary> + public void UpdateClockSource() + { + var sourceClock = (IAdjustableClock)Beatmap.Value.Track ?? new StopwatchClock(); + clock.ChangeSource(sourceClock); + } + + protected void Save() + { + // no longer new after first user-triggered save. + isNewBeatmap = false; + + // apply any set-level metadata changes. + beatmapManager.Update(playableBeatmap.BeatmapInfo.BeatmapSet); + + // save the loaded beatmap's data stream. + beatmapManager.Save(playableBeatmap.BeatmapInfo, editorBeatmap, editorBeatmap.BeatmapSkin); + + updateLastSavedHash(); + } + + protected override void Update() + { + base.Update(); + clock.ProcessFrame(); + } + + public bool OnPressed(PlatformAction action) + { + switch (action.ActionType) + { + case PlatformActionType.Cut: + Cut(); + return true; + + case PlatformActionType.Copy: + Copy(); + return true; + + case PlatformActionType.Paste: + Paste(); + return true; + + case PlatformActionType.Undo: + Undo(); + return true; + + case PlatformActionType.Redo: + Redo(); + return true; + + case PlatformActionType.Save: + Save(); + return true; + } + + return false; + } + + public void OnReleased(PlatformAction action) + { + } + protected override bool OnKeyDown(KeyDownEvent e) { switch (e.Key) @@ -191,11 +375,22 @@ namespace osu.Game.Screens.Edit protected override bool OnScroll(ScrollEvent e) { - scrollAccumulation += (e.ScrollDelta.X + e.ScrollDelta.Y) * (e.IsPrecise ? 0.1 : 1); + const double precision = 1; - const int precision = 1; + double scrollComponent = e.ScrollDelta.X + e.ScrollDelta.Y; - while (Math.Abs(scrollAccumulation) > precision) + double scrollDirection = Math.Sign(scrollComponent); + + // this is a special case to handle the "pivot" scenario. + // if we are precise scrolling in one direction then change our mind and scroll backwards, + // the existing accumulation should be applied in the inverse direction to maintain responsiveness. + if (scrollAccumulation != 0 && Math.Sign(scrollAccumulation) != scrollDirection) + scrollAccumulation = scrollDirection * (precision - Math.Abs(scrollAccumulation)); + + scrollAccumulation += scrollComponent * (e.IsPrecise ? 0.1 : 1); + + // because we are doing snapped seeking, we need to add up precise scrolls until they accumulate to an arbitrary cut-off. + while (Math.Abs(scrollAccumulation) >= precision) { if (scrollAccumulation > 0) seek(e, -1); @@ -210,78 +405,265 @@ namespace osu.Game.Screens.Edit public bool OnPressed(GlobalAction action) { - if (action == GlobalAction.Back) + switch (action) { - // as we don't want to display the back button, manual handling of exit action is required. - this.Exit(); - return true; - } + case GlobalAction.Back: + // as we don't want to display the back button, manual handling of exit action is required. + this.Exit(); + return true; - return false; + case GlobalAction.EditorComposeMode: + menuBar.Mode.Value = EditorScreenMode.Compose; + return true; + + case GlobalAction.EditorDesignMode: + menuBar.Mode.Value = EditorScreenMode.Design; + return true; + + case GlobalAction.EditorTimingMode: + menuBar.Mode.Value = EditorScreenMode.Timing; + return true; + + case GlobalAction.EditorSetupMode: + menuBar.Mode.Value = EditorScreenMode.SongSetup; + return true; + + default: + return false; + } } - public bool OnReleased(GlobalAction action) => action == GlobalAction.Back; - - public override void OnResuming(IScreen last) + public void OnReleased(GlobalAction action) { - base.OnResuming(last); - Beatmap.Value.Track?.Stop(); } public override void OnEntering(IScreen last) { base.OnEntering(last); + // todo: temporary. we want to be applying dim using the UserDimContainer eventually. Background.FadeColour(Color4.DarkGray, 500); - resetTrack(); + + Background.EnableUserDim.Value = false; + Background.BlurAmount.Value = 0; + + resetTrack(true); } public override bool OnExiting(IScreen next) { + 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) + { + confirmExit(); + return false; + } + + if (isNewBeatmap || HasUnsavedChanges) + { + dialogOverlay?.Push(new PromptForSaveDialog(() => + { + confirmExit(); + this.Exit(); + }, () => + { + confirmExitWithSave(); + this.Exit(); + })); + + return true; + } + } + Background.FadeColour(Color4.White, 500); resetTrack(); return base.OnExiting(next); } - private void resetTrack() + private void confirmExitWithSave() { - Beatmap.Value.Track?.ResetSpeedAdjustments(); - Beatmap.Value.Track?.Stop(); + exitConfirmed = true; + Save(); } - private void exportBeatmap() => host.OpenFileExternally(Beatmap.Value.Save()); + private void confirmExit() + { + // stop the track if playing to allow the parent screen to choose a suitable playback mode. + Beatmap.Value.Track.Stop(); + + if (isNewBeatmap) + { + // confirming exit without save means we should delete the new beatmap completely. + beatmapManager.Delete(playableBeatmap.BeatmapInfo.BeatmapSet); + + // eagerly clear contents before restoring default beatmap to prevent value change callbacks from firing. + ClearInternal(); + + // in theory this shouldn't be required but due to EF core not sharing instance states 100% + // MusicController is unaware of the changed DeletePending state. + Beatmap.SetDefault(); + } + + exitConfirmed = true; + } + + private readonly Bindable<string> clipboard = new Bindable<string>(); + + protected void Cut() + { + Copy(); + editorBeatmap.RemoveRange(editorBeatmap.SelectedHitObjects.ToArray()); + } + + protected void Copy() + { + if (editorBeatmap.SelectedHitObjects.Count == 0) + return; + + clipboard.Value = new ClipboardContent(editorBeatmap).Serialize(); + } + + protected void Paste() + { + if (string.IsNullOrEmpty(clipboard.Value)) + return; + + var objects = clipboard.Value.Deserialize<ClipboardContent>().HitObjects; + + Debug.Assert(objects.Any()); + + double timeOffset = clock.CurrentTime - objects.Min(o => o.StartTime); + + foreach (var h in objects) + h.StartTime += timeOffset; + + editorBeatmap.BeginChange(); + + editorBeatmap.SelectedHitObjects.Clear(); + + editorBeatmap.AddRange(objects); + editorBeatmap.SelectedHitObjects.AddRange(objects); + + editorBeatmap.EndChange(); + } + + protected void Undo() => changeHandler.RestoreState(-1); + + protected void Redo() => changeHandler.RestoreState(1); + + private void resetTrack(bool seekToStart = false) + { + Beatmap.Value.Track?.Stop(); + + if (seekToStart) + { + double targetTime = 0; + + if (Beatmap.Value.Beatmap.HitObjects.Count > 0) + { + // seek to one beat length before the first hitobject + targetTime = Beatmap.Value.Beatmap.HitObjects[0].StartTime; + targetTime -= Beatmap.Value.Beatmap.ControlPointInfo.TimingPointAt(targetTime).BeatLength; + } + + clock.Seek(Math.Max(0, targetTime)); + } + } private void onModeChanged(ValueChangedEvent<EditorScreenMode> e) { - currentScreen?.Exit(); + var lastScreen = currentScreen; - switch (e.NewValue) + lastScreen? + .ScaleTo(0.98f, 200, Easing.OutQuint) + .FadeOut(200, Easing.OutQuint); + + try { - case EditorScreenMode.Compose: - currentScreen = new ComposeScreen(); - break; + if ((currentScreen = screenContainer.SingleOrDefault(s => s.Type == e.NewValue)) != null) + { + screenContainer.ChangeChildDepth(currentScreen, lastScreen?.Depth + 1 ?? 0); - case EditorScreenMode.Design: - currentScreen = new DesignScreen(); - break; + currentScreen + .ScaleTo(1, 200, Easing.OutQuint) + .FadeIn(200, Easing.OutQuint); + return; + } - default: - currentScreen = new EditorScreen(); - break; + switch (e.NewValue) + { + case EditorScreenMode.SongSetup: + currentScreen = new SetupScreen(); + break; + + case EditorScreenMode.Compose: + currentScreen = new ComposeScreen(); + break; + + case EditorScreenMode.Design: + currentScreen = new DesignScreen(); + break; + + case EditorScreenMode.Timing: + currentScreen = new TimingScreen(); + break; + } + + LoadComponentAsync(currentScreen, newScreen => + { + if (newScreen == currentScreen) + screenContainer.Add(newScreen); + }); } + finally + { + updateSampleDisabledState(); + } + } - LoadComponentAsync(currentScreen, screenContainer.Add); + private void updateSampleDisabledState() + { + samplePlaybackDisabled.Value = clock.SeekingOrStopped.Value || !(currentScreen is ComposeScreen); } private void seek(UIEvent e, int direction) { - double amount = e.ShiftPressed ? 2 : 1; + double amount = e.ShiftPressed ? 4 : 1; + + bool trackPlaying = clock.IsRunning; + + if (trackPlaying) + { + // generally users are not looking to perform tiny seeks when the track is playing, + // so seeks should always be by one full beat, bypassing the beatDivisor. + // this multiplication undoes the division that will be applied in the underlying seek operation. + amount *= beatDivisor.Value; + } if (direction < 1) - clock.SeekBackward(!clock.IsRunning, amount); + clock.SeekBackward(!trackPlaying, amount); else - clock.SeekForward(!clock.IsRunning, amount); + clock.SeekForward(!trackPlaying, amount); } + + private void exportBeatmap() + { + Save(); + beatmapManager.Export(Beatmap.Value.BeatmapSetInfo); + } + + private void updateLastSavedHash() + { + lastSavedHash = changeHandler.CurrentStateHash; + } + + public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime); + + public double GetBeatLengthAtTime(double referenceTime) => editorBeatmap.GetBeatLengthAtTime(referenceTime); + + public int BeatDivisor => beatDivisor.Value; } } diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs new file mode 100644 index 0000000000..165d2ba278 --- /dev/null +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -0,0 +1,293 @@ +// 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. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Edit +{ + public class EditorBeatmap : TransactionalCommitComponent, IBeatmap, IBeatSnapProvider + { + /// <summary> + /// Invoked when a <see cref="HitObject"/> is added to this <see cref="EditorBeatmap"/>. + /// </summary> + public event Action<HitObject> HitObjectAdded; + + /// <summary> + /// Invoked when a <see cref="HitObject"/> is removed from this <see cref="EditorBeatmap"/>. + /// </summary> + public event Action<HitObject> HitObjectRemoved; + + /// <summary> + /// Invoked when a <see cref="HitObject"/> is updated. + /// </summary> + public event Action<HitObject> HitObjectUpdated; + + /// <summary> + /// All currently selected <see cref="HitObject"/>s. + /// </summary> + public readonly BindableList<HitObject> SelectedHitObjects = new BindableList<HitObject>(); + + /// <summary> + /// The current placement. Null if there's no active placement. + /// </summary> + public readonly Bindable<HitObject> PlacementObject = new Bindable<HitObject>(); + + public readonly IBeatmap PlayableBeatmap; + + public readonly ISkin BeatmapSkin; + + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } + + private readonly IBeatmapProcessor beatmapProcessor; + + private readonly Dictionary<HitObject, Bindable<double>> startTimeBindables = new Dictionary<HitObject, Bindable<double>>(); + + public EditorBeatmap(IBeatmap playableBeatmap, ISkin beatmapSkin = null) + { + PlayableBeatmap = playableBeatmap; + BeatmapSkin = beatmapSkin; + + beatmapProcessor = playableBeatmap.BeatmapInfo.Ruleset?.CreateInstance().CreateBeatmapProcessor(PlayableBeatmap); + + foreach (var obj in HitObjects) + trackStartTime(obj); + } + + public BeatmapInfo BeatmapInfo + { + get => PlayableBeatmap.BeatmapInfo; + set => PlayableBeatmap.BeatmapInfo = value; + } + + public BeatmapMetadata Metadata => PlayableBeatmap.Metadata; + + public ControlPointInfo ControlPointInfo => PlayableBeatmap.ControlPointInfo; + + public List<BreakPeriod> Breaks => PlayableBeatmap.Breaks; + + public double TotalBreakTime => PlayableBeatmap.TotalBreakTime; + + public IReadOnlyList<HitObject> HitObjects => PlayableBeatmap.HitObjects; + + public IEnumerable<BeatmapStatistic> GetStatistics() => PlayableBeatmap.GetStatistics(); + + public IBeatmap Clone() => (EditorBeatmap)MemberwiseClone(); + + private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects; + + private readonly List<HitObject> batchPendingInserts = new List<HitObject>(); + + private readonly List<HitObject> batchPendingDeletes = new List<HitObject>(); + + private readonly HashSet<HitObject> batchPendingUpdates = new HashSet<HitObject>(); + + /// <summary> + /// Adds a collection of <see cref="HitObject"/>s to this <see cref="EditorBeatmap"/>. + /// </summary> + /// <param name="hitObjects">The <see cref="HitObject"/>s to add.</param> + public void AddRange(IEnumerable<HitObject> hitObjects) + { + BeginChange(); + foreach (var h in hitObjects) + Add(h); + EndChange(); + } + + /// <summary> + /// Adds a <see cref="HitObject"/> to this <see cref="EditorBeatmap"/>. + /// </summary> + /// <param name="hitObject">The <see cref="HitObject"/> to add.</param> + public void Add(HitObject hitObject) + { + // Preserve existing sorting order in the beatmap + var insertionIndex = findInsertionIndex(PlayableBeatmap.HitObjects, hitObject.StartTime); + Insert(insertionIndex + 1, hitObject); + } + + /// <summary> + /// Inserts a <see cref="HitObject"/> into this <see cref="EditorBeatmap"/>. + /// </summary> + /// <remarks> + /// It is the invoker's responsibility to make sure that <see cref="HitObject"/> sorting order is maintained. + /// </remarks> + /// <param name="index">The index to insert the <see cref="HitObject"/> at.</param> + /// <param name="hitObject">The <see cref="HitObject"/> to insert.</param> + public void Insert(int index, HitObject hitObject) + { + trackStartTime(hitObject); + + mutableHitObjects.Insert(index, hitObject); + + BeginChange(); + batchPendingInserts.Add(hitObject); + EndChange(); + } + + /// <summary> + /// Updates a <see cref="HitObject"/>, invoking <see cref="HitObject.ApplyDefaults"/> and re-processing the beatmap. + /// </summary> + /// <param name="hitObject">The <see cref="HitObject"/> to update.</param> + public void Update([NotNull] HitObject hitObject) + { + // updates are debounced regardless of whether a batch is active. + batchPendingUpdates.Add(hitObject); + } + + /// <summary> + /// Update all hit objects with potentially changed difficulty or control point data. + /// </summary> + public void UpdateAllHitObjects() + { + foreach (var h in HitObjects) + batchPendingUpdates.Add(h); + } + + /// <summary> + /// Removes a <see cref="HitObject"/> from this <see cref="EditorBeatmap"/>. + /// </summary> + /// <param name="hitObject">The <see cref="HitObject"/> to remove.</param> + /// <returns>True if the <see cref="HitObject"/> has been removed, false otherwise.</returns> + public bool Remove(HitObject hitObject) + { + int index = FindIndex(hitObject); + + if (index == -1) + return false; + + RemoveAt(index); + return true; + } + + /// <summary> + /// Removes a collection of <see cref="HitObject"/>s to this <see cref="EditorBeatmap"/>. + /// </summary> + /// <param name="hitObjects">The <see cref="HitObject"/>s to remove.</param> + public void RemoveRange(IEnumerable<HitObject> hitObjects) + { + BeginChange(); + foreach (var h in hitObjects) + Remove(h); + EndChange(); + } + + /// <summary> + /// Finds the index of a <see cref="HitObject"/> in this <see cref="EditorBeatmap"/>. + /// </summary> + /// <param name="hitObject">The <see cref="HitObject"/> to search for.</param> + /// <returns>The index of <paramref name="hitObject"/>.</returns> + public int FindIndex(HitObject hitObject) => mutableHitObjects.IndexOf(hitObject); + + /// <summary> + /// Removes a <see cref="HitObject"/> at an index in this <see cref="EditorBeatmap"/>. + /// </summary> + /// <param name="index">The index of the <see cref="HitObject"/> to remove.</param> + public void RemoveAt(int index) + { + var hitObject = (HitObject)mutableHitObjects[index]; + + mutableHitObjects.RemoveAt(index); + + var bindable = startTimeBindables[hitObject]; + bindable.UnbindAll(); + startTimeBindables.Remove(hitObject); + + BeginChange(); + batchPendingDeletes.Add(hitObject); + EndChange(); + } + + protected override void Update() + { + base.Update(); + + if (batchPendingUpdates.Count > 0) + UpdateState(); + } + + protected override void UpdateState() + { + if (batchPendingUpdates.Count == 0 && batchPendingDeletes.Count == 0 && batchPendingInserts.Count == 0) + return; + + beatmapProcessor?.PreProcess(); + + foreach (var h in batchPendingDeletes) processHitObject(h); + foreach (var h in batchPendingInserts) processHitObject(h); + foreach (var h in batchPendingUpdates) processHitObject(h); + + beatmapProcessor?.PostProcess(); + + // callbacks may modify the lists so let's be safe about it + var deletes = batchPendingDeletes.ToArray(); + batchPendingDeletes.Clear(); + + var inserts = batchPendingInserts.ToArray(); + batchPendingInserts.Clear(); + + var updates = batchPendingUpdates.ToArray(); + batchPendingUpdates.Clear(); + + foreach (var h in deletes) HitObjectRemoved?.Invoke(h); + foreach (var h in inserts) HitObjectAdded?.Invoke(h); + foreach (var h in updates) HitObjectUpdated?.Invoke(h); + } + + /// <summary> + /// Clears all <see cref="HitObjects"/> from this <see cref="EditorBeatmap"/>. + /// </summary> + public void Clear() => RemoveRange(HitObjects.ToArray()); + + private void processHitObject(HitObject hitObject) => hitObject.ApplyDefaults(ControlPointInfo, BeatmapInfo.BaseDifficulty); + + private void trackStartTime(HitObject hitObject) + { + startTimeBindables[hitObject] = hitObject.StartTimeBindable.GetBoundCopy(); + startTimeBindables[hitObject].ValueChanged += _ => + { + // For now we'll remove and re-add the hitobject. This is not optimal and can be improved if required. + mutableHitObjects.Remove(hitObject); + + var insertionIndex = findInsertionIndex(PlayableBeatmap.HitObjects, hitObject.StartTime); + mutableHitObjects.Insert(insertionIndex + 1, hitObject); + + Update(hitObject); + }; + } + + private int findInsertionIndex(IReadOnlyList<HitObject> list, double startTime) + { + for (int i = 0; i < list.Count; i++) + { + if (list[i].StartTime > startTime) + return i - 1; + } + + 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 GetBeatLengthAtTime(double referenceTime) => ControlPointInfo.TimingPointAt(referenceTime).BeatLength / BeatDivisor; + + public int BeatDivisor => beatDivisor?.Value ?? 1; + } +} diff --git a/osu.Game/Screens/Edit/EditorChangeHandler.cs b/osu.Game/Screens/Edit/EditorChangeHandler.cs new file mode 100644 index 0000000000..62187aed24 --- /dev/null +++ b/osu.Game/Screens/Edit/EditorChangeHandler.cs @@ -0,0 +1,129 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Game.Beatmaps.Formats; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Edit +{ + /// <summary> + /// Tracks changes to the <see cref="Editor"/>. + /// </summary> + public class EditorChangeHandler : TransactionalCommitComponent, IEditorChangeHandler + { + public readonly Bindable<bool> CanUndo = new Bindable<bool>(); + public readonly Bindable<bool> CanRedo = new Bindable<bool>(); + + public event Action OnStateChange; + + private readonly LegacyEditorBeatmapPatcher patcher; + private readonly List<byte[]> savedStates = new List<byte[]>(); + + private int currentState = -1; + + /// <summary> + /// A SHA-2 hash representing the current visible editor state. + /// </summary> + public string CurrentStateHash + { + get + { + using (var stream = new MemoryStream(savedStates[currentState])) + return stream.ComputeSHA2Hash(); + } + } + + private readonly EditorBeatmap editorBeatmap; + private bool isRestoring; + + public const int MAX_SAVED_STATES = 50; + + /// <summary> + /// Creates a new <see cref="EditorChangeHandler"/>. + /// </summary> + /// <param name="editorBeatmap">The <see cref="EditorBeatmap"/> to track the <see cref="HitObject"/>s of.</param> + public EditorChangeHandler(EditorBeatmap editorBeatmap) + { + this.editorBeatmap = editorBeatmap; + + editorBeatmap.TransactionBegan += BeginChange; + editorBeatmap.TransactionEnded += EndChange; + editorBeatmap.SaveStateTriggered += SaveState; + + patcher = new LegacyEditorBeatmapPatcher(editorBeatmap); + + // Initial state. + SaveState(); + } + + protected override void UpdateState() + { + if (isRestoring) + return; + + using (var stream = new MemoryStream()) + { + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + new LegacyBeatmapEncoder(editorBeatmap, editorBeatmap.BeatmapSkin).Encode(sw); + + var newState = stream.ToArray(); + + // if the previous state is binary equal we don't need to push a new one, unless this is the initial state. + if (savedStates.Count > 0 && newState.SequenceEqual(savedStates.Last())) return; + + if (currentState < savedStates.Count - 1) + savedStates.RemoveRange(currentState + 1, savedStates.Count - currentState - 1); + + if (savedStates.Count > MAX_SAVED_STATES) + savedStates.RemoveAt(0); + + savedStates.Add(newState); + + currentState = savedStates.Count - 1; + + OnStateChange?.Invoke(); + updateBindables(); + } + } + + /// <summary> + /// Restores an older or newer state. + /// </summary> + /// <param name="direction">The direction to restore in. If less than 0, an older state will be used. If greater than 0, a newer state will be used.</param> + public void RestoreState(int direction) + { + if (TransactionActive) + return; + + if (savedStates.Count == 0) + return; + + int newState = Math.Clamp(currentState + direction, 0, savedStates.Count - 1); + if (currentState == newState) + return; + + isRestoring = true; + + patcher.Patch(savedStates[currentState], savedStates[newState]); + currentState = newState; + + isRestoring = false; + + OnStateChange?.Invoke(); + updateBindables(); + } + + private void updateBindables() + { + CanUndo.Value = savedStates.Count > 0 && currentState > 0; + CanRedo.Value = currentState < savedStates.Count - 1; + } + } +} diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index 24fb561f04..148eef6c93 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -3,31 +3,41 @@ using System; using System.Linq; -using osu.Framework.MathUtils; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; using osu.Framework.Timing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; -using osuTK; namespace osu.Game.Screens.Edit { /// <summary> /// A decoupled clock which adds editor-specific functionality, such as snapping to a user-defined beat divisor. /// </summary> - public class EditorClock : DecoupleableInterpolatingFramedClock + public class EditorClock : Component, IFrameBasedClock, IAdjustableClock, ISourceChangeableClock { - public readonly double TrackLength; + public IBindable<Track> Track => track; + + private readonly Bindable<Track> track = new Bindable<Track>(); + + public double TrackLength => track.Value?.Length ?? 60000; public ControlPointInfo ControlPointInfo; private readonly BindableBeatDivisor beatDivisor; - public EditorClock(WorkingBeatmap beatmap, BindableBeatDivisor beatDivisor) - { - this.beatDivisor = beatDivisor; + private readonly DecoupleableInterpolatingFramedClock underlyingClock; - ControlPointInfo = beatmap.Beatmap.ControlPointInfo; - TrackLength = beatmap.Track.Length; + public IBindable<bool> SeekingOrStopped => seekingOrStopped; + + private readonly Bindable<bool> seekingOrStopped = new Bindable<bool>(true); + + public EditorClock(WorkingBeatmap beatmap, BindableBeatDivisor beatDivisor) + : this(beatmap.Beatmap.ControlPointInfo, beatmap.Track.Length, beatDivisor) + { } public EditorClock(ControlPointInfo controlPointInfo, double trackLength, BindableBeatDivisor beatDivisor) @@ -35,7 +45,13 @@ namespace osu.Game.Screens.Edit this.beatDivisor = beatDivisor; ControlPointInfo = controlPointInfo; - TrackLength = trackLength; + + underlyingClock = new DecoupleableInterpolatingFramedClock(); + } + + public EditorClock() + : this(new ControlPointInfo(), 1000, new BindableBeatDivisor()) + { } /// <summary> @@ -57,7 +73,7 @@ namespace osu.Game.Screens.Edit // Depending on beatSnapLength, we may snap to a beat that is beyond timingPoint's end time, but we want to instead snap to // the next timing point's start time - var nextTimingPoint = ControlPointInfo.TimingPoints.Find(t => t.Time > timingPoint.Time); + var nextTimingPoint = ControlPointInfo.TimingPoints.FirstOrDefault(t => t.Time > timingPoint.Time); if (position > nextTimingPoint?.Time) position = nextTimingPoint.Time; @@ -69,7 +85,7 @@ namespace osu.Game.Screens.Edit /// </summary> /// <param name="snapped">Whether to snap to the closest beat after seeking.</param> /// <param name="amount">The relative amount (magnitude) which should be seeked.</param> - public void SeekBackward(bool snapped = false, double amount = 1) => seek(-1, snapped, amount); + public void SeekBackward(bool snapped = false, double amount = 1) => seek(-1, snapped, amount + (IsRunning ? 1.5 : 0)); /// <summary> /// Seeks forwards by one beat length. @@ -80,24 +96,22 @@ namespace osu.Game.Screens.Edit private void seek(int direction, bool snapped, double amount = 1) { + double current = CurrentTimeAccurate; + if (amount <= 0) throw new ArgumentException("Value should be greater than zero", nameof(amount)); - var timingPoint = ControlPointInfo.TimingPointAt(CurrentTime); + var timingPoint = ControlPointInfo.TimingPointAt(current); - if (direction < 0 && timingPoint.Time == CurrentTime) - { + if (direction < 0 && timingPoint.Time == current) // When going backwards and we're at the boundary of two timing points, we compute the seek distance with the timing point which we are seeking into - int activeIndex = ControlPointInfo.TimingPoints.IndexOf(timingPoint); - while (activeIndex > 0 && CurrentTime == timingPoint.Time) - timingPoint = ControlPointInfo.TimingPoints[--activeIndex]; - } + timingPoint = ControlPointInfo.TimingPointAt(current - 1); double seekAmount = timingPoint.BeatLength / beatDivisor.Value * amount; - double seekTime = CurrentTime + seekAmount * direction; + double seekTime = current + seekAmount * direction; if (!snapped || ControlPointInfo.TimingPoints.Count == 0) { - Seek(seekTime); + SeekTo(seekTime); return; } @@ -113,9 +127,14 @@ namespace osu.Game.Screens.Edit seekTime = timingPoint.Time + closestBeat * seekAmount; + // limit forward seeking to only up to the next timing point's start time. + var nextTimingPoint = ControlPointInfo.TimingPoints.FirstOrDefault(t => t.Time > timingPoint.Time); + if (seekTime > nextTimingPoint?.Time) + seekTime = nextTimingPoint.Time; + // Due to the rounding above, we may end up on the current beat. This will effectively cause 0 seeking to happen, but we don't want this. // Instead, we'll go to the next beat in the direction when this is the case - if (Precision.AlmostEquals(CurrentTime, seekTime)) + if (Precision.AlmostEquals(current, seekTime, 0.5f)) { closestBeat += direction > 0 ? 1 : -1; seekTime = timingPoint.Time + closestBeat * seekAmount; @@ -124,13 +143,140 @@ namespace osu.Game.Screens.Edit if (seekTime < timingPoint.Time && timingPoint != ControlPointInfo.TimingPoints.First()) seekTime = timingPoint.Time; - var nextTimingPoint = ControlPointInfo.TimingPoints.Find(t => t.Time > timingPoint.Time); - if (seekTime > nextTimingPoint?.Time) - seekTime = nextTimingPoint.Time; - // Ensure the sought point is within the boundaries - seekTime = MathHelper.Clamp(seekTime, 0, TrackLength); - Seek(seekTime); + seekTime = Math.Clamp(seekTime, 0, TrackLength); + SeekTo(seekTime); + } + + /// <summary> + /// The current time of this clock, include any active transform seeks performed via <see cref="SeekTo"/>. + /// </summary> + public double CurrentTimeAccurate => + Transforms.OfType<TransformSeek>().FirstOrDefault()?.EndValue ?? CurrentTime; + + public double CurrentTime => underlyingClock.CurrentTime; + + public void Reset() + { + ClearTransforms(); + underlyingClock.Reset(); + } + + public void Start() + { + ClearTransforms(); + underlyingClock.Start(); + } + + public void Stop() + { + seekingOrStopped.Value = true; + underlyingClock.Stop(); + } + + public bool Seek(double position) + { + seekingOrStopped.Value = true; + + ClearTransforms(); + return underlyingClock.Seek(position); + } + + public void ResetSpeedAdjustments() => underlyingClock.ResetSpeedAdjustments(); + + double IAdjustableClock.Rate + { + get => underlyingClock.Rate; + set => underlyingClock.Rate = value; + } + + double IClock.Rate => underlyingClock.Rate; + + public bool IsRunning => underlyingClock.IsRunning; + + public void ProcessFrame() => underlyingClock.ProcessFrame(); + + public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; + + public double FramesPerSecond => underlyingClock.FramesPerSecond; + + public FrameTimeInfo TimeInfo => underlyingClock.TimeInfo; + + public void ChangeSource(IClock source) + { + track.Value = source as Track; + underlyingClock.ChangeSource(source); + } + + public IClock Source => underlyingClock.Source; + + public bool IsCoupled + { + get => underlyingClock.IsCoupled; + set => underlyingClock.IsCoupled = value; + } + + private const double transform_time = 300; + + protected override void Update() + { + base.Update(); + + updateSeekingState(); + } + + private void updateSeekingState() + { + if (seekingOrStopped.Value) + { + if (track.Value?.IsRunning != true) + { + // seeking in the editor can happen while the track isn't running. + // in this case we always want to expose ourselves as seeking (to avoid sample playback). + return; + } + + // we are either running a seek tween or doing an immediate seek. + // in the case of an immediate seek the seeking bool will be set to false after one update. + // this allows for silencing hit sounds and the likes. + seekingOrStopped.Value = Transforms.Any(); + } + } + + public void SeekTo(double seekDestination) + { + seekingOrStopped.Value = true; + + if (IsRunning) + Seek(seekDestination); + else + transformSeekTo(seekDestination, transform_time, Easing.OutQuint); + } + + private void transformSeekTo(double seek, double duration = 0, Easing easing = Easing.None) + => this.TransformTo(this.PopulateTransform(new TransformSeek(), seek, duration, easing)); + + private double currentTime + { + get => underlyingClock.CurrentTime; + set => underlyingClock.Seek(value); + } + + private class TransformSeek : Transform<double, EditorClock> + { + public override string TargetMember => nameof(currentTime); + + protected override void Apply(EditorClock clock, double time) => clock.currentTime = valueAt(time); + + private double valueAt(double time) + { + if (time < StartTime) return StartValue; + if (time >= EndTime) return EndValue; + + return Interpolation.ValueAt(time, StartValue, EndValue, StartTime, EndTime, Easing); + } + + protected override void ReadIntoStartValue(EditorClock clock) => StartValue = clock.currentTime; } } } diff --git a/osu.Game/Screens/Edit/EditorScreen.cs b/osu.Game/Screens/Edit/EditorScreen.cs index 045e5a1226..4d62a7d3cd 100644 --- a/osu.Game/Screens/Edit/EditorScreen.cs +++ b/osu.Game/Screens/Edit/EditorScreen.cs @@ -10,17 +10,25 @@ using osu.Game.Beatmaps; namespace osu.Game.Screens.Edit { /// <summary> - /// TODO: eventually make this inherit Screen and add a local scren stack inside the Editor. + /// TODO: eventually make this inherit Screen and add a local screen stack inside the Editor. /// </summary> - public class EditorScreen : Container + public abstract class EditorScreen : Container { - protected readonly IBindable<WorkingBeatmap> Beatmap = new Bindable<WorkingBeatmap>(); + [Resolved] + protected IBindable<WorkingBeatmap> Beatmap { get; private set; } + + [Resolved] + protected EditorBeatmap EditorBeatmap { get; private set; } protected override Container<Drawable> Content => content; private readonly Container content; - public EditorScreen() + public readonly EditorScreenMode Type; + + protected EditorScreen(EditorScreenMode type) { + Type = type; + Anchor = Anchor.Centre; Origin = Anchor.Centre; RelativeSizeAxes = Axes.Both; @@ -28,12 +36,6 @@ namespace osu.Game.Screens.Edit InternalChild = content = new Container { RelativeSizeAxes = Axes.Both }; } - [BackgroundDependencyLoader] - private void load(IBindable<WorkingBeatmap> beatmap) - { - Beatmap.BindTo(beatmap); - } - protected override void LoadComplete() { base.LoadComplete(); @@ -42,10 +44,5 @@ namespace osu.Game.Screens.Edit .Then() .FadeTo(1f, 250, Easing.OutQuint); } - - public void Exit() - { - this.FadeOut(250).Expire(); - } } } diff --git a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs new file mode 100644 index 0000000000..b9457f422a --- /dev/null +++ b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs @@ -0,0 +1,133 @@ +// 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. + +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit +{ + public abstract class EditorScreenWithTimeline : EditorScreen + { + 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; + + protected EditorScreenWithTimeline(EditorScreenMode type) + : base(type) + { + } + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] BindableBeatDivisor beatDivisor) + { + if (beatDivisor != null) + this.beatDivisor.BindTo(beatDivisor); + + Container mainContent; + + LoadingSpinner spinner; + + Children = new Drawable[] + { + mainContent = new Container + { + 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 Container + { + Name = "Timeline", + RelativeSizeAxes = Axes.X, + Height = timeline_height, + Children = 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 + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + timelineContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = 5 }, + }, + new BeatDivisorControl(beatDivisor) { RelativeSizeAxes = Axes.Both } + }, + }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 90), + } + }, + } + } + }, + }; + + LoadComponentAsync(CreateMainContent(), content => + { + spinner.State.Value = Visibility.Hidden; + + mainContent.Add(content); + content.FadeInFromZero(300, Easing.OutQuint); + + LoadComponentAsync(new TimelineArea + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + CreateTimelineContent(), + } + }, t => + { + timelineContainer.Add(t); + OnTimelineLoaded(t); + }); + }); + } + + protected virtual void OnTimelineLoaded(TimelineArea timelineArea) + { + } + + protected abstract Drawable CreateMainContent(); + + protected virtual Drawable CreateTimelineContent() => new Container(); + } +} diff --git a/osu.Game/Screens/Edit/IEditorChangeHandler.cs b/osu.Game/Screens/Edit/IEditorChangeHandler.cs new file mode 100644 index 0000000000..0283421b8c --- /dev/null +++ b/osu.Game/Screens/Edit/IEditorChangeHandler.cs @@ -0,0 +1,45 @@ +// 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. + +using System; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Edit +{ + /// <summary> + /// Interface for a component that manages changes in the <see cref="Editor"/>. + /// </summary> + public interface IEditorChangeHandler + { + /// <summary> + /// Fired whenever a state change occurs. + /// </summary> + event Action OnStateChange; + + /// <summary> + /// Begins a bulk state change event. <see cref="EndChange"/> should be invoked soon after. + /// </summary> + /// <remarks> + /// This should be invoked when multiple changes to the <see cref="Editor"/> should be bundled together into one state change event. + /// When nested invocations are involved, a state change will not occur until an equal number of invocations of <see cref="EndChange"/> are received. + /// </remarks> + /// <example> + /// When a group of <see cref="HitObject"/>s are deleted, a single undo and redo state change should update the state of all <see cref="HitObject"/>. + /// </example> + void BeginChange(); + + /// <summary> + /// Ends a bulk state change event. + /// </summary> + /// <remarks> + /// This should be invoked as soon as possible after <see cref="BeginChange"/> to cause a state change. + /// </remarks> + void EndChange(); + + /// <summary> + /// Immediately saves the current <see cref="Editor"/> state. + /// Note that this will be a no-op if there is a change in progress via <see cref="BeginChange"/>. + /// </summary> + void SaveState(); + } +} diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs new file mode 100644 index 0000000000..f2e0320ce3 --- /dev/null +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -0,0 +1,121 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using DiffPlex; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps; +using osu.Game.IO; +using Decoder = osu.Game.Beatmaps.Formats.Decoder; + +namespace osu.Game.Screens.Edit +{ + /// <summary> + /// Patches an <see cref="EditorBeatmap"/> based on the difference between two legacy (.osu) states. + /// </summary> + public class LegacyEditorBeatmapPatcher + { + private readonly EditorBeatmap editorBeatmap; + + public LegacyEditorBeatmapPatcher(EditorBeatmap editorBeatmap) + { + this.editorBeatmap = editorBeatmap; + } + + public void Patch(byte[] currentState, byte[] newState) + { + // Diff the beatmaps + var result = new Differ().CreateLineDiffs(readString(currentState), readString(newState), true, false); + + // Find the index of [HitObject] sections. Lines changed prior to this index are ignored. + int oldHitObjectsIndex = Array.IndexOf(result.PiecesOld, "[HitObjects]"); + int newHitObjectsIndex = Array.IndexOf(result.PiecesNew, "[HitObjects]"); + + Debug.Assert(oldHitObjectsIndex >= 0); + Debug.Assert(newHitObjectsIndex >= 0); + + var toRemove = new List<int>(); + var toAdd = new List<int>(); + + foreach (var block in result.DiffBlocks) + { + // Removed hitobjects + for (int i = 0; i < block.DeleteCountA; i++) + { + int hoIndex = block.DeleteStartA + i - oldHitObjectsIndex - 1; + + if (hoIndex < 0) + continue; + + toRemove.Add(hoIndex); + } + + // Added hitobjects + for (int i = 0; i < block.InsertCountB; i++) + { + int hoIndex = block.InsertStartB + i - newHitObjectsIndex - 1; + + if (hoIndex < 0) + continue; + + toAdd.Add(hoIndex); + } + } + + // Sort the indices to ensure that removal + insertion indices don't get jumbled up post-removal or post-insertion. + // This isn't strictly required, but the differ makes no guarantees about order. + toRemove.Sort(); + toAdd.Sort(); + + editorBeatmap.BeginChange(); + + // Apply the changes. + for (int i = toRemove.Count - 1; i >= 0; i--) + editorBeatmap.RemoveAt(toRemove[i]); + + if (toAdd.Count > 0) + { + IBeatmap newBeatmap = readBeatmap(newState); + foreach (var i in toAdd) + editorBeatmap.Insert(i, newBeatmap.HitObjects[i]); + } + + editorBeatmap.EndChange(); + } + + private string readString(byte[] state) => Encoding.UTF8.GetString(state); + + private IBeatmap readBeatmap(byte[] state) + { + using (var stream = new MemoryStream(state)) + using (var reader = new LineBufferedReader(stream, true)) + { + var decoded = Decoder.GetDecoder<Beatmap>(reader).Decode(reader); + decoded.BeatmapInfo.Ruleset = editorBeatmap.BeatmapInfo.Ruleset; + return new PassThroughWorkingBeatmap(decoded).GetPlayableBeatmap(editorBeatmap.BeatmapInfo.Ruleset); + } + } + + private class PassThroughWorkingBeatmap : WorkingBeatmap + { + private readonly IBeatmap beatmap; + + public PassThroughWorkingBeatmap(IBeatmap beatmap) + : base(beatmap.BeatmapInfo, null) + { + this.beatmap = beatmap; + } + + protected override IBeatmap GetBeatmap() => beatmap; + + protected override Texture GetBackground() => throw new NotImplementedException(); + + protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + } + } +} diff --git a/osu.Game/Screens/Edit/PromptForSaveDialog.cs b/osu.Game/Screens/Edit/PromptForSaveDialog.cs new file mode 100644 index 0000000000..16504b47bd --- /dev/null +++ b/osu.Game/Screens/Edit/PromptForSaveDialog.cs @@ -0,0 +1,37 @@ +// 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. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Edit +{ + public class PromptForSaveDialog : PopupDialog + { + public PromptForSaveDialog(Action exit, Action saveAndExit) + { + HeaderText = "Did you want to save your changes?"; + + Icon = FontAwesome.Regular.Save; + + Buttons = new PopupDialogButton[] + { + new PopupDialogCancelButton + { + Text = @"Save my masterpiece!", + Action = saveAndExit + }, + new PopupDialogOkButton + { + Text = @"Forget all changes", + Action = exit + }, + new PopupDialogCancelButton + { + Text = @"Oops, continue editing", + }, + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledTextBox.cs deleted file mode 100644 index 1c53fc7088..0000000000 --- a/osu.Game/Screens/Edit/Setup/Components/LabelledComponents/LabelledTextBox.cs +++ /dev/null @@ -1,129 +0,0 @@ -// 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. - -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; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osuTK.Graphics; - -namespace osu.Game.Screens.Edit.Setup.Components.LabelledComponents -{ - public class LabelledTextBox : CompositeDrawable - { - private const float label_container_width = 150; - private const float corner_radius = 15; - private const float default_height = 40; - private const float default_label_left_padding = 15; - private const float default_label_top_padding = 12; - private const float default_label_text_size = 16; - - public event TextBox.OnCommitHandler OnCommit; - - public bool ReadOnly - { - get => textBox.ReadOnly; - set => textBox.ReadOnly = value; - } - - public string LabelText - { - get => label.Text; - set => label.Text = value; - } - - public float LabelTextSize - { - get => label.Font.Size; - set => label.Font = label.Font.With(size: value); - } - - public string PlaceholderText - { - get => textBox.PlaceholderText; - set => textBox.PlaceholderText = value; - } - - public string Text - { - get => textBox.Text; - set => textBox.Text = value; - } - - public Color4 LabelTextColour - { - get => label.Colour; - set => label.Colour = value; - } - - private readonly OsuTextBox textBox; - private readonly OsuSpriteText label; - - public LabelledTextBox() - { - RelativeSizeAxes = Axes.X; - Height = default_height; - CornerRadius = corner_radius; - Masking = true; - - InternalChild = new Container - { - RelativeSizeAxes = Axes.Both, - CornerRadius = corner_radius, - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex("1c2125"), - }, - new GridContainer - { - RelativeSizeAxes = Axes.X, - Height = default_height, - Content = new[] - { - new Drawable[] - { - label = new OsuSpriteText - { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - Padding = new MarginPadding { Left = default_label_left_padding, Top = default_label_top_padding }, - Colour = Color4.White, - Font = OsuFont.GetFont(size: default_label_text_size, weight: FontWeight.Bold), - }, - textBox = new OsuTextBox - { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - RelativeSizeAxes = Axes.Both, - Height = 1, - CornerRadius = corner_radius, - }, - }, - }, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.Absolute, label_container_width), - new Dimension() - } - } - } - }; - - textBox.OnCommit += OnCommit; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - textBox.BorderColour = colours.Blue; - } - } -} diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs new file mode 100644 index 0000000000..aa1d57db31 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -0,0 +1,99 @@ +// 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. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Screens.Edit.Setup +{ + internal class DifficultySection : SetupSection + { + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } + + private LabelledSliderBar<float> circleSizeSlider; + private LabelledSliderBar<float> healthDrainSlider; + private LabelledSliderBar<float> approachRateSlider; + private LabelledSliderBar<float> overallDifficultySlider; + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Difficulty settings" + }, + circleSizeSlider = new LabelledSliderBar<float> + { + Label = "Object Size", + Description = "The size of all hit objects", + Current = new BindableFloat(Beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 2, + MaxValue = 7, + Precision = 0.1f, + } + }, + healthDrainSlider = new LabelledSliderBar<float> + { + Label = "Health Drain", + Description = "The rate of passive health drain throughout playable time", + Current = new BindableFloat(Beatmap.Value.BeatmapInfo.BaseDifficulty.DrainRate) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + } + }, + approachRateSlider = new LabelledSliderBar<float> + { + Label = "Approach Rate", + Description = "The speed at which objects are presented to the player", + Current = new BindableFloat(Beatmap.Value.BeatmapInfo.BaseDifficulty.ApproachRate) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + } + }, + overallDifficultySlider = new LabelledSliderBar<float> + { + Label = "Overall Difficulty", + Description = "The harshness of hit windows and difficulty of special objects (ie. spinners)", + Current = new BindableFloat(Beatmap.Value.BeatmapInfo.BaseDifficulty.OverallDifficulty) + { + Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, + MinValue = 0, + MaxValue = 10, + Precision = 0.1f, + } + }, + }; + + foreach (var item in Children.OfType<LabelledSliderBar<float>>()) + item.Current.ValueChanged += onValueChanged; + } + + private void onValueChanged(ValueChangedEvent<float> args) + { + // for now, update these on commit rather than making BeatmapMetadata bindables. + // after switching database engines we can reconsider if switching to bindables is a good direction. + Beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize = circleSizeSlider.Current.Value; + Beatmap.Value.BeatmapInfo.BaseDifficulty.DrainRate = healthDrainSlider.Current.Value; + Beatmap.Value.BeatmapInfo.BaseDifficulty.ApproachRate = approachRateSlider.Current.Value; + Beatmap.Value.BeatmapInfo.BaseDifficulty.OverallDifficulty = overallDifficultySlider.Current.Value; + + editorBeatmap.UpdateAllHitObjects(); + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs new file mode 100644 index 0000000000..b802b3405a --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs @@ -0,0 +1,73 @@ +// 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. + +using System; +using System.IO; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Screens.Edit.Setup +{ + /// <summary> + /// A labelled textbox which reveals an inline file chooser when clicked. + /// </summary> + internal class FileChooserLabelledTextBox : LabelledTextBox + { + public Container Target; + + private readonly IBindable<FileInfo> currentFile = new Bindable<FileInfo>(); + + public FileChooserLabelledTextBox() + { + currentFile.BindValueChanged(onFileSelected); + } + + private void onFileSelected(ValueChangedEvent<FileInfo> file) + { + if (file.NewValue == null) + return; + + Target.Clear(); + Current.Value = file.NewValue.FullName; + } + + protected override OsuTextBox CreateTextBox() => + new FileChooserOsuTextBox + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + CornerRadius = CORNER_RADIUS, + OnFocused = DisplayFileChooser + }; + + public void DisplayFileChooser() + { + Target.Child = new FileSelector(validFileExtensions: ResourcesSection.AudioExtensions) + { + RelativeSizeAxes = Axes.X, + Height = 400, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + CurrentFile = { BindTarget = currentFile } + }; + } + + internal class FileChooserOsuTextBox : OsuTextBox + { + public Action OnFocused; + + protected override void OnFocus(FocusEvent e) + { + OnFocused?.Invoke(); + base.OnFocus(e); + + GetContainingInputManager().TriggerFocusContention(this); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs new file mode 100644 index 0000000000..4ddee2acc6 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -0,0 +1,71 @@ +// 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. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Screens.Edit.Setup +{ + internal class MetadataSection : SetupSection + { + private LabelledTextBox artistTextBox; + private LabelledTextBox titleTextBox; + private LabelledTextBox creatorTextBox; + private LabelledTextBox difficultyTextBox; + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Beatmap metadata" + }, + artistTextBox = new LabelledTextBox + { + Label = "Artist", + Current = { Value = Beatmap.Value.Metadata.Artist }, + TabbableContentContainer = this + }, + titleTextBox = new LabelledTextBox + { + Label = "Title", + Current = { Value = Beatmap.Value.Metadata.Title }, + TabbableContentContainer = this + }, + creatorTextBox = new LabelledTextBox + { + Label = "Creator", + Current = { Value = Beatmap.Value.Metadata.AuthorString }, + TabbableContentContainer = this + }, + difficultyTextBox = new LabelledTextBox + { + Label = "Difficulty Name", + Current = { Value = Beatmap.Value.BeatmapInfo.Version }, + TabbableContentContainer = this + }, + }; + + foreach (var item in Children.OfType<LabelledTextBox>()) + item.OnCommit += onCommit; + } + + private void onCommit(TextBox sender, bool newText) + { + if (!newText) return; + + // for now, update these on commit rather than making BeatmapMetadata bindables. + // after switching database engines we can reconsider if switching to bindables is a good direction. + Beatmap.Value.Metadata.Artist = artistTextBox.Current.Value; + Beatmap.Value.Metadata.Title = titleTextBox.Current.Value; + Beatmap.Value.Metadata.AuthorString = creatorTextBox.Current.Value; + Beatmap.Value.BeatmapInfo.Version = difficultyTextBox.Current.Value; + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs new file mode 100644 index 0000000000..17ecfdd52e --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -0,0 +1,211 @@ +// 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. + +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.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 + { + private LabelledTextBox audioTrackTextBox; + private Container backgroundSpriteContainer; + + public IEnumerable<string> 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; } + + [Resolved] + private MusicController music { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } + + [Resolved(canBeNull: true)] + private Editor editor { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + Container audioTrackFileChooserContainer = 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 = Beatmap.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; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + game.RegisterImportHandler(this); + } + + public bool ChangeBackgroundImage(string path) + { + var info = new FileInfo(path); + + if (!info.Exists) + return false; + + var set = Beatmap.Value.BeatmapSetInfo; + + // remove the previous background for now. + // in the future we probably want to check if this is being used elsewhere (other difficulties?) + var oldFile = set.Files.FirstOrDefault(f => f.Filename == Beatmap.Value.Metadata.BackgroundFile); + + using (var stream = info.OpenRead()) + { + if (oldFile != null) + beatmaps.ReplaceFile(set, oldFile, stream, info.Name); + else + beatmaps.AddFile(set, stream, info.Name); + } + + Beatmap.Value.Metadata.BackgroundFile = info.Name; + updateBackgroundSprite(); + + return true; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + game?.UnregisterImportHandler(this); + } + + public bool ChangeAudioTrack(string path) + { + var info = new FileInfo(path); + + if (!info.Exists) + return false; + + var set = Beatmap.Value.BeatmapSetInfo; + + // remove the previous audio track for now. + // in the future we probably want to check if this is being used elsewhere (other difficulties?) + var oldFile = set.Files.FirstOrDefault(f => f.Filename == Beatmap.Value.Metadata.AudioFile); + + using (var stream = info.OpenRead()) + { + if (oldFile != null) + beatmaps.ReplaceFile(set, oldFile, stream, info.Name); + else + beatmaps.AddFile(set, stream, info.Name); + } + + Beatmap.Value.Metadata.AudioFile = info.Name; + + music.ReloadCurrentTrack(); + + editor?.UpdateClockSource(); + return true; + } + + private void audioTrackChanged(ValueChangedEvent<string> filePath) + { + if (!ChangeAudioTrack(filePath.NewValue)) + audioTrackTextBox.Current.Value = filePath.OldValue; + } + + private void updateBackgroundSprite() + { + LoadComponentAsync(new BeatmapBackgroundSprite(Beatmap.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 new file mode 100644 index 0000000000..1c3cbb7206 --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/SetupScreen.cs @@ -0,0 +1,78 @@ +// 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. + +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 + { + [Resolved] + private OsuColour colours { get; set; } + + [Cached] + protected readonly OverlayColourProvider ColourProvider; + + public SetupScreen() + : base(EditorScreenMode.SongSetup) + { + ColourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + } + + [BackgroundDependencyLoader] + private void load() + { + Child = 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 = colours.GreySeafoamDark, + RelativeSizeAxes = Axes.Both, + }, + new SectionsContainer<SetupSection> + { + FixedHeader = new SetupScreenHeader(), + RelativeSizeAxes = Axes.Both, + Children = new SetupSection[] + { + new ResourcesSection(), + new MetadataSection(), + new DifficultySection(), + } + }, + } + } + }; + } + } + + 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/SetupSection.cs b/osu.Game/Screens/Edit/Setup/SetupSection.cs new file mode 100644 index 0000000000..cdf17d355e --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/SetupSection.cs @@ -0,0 +1,42 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Screens.Edit.Setup +{ + internal class SetupSection : Container + { + private readonly FillFlowContainer flow; + + [Resolved] + protected OsuColour Colours { get; private set; } + + [Resolved] + protected IBindable<WorkingBeatmap> Beatmap { get; private set; } + + protected override Container<Drawable> Content => flow; + + public SetupSection() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Padding = new MarginPadding(10); + + InternalChild = 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 new file mode 100644 index 0000000000..c40061b97c --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs @@ -0,0 +1,51 @@ +// 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. + +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 + { + [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<Drawable> createSections() => new Drawable[] + { + new GroupSection(), + new TimingSection(), + new DifficultySection(), + new SampleSection(), + new EffectSection(), + }; + } +} diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs new file mode 100644 index 0000000000..89d3c36250 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -0,0 +1,268 @@ +// 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. + +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.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 osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Timing +{ + public class ControlPointTable : TableContainer + { + 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<ControlPointGroup> selectedGroup { get; set; } + + public ControlPointTable() + { + 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 } + }); + } + + public IEnumerable<ControlPointGroup> ControlGroups + { + set + { + Content = null; + backgroundFlow.Clear(); + + if (value?.Any() != true) + return; + + foreach (var group in value) + { + backgroundFlow.Add(new RowBackground(group)); + } + + Columns = createHeaders(); + Content = value.Select((g, i) => createContent(i, g)).ToArray().ToRectangular(); + } + } + + private TableColumn[] createHeaders() + { + var columns = new List<TableColumn> + { + new TableColumn(string.Empty, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("Time", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new TableColumn("Attributes", Anchor.Centre), + }; + + return columns.ToArray(); + } + + private Drawable[] createContent(int index, ControlPointGroup group) => new Drawable[] + { + new OsuSpriteText + { + 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) + }, + new ControlGroupAttributes(group), + }; + + private class ControlGroupAttributes : CompositeDrawable + { + private readonly IBindableList<ControlPoint> controlPoints = new BindableList<ControlPoint>(); + + private readonly FillFlowContainer fill; + + public ControlGroupAttributes(ControlPointGroup group) + { + InternalChild = fill = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding(10), + Spacing = new Vector2(2) + }; + + controlPoints.BindTo(group.ControlPoints); + } + + [Resolved] + private OsuColour colours { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + createChildren(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + controlPoints.CollectionChanged += (_, __) => createChildren(); + } + + private void createChildren() + { + fill.ChildrenEnumerable = controlPoints.Select(createAttribute).Where(c => c != null); + } + + 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); + + case DifficultyControlPoint difficulty: + + return new RowAttribute("difficulty", () => $"{difficulty.SpeedMultiplier:n2}x", colour); + + case EffectControlPoint effect: + return new RowAttribute("effect", () => $"{(effect.KiaiMode ? "Kiai " : "")}{(effect.OmitFirstBarLine ? "NoBarLine " : "")}", colour); + + case SampleControlPoint sample: + return new RowAttribute("sample", () => $"{sample.SampleBank} {sample.SampleVolume}%", colour); + } + + 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<ControlPointGroup> 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.SeekTo(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 new file mode 100644 index 0000000000..b55d74e3b4 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/DifficultySection.cs @@ -0,0 +1,45 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Screens.Edit.Timing +{ + internal class DifficultySection : Section<DifficultyControlPoint> + { + private SliderWithTextBoxInput<double> multiplierSlider; + + [BackgroundDependencyLoader] + private void load() + { + Flow.AddRange(new[] + { + multiplierSlider = new SliderWithTextBoxInput<double>("Speed Multiplier") + { + Current = new DifficultyControlPoint().SpeedMultiplierBindable + } + }); + } + + protected override void OnControlPointChanged(ValueChangedEvent<DifficultyControlPoint> point) + { + if (point.NewValue != null) + { + multiplierSlider.Current = point.NewValue.SpeedMultiplierBindable; + multiplierSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + } + } + + protected override DifficultyControlPoint CreatePoint() + { + var reference = Beatmap.Value.Beatmap.ControlPointInfo.DifficultyPointAt(SelectedGroup.Value.Time); + + return new DifficultyControlPoint + { + SpeedMultiplier = reference.SpeedMultiplier, + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/EffectSection.cs b/osu.Game/Screens/Edit/Timing/EffectSection.cs new file mode 100644 index 0000000000..2f143108a9 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/EffectSection.cs @@ -0,0 +1,49 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Screens.Edit.Timing +{ + internal class EffectSection : Section<EffectControlPoint> + { + private LabelledSwitchButton kiai; + private LabelledSwitchButton omitBarLine; + + [BackgroundDependencyLoader] + private void load() + { + Flow.AddRange(new[] + { + kiai = new LabelledSwitchButton { Label = "Kiai Time" }, + omitBarLine = new LabelledSwitchButton { Label = "Skip Bar Line" }, + }); + } + + protected override void OnControlPointChanged(ValueChangedEvent<EffectControlPoint> point) + { + if (point.NewValue != null) + { + kiai.Current = point.NewValue.KiaiModeBindable; + kiai.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + + omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable; + omitBarLine.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + } + } + + protected override EffectControlPoint CreatePoint() + { + var reference = Beatmap.Value.Beatmap.ControlPointInfo.EffectPointAt(SelectedGroup.Value.Time); + + return new EffectControlPoint + { + KiaiMode = reference.KiaiMode, + OmitFirstBarLine = reference.OmitFirstBarLine + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/GroupSection.cs b/osu.Game/Screens/Edit/Timing/GroupSection.cs new file mode 100644 index 0000000000..2605ea8b75 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/GroupSection.cs @@ -0,0 +1,121 @@ +// 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. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK; + +namespace osu.Game.Screens.Edit.Timing +{ + internal class GroupSection : CompositeDrawable + { + private LabelledTextBox textBox; + + private TriangleButton button; + + [Resolved] + protected Bindable<ControlPointGroup> SelectedGroup { get; private set; } + + [Resolved] + protected IBindable<WorkingBeatmap> Beatmap { get; private set; } + + [Resolved] + private EditorClock clock { get; set; } + + [Resolved(canBeNull: true)] + private IEditorChangeHandler changeHandler { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Padding = new MarginPadding(10); + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + textBox = new LabelledTextBox + { + Label = "Time" + }, + button = new TriangleButton + { + Text = "Use current time", + RelativeSizeAxes = Axes.X, + Action = () => changeSelectedGroupTime(clock.CurrentTime) + } + } + }, + }; + + textBox.OnCommit += (sender, isNew) => + { + if (!isNew) + return; + + if (double.TryParse(sender.Text, out var newTime)) + { + changeSelectedGroupTime(newTime); + } + else + { + SelectedGroup.TriggerChange(); + } + }; + + SelectedGroup.BindValueChanged(group => + { + if (group.NewValue == null) + { + textBox.Text = string.Empty; + + // cannot use textBox.Current.Disabled due to https://github.com/ppy/osu-framework/issues/3919 + textBox.ReadOnly = true; + button.Enabled.Value = false; + return; + } + + textBox.ReadOnly = false; + button.Enabled.Value = true; + + textBox.Text = $"{group.NewValue.Time:n0}"; + }, true); + } + + private void changeSelectedGroupTime(in double time) + { + if (SelectedGroup.Value == null || time == SelectedGroup.Value.Time) + return; + + changeHandler?.BeginChange(); + + var currentGroupItems = SelectedGroup.Value.ControlPoints.ToArray(); + + Beatmap.Value.Beatmap.ControlPointInfo.RemoveGroup(SelectedGroup.Value); + + foreach (var cp in currentGroupItems) + Beatmap.Value.Beatmap.ControlPointInfo.Add(time, cp); + + // the control point might not necessarily exist yet, if currentGroupItems was empty. + SelectedGroup.Value = Beatmap.Value.Beatmap.ControlPointInfo.GroupAt(time, true); + + changeHandler?.EndChange(); + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/RowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttribute.cs new file mode 100644 index 0000000000..2757e08026 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/RowAttribute.cs @@ -0,0 +1,63 @@ +// 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. + +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.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Timing +{ + public class RowAttribute : CompositeDrawable, IHasTooltip + { + private readonly string header; + private readonly Func<string> content; + private readonly Color4 colour; + + public RowAttribute(string header, Func<string> content, Color4 colour) + { + this.header = header; + this.content = content; + this.colour = colour; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AutoSizeAxes = Axes.X; + + Height = 20; + + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + + Masking = true; + CornerRadius = 5; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colour, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Padding = new MarginPadding(2), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(weight: FontWeight.SemiBold, size: 12), + Text = header, + Colour = colours.Gray0 + }, + }; + } + + public string TooltipText => content(); + } +} diff --git a/osu.Game/Screens/Edit/Timing/SampleSection.cs b/osu.Game/Screens/Edit/Timing/SampleSection.cs new file mode 100644 index 0000000000..280e19c99a --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/SampleSection.cs @@ -0,0 +1,56 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Screens.Edit.Timing +{ + internal class SampleSection : Section<SampleControlPoint> + { + private LabelledTextBox bank; + private SliderWithTextBoxInput<int> volume; + + [BackgroundDependencyLoader] + private void load() + { + Flow.AddRange(new Drawable[] + { + bank = new LabelledTextBox + { + Label = "Bank Name", + }, + volume = new SliderWithTextBoxInput<int>("Volume") + { + Current = new SampleControlPoint().SampleVolumeBindable, + } + }); + } + + protected override void OnControlPointChanged(ValueChangedEvent<SampleControlPoint> point) + { + if (point.NewValue != null) + { + bank.Current = point.NewValue.SampleBankBindable; + bank.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + + volume.Current = point.NewValue.SampleVolumeBindable; + volume.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + } + } + + protected override SampleControlPoint CreatePoint() + { + var reference = Beatmap.Value.Beatmap.ControlPointInfo.SamplePointAt(SelectedGroup.Value.Time); + + return new SampleControlPoint + { + SampleBank = reference.SampleBank, + SampleVolume = reference.SampleVolume, + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/Section.cs b/osu.Game/Screens/Edit/Timing/Section.cs new file mode 100644 index 0000000000..7a81eeb1a4 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/Section.cs @@ -0,0 +1,133 @@ +// 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. + +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.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Edit.Timing +{ + internal abstract class Section<T> : CompositeDrawable + where T : ControlPoint + { + private OsuCheckbox checkbox; + private Container content; + + protected FillFlowContainer Flow { get; private set; } + + protected Bindable<T> ControlPoint { get; } = new Bindable<T>(); + + private const float header_height = 20; + + [Resolved] + protected IBindable<WorkingBeatmap> Beatmap { get; private set; } + + [Resolved] + protected Bindable<ControlPointGroup> SelectedGroup { get; private set; } + + [Resolved(canBeNull: true)] + protected IEditorChangeHandler ChangeHandler { get; private set; } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.X; + AutoSizeDuration = 200; + AutoSizeEasing = Easing.OutQuint; + AutoSizeAxes = Axes.Y; + + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Gray1, + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = header_height, + Children = new Drawable[] + { + checkbox = new OsuCheckbox + { + LabelText = typeof(T).Name.Replace(nameof(Beatmaps.ControlPoints.ControlPoint), string.Empty) + } + } + }, + content = new Container + { + Y = header_height, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Box + { + Colour = colours.Gray2, + RelativeSizeAxes = Axes.Both, + }, + Flow = new FillFlowContainer + { + Padding = new MarginPadding(10), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + }, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + checkbox.Current.BindValueChanged(selected => + { + if (selected.NewValue) + { + if (SelectedGroup.Value == null) + { + checkbox.Current.Value = false; + return; + } + + if (ControlPoint.Value == null) + SelectedGroup.Value.Add(ControlPoint.Value = CreatePoint()); + } + else + { + if (ControlPoint.Value != null) + { + SelectedGroup.Value.Remove(ControlPoint.Value); + ControlPoint.Value = null; + } + } + + content.BypassAutoSizeAxes = selected.NewValue ? Axes.None : Axes.Y; + }, true); + + SelectedGroup.BindValueChanged(points => + { + ControlPoint.Value = points.NewValue?.ControlPoints.OfType<T>().FirstOrDefault(); + checkbox.Current.Value = ControlPoint.Value != null; + }, true); + + ControlPoint.BindValueChanged(OnControlPointChanged, true); + } + + protected abstract void OnControlPointChanged(ValueChangedEvent<T> point); + + protected abstract T CreatePoint(); + } +} diff --git a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs new file mode 100644 index 0000000000..f2f9f76143 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs @@ -0,0 +1,78 @@ +// 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. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Screens.Edit.Timing +{ + public class SliderWithTextBoxInput<T> : CompositeDrawable, IHasCurrentValue<T> + where T : struct, IEquatable<T>, IComparable<T>, IConvertible + { + private readonly SettingsSlider<T> slider; + + public SliderWithTextBoxInput(string labelText) + { + LabelledTextBox textbox; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + textbox = new LabelledTextBox + { + Label = labelText, + }, + slider = new SettingsSlider<T> + { + TransferValueOnCommit = true, + RelativeSizeAxes = Axes.X, + } + } + }, + }; + + textbox.OnCommit += (t, isNew) => + { + if (!isNew) return; + + try + { + slider.Current.Parse(t.Text); + } + catch + { + // TriggerChange below will restore the previous text value on failure. + } + + // This is run regardless of parsing success as the parsed number may not actually trigger a change + // due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state. + Current.TriggerChange(); + }; + + Current.BindValueChanged(val => + { + textbox.Text = val.NewValue.ToString(); + }, true); + } + + public Bindable<T> Current + { + get => slider.Current; + set => slider.Current = value; + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/TimingScreen.cs b/osu.Game/Screens/Edit/Timing/TimingScreen.cs new file mode 100644 index 0000000000..09d861522a --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/TimingScreen.cs @@ -0,0 +1,151 @@ +// 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. + +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.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 osuTK; + +namespace osu.Game.Screens.Edit.Timing +{ + public class TimingScreen : EditorScreenWithTimeline + { + [Cached] + private Bindable<ControlPointGroup> selectedGroup = new Bindable<ControlPointGroup>(); + + public TimingScreen() + : base(EditorScreenMode.Timing) + { + } + + protected override Drawable CreateMainContent() => new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 200), + }, + Content = new[] + { + new Drawable[] + { + new ControlPointList(), + new ControlPointSettings(), + }, + } + }; + + protected override void OnTimelineLoaded(TimelineArea timelineArea) + { + base.OnTimelineLoaded(timelineArea); + timelineArea.Timeline.Zoom = timelineArea.Timeline.MinZoom; + } + + public class ControlPointList : CompositeDrawable + { + private OsuButton deleteButton; + private ControlPointTable table; + + private readonly IBindableList<ControlPointGroup> controlPointGroups = new BindableList<ControlPointGroup>(); + + [Resolved] + private EditorClock clock { get; set; } + + [Resolved] + protected IBindable<WorkingBeatmap> Beatmap { get; private set; } + + [Resolved] + private Bindable<ControlPointGroup> selectedGroup { get; set; } + + [Resolved(canBeNull: true)] + private IEditorChangeHandler changeHandler { get; set; } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colours.Gray0, + RelativeSizeAxes = Axes.Both, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = table = new ControlPointTable(), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding(10), + Spacing = new Vector2(5), + Children = new Drawable[] + { + deleteButton = new OsuButton + { + Text = "-", + Size = new Vector2(30, 30), + Action = delete, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + new OsuButton + { + Text = "+", + Action = addNew, + Size = new Vector2(30, 30), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedGroup.BindValueChanged(selected => { deleteButton.Enabled.Value = selected.NewValue != null; }, true); + + controlPointGroups.BindTo(Beatmap.Value.Beatmap.ControlPointInfo.Groups); + controlPointGroups.BindCollectionChanged((sender, args) => + { + table.ControlGroups = controlPointGroups; + changeHandler.SaveState(); + }, true); + } + + private void delete() + { + if (selectedGroup.Value == null) + return; + + Beatmap.Value.Beatmap.ControlPointInfo.RemoveGroup(selectedGroup.Value); + + selectedGroup.Value = Beatmap.Value.Beatmap.ControlPointInfo.Groups.FirstOrDefault(g => g.Time >= clock.CurrentTime); + } + + private void addNew() + { + selectedGroup.Value = Beatmap.Value.Beatmap.ControlPointInfo.GroupAt(clock.CurrentTime, true); + } + } + } +} diff --git a/osu.Game/Screens/Edit/Timing/TimingSection.cs b/osu.Game/Screens/Edit/Timing/TimingSection.cs new file mode 100644 index 0000000000..1ae2a86885 --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/TimingSection.cs @@ -0,0 +1,153 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Screens.Edit.Timing +{ + internal class TimingSection : Section<TimingControlPoint> + { + private SettingsSlider<double> bpmSlider; + private SettingsEnumDropdown<TimeSignatures> timeSignature; + private BPMTextBox bpmTextEntry; + + [BackgroundDependencyLoader] + private void load() + { + Flow.AddRange(new Drawable[] + { + bpmTextEntry = new BPMTextBox(), + bpmSlider = new BPMSlider(), + timeSignature = new SettingsEnumDropdown<TimeSignatures> + { + LabelText = "Time Signature" + }, + }); + } + + protected override void OnControlPointChanged(ValueChangedEvent<TimingControlPoint> point) + { + if (point.NewValue != null) + { + bpmSlider.Current = point.NewValue.BeatLengthBindable; + bpmSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + + bpmTextEntry.Bindable = point.NewValue.BeatLengthBindable; + // no need to hook change handler here as it's the same bindable as above + + timeSignature.Current = point.NewValue.TimeSignatureBindable; + timeSignature.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + } + } + + protected override TimingControlPoint CreatePoint() + { + var reference = Beatmap.Value.Beatmap.ControlPointInfo.TimingPointAt(SelectedGroup.Value.Time); + + return new TimingControlPoint + { + BeatLength = reference.BeatLength, + TimeSignature = reference.TimeSignature + }; + } + + private class BPMTextBox : LabelledTextBox + { + private readonly BindableNumber<double> beatLengthBindable = new TimingControlPoint().BeatLengthBindable; + + public BPMTextBox() + { + Label = "BPM"; + + OnCommit += (val, isNew) => + { + if (!isNew) return; + + try + { + if (double.TryParse(Current.Value, out double doubleVal) && doubleVal > 0) + beatLengthBindable.Value = beatLengthToBpm(doubleVal); + } + catch + { + // TriggerChange below will restore the previous text value on failure. + } + + // This is run regardless of parsing success as the parsed number may not actually trigger a change + // due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state. + beatLengthBindable.TriggerChange(); + }; + + beatLengthBindable.BindValueChanged(val => + { + Current.Value = beatLengthToBpm(val.NewValue).ToString("N2"); + }, true); + } + + public Bindable<double> Bindable + { + get => beatLengthBindable; + set + { + // incoming will be beat length, not bpm + beatLengthBindable.UnbindBindings(); + beatLengthBindable.BindTo(value); + } + } + } + + private class BPMSlider : SettingsSlider<double> + { + private const double sane_minimum = 60; + private const double sane_maximum = 240; + + private readonly BindableNumber<double> beatLengthBindable = new TimingControlPoint().BeatLengthBindable; + + private readonly BindableDouble bpmBindable = new BindableDouble(60000 / TimingControlPoint.DEFAULT_BEAT_LENGTH) + { + MinValue = sane_minimum, + MaxValue = sane_maximum, + }; + + public BPMSlider() + { + beatLengthBindable.BindValueChanged(beatLength => updateCurrent(beatLengthToBpm(beatLength.NewValue)), true); + bpmBindable.BindValueChanged(bpm => beatLengthBindable.Value = beatLengthToBpm(bpm.NewValue)); + + base.Current = bpmBindable; + + TransferValueOnCommit = true; + } + + public override Bindable<double> Current + { + get => base.Current; + set + { + // incoming will be beat length, not bpm + beatLengthBindable.UnbindBindings(); + beatLengthBindable.BindTo(value); + } + } + + private void updateCurrent(double newValue) + { + // we use a more sane range for the slider display unless overridden by the user. + // if a value comes in outside our range, we should expand temporarily. + bpmBindable.MinValue = Math.Min(newValue, sane_minimum); + bpmBindable.MaxValue = Math.Max(newValue, sane_maximum); + + bpmBindable.Value = newValue; + } + } + + private static double beatLengthToBpm(double beatLength) => 60000 / beatLength; + } +} diff --git a/osu.Game/Screens/Edit/TransactionalCommitComponent.cs b/osu.Game/Screens/Edit/TransactionalCommitComponent.cs new file mode 100644 index 0000000000..3d3539ee2f --- /dev/null +++ b/osu.Game/Screens/Edit/TransactionalCommitComponent.cs @@ -0,0 +1,73 @@ +// 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. + +using System; +using osu.Framework.Graphics; + +namespace osu.Game.Screens.Edit +{ + /// <summary> + /// A component that tracks a batch change, only applying after all active changes are completed. + /// </summary> + public abstract class TransactionalCommitComponent : Component + { + /// <summary> + /// Fires whenever a transaction begins. Will not fire on nested transactions. + /// </summary> + public event Action TransactionBegan; + + /// <summary> + /// Fires when the last transaction completes. + /// </summary> + public event Action TransactionEnded; + + /// <summary> + /// Fires when <see cref="SaveState"/> is called and results in a non-transactional state save. + /// </summary> + public event Action SaveStateTriggered; + + public bool TransactionActive => bulkChangesStarted > 0; + + private int bulkChangesStarted; + + /// <summary> + /// Signal the beginning of a change. + /// </summary> + public void BeginChange() + { + if (bulkChangesStarted++ == 0) + TransactionBegan?.Invoke(); + } + + /// <summary> + /// Signal the end of a change. + /// </summary> + /// <exception cref="InvalidOperationException">Throws if <see cref="BeginChange"/> was not first called.</exception> + public void EndChange() + { + if (bulkChangesStarted == 0) + throw new InvalidOperationException($"Cannot call {nameof(EndChange)} without a previous call to {nameof(BeginChange)}."); + + if (--bulkChangesStarted == 0) + { + UpdateState(); + TransactionEnded?.Invoke(); + } + } + + /// <summary> + /// Force an update of the state with no attached transaction. + /// This is a no-op if a transaction is already active. Should generally be used as a safety measure to ensure granular changes are not left outside a transaction. + /// </summary> + public void SaveState() + { + if (bulkChangesStarted > 0) + return; + + SaveStateTriggered?.Invoke(); + UpdateState(); + } + + protected abstract void UpdateState(); + } +} diff --git a/osu.Game/Screens/Edit/WaveformOpacityMenu.cs b/osu.Game/Screens/Edit/WaveformOpacityMenu.cs new file mode 100644 index 0000000000..5d209ae141 --- /dev/null +++ b/osu.Game/Screens/Edit/WaveformOpacityMenu.cs @@ -0,0 +1,46 @@ +// 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. + +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 + { + private readonly Bindable<float> waveformOpacity; + + private readonly Dictionary<float, ToggleMenuItem> menuItemLookup = new Dictionary<float, ToggleMenuItem>(); + + public WaveformOpacityMenu(OsuConfigManager config) + : base("Waveform opacity") + { + Items = new[] + { + createMenuItem(0.25f), + createMenuItem(0.5f), + createMenuItem(0.75f), + createMenuItem(1f), + }; + + waveformOpacity = config.GetBindable<float>(OsuSetting.EditorWaveformOpacity); + waveformOpacity.BindValueChanged(opacity => + { + foreach (var kvp in menuItemLookup) + kvp.Value.State.Value = kvp.Key == opacity.NewValue; + }, true); + } + + private ToggleMenuItem createMenuItem(float opacity) + { + var item = new ToggleMenuItem($"{opacity * 100}%", MenuItemType.Standard, _ => updateOpacity(opacity)); + menuItemLookup[opacity] = item; + return item; + } + + private void updateOpacity(float opacity) => waveformOpacity.Value = opacity; + } +} diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index 9fc907c2a4..cc8778d9ae 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -6,6 +6,7 @@ using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Rulesets; +using osu.Game.Users; namespace osu.Game.Screens { @@ -39,9 +40,14 @@ namespace osu.Game.Screens bool HideOverlaysOnEnter { get; } /// <summary> - /// Whether overlays should be able to be opened once this screen is entered or resumed. + /// Whether overlays should be able to be opened when this screen is current. /// </summary> - OverlayActivation InitialOverlayActivationMode { get; } + IBindable<OverlayActivation> OverlayActivationMode { get; } + + /// <summary> + /// The current <see cref="UserActivity"/> for this screen. + /// </summary> + IBindable<UserActivity> Activity { get; } /// <summary> /// The amount of parallax to be applied while this screen is displayed. @@ -51,5 +57,19 @@ namespace osu.Game.Screens Bindable<WorkingBeatmap> Beatmap { get; } Bindable<RulesetInfo> Ruleset { get; } + + /// <summary> + /// Whether mod rate adjustments are allowed to be applied. + /// </summary> + bool AllowRateAdjustments { get; } + + /// <summary> + /// Invoked when the back button has been pressed to close any overlays before exiting this <see cref="IOsuScreen"/>. + /// </summary> + /// <remarks> + /// Return <c>true</c> to block this <see cref="IOsuScreen"/> from being exited after closing an overlay. + /// Return <c>false</c> if this <see cref="IOsuScreen"/> should continue exiting. + /// </remarks> + bool OnBackButton(); } } diff --git a/osu.Game/Screens/Loader.cs b/osu.Game/Screens/Loader.cs index 8add730c4e..0bfabdaa15 100644 --- a/osu.Game/Screens/Loader.cs +++ b/osu.Game/Screens/Loader.cs @@ -5,10 +5,15 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shaders; +using osu.Framework.Utils; using osu.Game.Screens.Menu; -using osuTK; using osu.Framework.Screens; +using osu.Framework.Threading; +using osu.Game.Configuration; +using osu.Game.Graphics.UserInterface; +using IntroSequence = osu.Game.Configuration.IntroSequence; namespace osu.Game.Screens { @@ -21,31 +26,38 @@ namespace osu.Game.Screens ValidForResume = false; } - protected override void LogoArriving(OsuLogo logo, bool resuming) - { - base.LogoArriving(logo, resuming); - - logo.BeatMatching = false; - logo.Triangles = false; - logo.RelativePositionAxes = Axes.None; - logo.Origin = Anchor.BottomRight; - logo.Anchor = Anchor.BottomRight; - logo.Position = new Vector2(-40); - logo.Scale = new Vector2(0.2f); - - logo.Delay(500).FadeInFromZero(1000, Easing.OutQuint); - } - - protected override void LogoSuspending(OsuLogo logo) - { - base.LogoSuspending(logo); - logo.FadeOut(logo.Alpha * 400); - } - private OsuScreen loadableScreen; private ShaderPrecompiler precompiler; - protected virtual OsuScreen CreateLoadableScreen() => showDisclaimer ? (OsuScreen)new Disclaimer() : new Intro(); + private IntroSequence introSequence; + private LoadingSpinner spinner; + private ScheduledDelegate spinnerShow; + + protected virtual OsuScreen CreateLoadableScreen() + { + if (showDisclaimer) + return new Disclaimer(getIntroSequence()); + + return getIntroSequence(); + } + + private IntroScreen getIntroSequence() + { + if (introSequence == IntroSequence.Random) + introSequence = (IntroSequence)RNG.Next(0, (int)IntroSequence.Random); + + switch (introSequence) + { + case IntroSequence.Circles: + return new IntroCircles(); + + case IntroSequence.Welcome: + return new IntroWelcome(); + + default: + return new IntroTriangles(); + } + } protected virtual ShaderPrecompiler CreateShaderPrecompiler() => new ShaderPrecompiler(); @@ -56,6 +68,17 @@ namespace osu.Game.Screens LoadComponentAsync(precompiler = CreateShaderPrecompiler(), AddInternal); LoadComponentAsync(loadableScreen = CreateLoadableScreen()); + LoadComponentAsync(spinner = new LoadingSpinner(true, true) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding(40), + }, _ => + { + AddInternal(spinner); + spinnerShow = Scheduler.AddDelayed(spinner.Show, 200); + }); + checkIfLoaded(); } @@ -67,13 +90,22 @@ namespace osu.Game.Screens return; } - this.Push(loadableScreen); + spinnerShow?.Cancel(); + + if (spinner.State.Value == Visibility.Visible) + { + spinner.Hide(); + Scheduler.AddDelayed(() => this.Push(loadableScreen), LoadingSpinner.TRANSITION_DURATION); + } + else + this.Push(loadableScreen); } [BackgroundDependencyLoader] - private void load(OsuGameBase game) + private void load(OsuGameBase game, OsuConfigManager config) { showDisclaimer = game.IsDeployedBuild; + introSequence = config.Get<IntroSequence>(OsuSetting.IntroSequence); } /// <summary> diff --git a/osu.Game/Screens/Menu/Button.cs b/osu.Game/Screens/Menu/Button.cs index badd1e0549..be6ed9700c 100644 --- a/osu.Game/Screens/Menu/Button.cs +++ b/osu.Game/Screens/Menu/Button.cs @@ -6,6 +6,7 @@ using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -15,7 +16,6 @@ using osuTK.Graphics; using osuTK.Input; using osu.Framework.Extensions.Color4Extensions; using osu.Game.Graphics.Containers; -using osu.Framework.Audio.Track; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; @@ -31,6 +31,8 @@ namespace osu.Game.Screens.Menu { public event Action<ButtonState> StateChanged; + public readonly Key TriggerKey; + private readonly Container iconText; private readonly Container box; private readonly Box boxHoverLayer; @@ -43,7 +45,6 @@ namespace osu.Game.Screens.Menu public ButtonSystemState VisibleState = ButtonSystemState.TopLevel; private readonly Action clickAction; - private readonly Key triggerKey; private SampleChannel sampleClick; private SampleChannel sampleHover; @@ -53,7 +54,7 @@ namespace osu.Game.Screens.Menu { this.sampleName = sampleName; this.clickAction = clickAction; - this.triggerKey = triggerKey; + TriggerKey = triggerKey; AutoSizeAxes = Axes.Both; Alpha = 0; @@ -92,7 +93,7 @@ namespace osu.Game.Screens.Menu { EdgeSmoothness = new Vector2(1.5f, 0), RelativeSizeAxes = Axes.Both, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Colour = Color4.White, Alpha = 0, }, @@ -131,7 +132,7 @@ namespace osu.Game.Screens.Menu private bool rightward; - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); @@ -193,10 +194,10 @@ namespace osu.Game.Screens.Menu return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { boxHoverLayer.FadeTo(0, 1000, Easing.OutQuint); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) @@ -210,7 +211,7 @@ namespace osu.Game.Screens.Menu if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed) return false; - if (triggerKey == e.Key && triggerKey != Key.Unknown) + if (TriggerKey == e.Key && TriggerKey != Key.Unknown) { trigger(); return true; @@ -235,7 +236,7 @@ namespace osu.Game.Screens.Menu protected override void Update() { - iconText.Alpha = MathHelper.Clamp((box.Scale.X - 0.5f) / 0.3f, 0, 1); + iconText.Alpha = Math.Clamp((box.Scale.X - 0.5f) / 0.3f, 0, 1); base.Update(); } diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 1a3e1213b4..4becdd58cd 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Threading; @@ -38,7 +39,7 @@ namespace osu.Game.Screens.Menu public Action OnEdit; public Action OnExit; - public Action OnDirect; + public Action OnBeatmapListing; public Action OnSolo; public Action OnSettings; public Action OnMulti; @@ -129,7 +130,7 @@ namespace osu.Game.Screens.Menu 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(@"osu!editor", @"button-generic-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), () => OnEdit?.Invoke(), 0, Key.E)); - buttonsTopLevel.Add(new Button(@"osu!direct", @"button-direct-select", OsuIcon.ChevronDownCircle, new Color4(165, 204, 0, 255), () => OnDirect?.Invoke(), 0, Key.D)); + buttonsTopLevel.Add(new Button(@"osu!direct", @"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)); @@ -180,6 +181,20 @@ namespace osu.Game.Screens.Menu State = ButtonSystemState.Initial; } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (State == ButtonSystemState.Initial) + { + if (buttonsTopLevel.Any(b => e.Key == b.TriggerKey)) + { + logo?.Click(); + return true; + } + } + + return base.OnKeyDown(e); + } + public bool OnPressed(GlobalAction action) { switch (action) @@ -196,7 +211,9 @@ namespace osu.Game.Screens.Menu } } - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } private bool goBack() { @@ -253,9 +270,6 @@ namespace osu.Game.Screens.Menu ButtonSystemState lastState = state; state = value; - if (game != null) - game.OverlayActivationMode.Value = state == ButtonSystemState.Exit ? OverlayActivation.Disabled : OverlayActivation.All; - updateLogoState(lastState); Logger.Log($"{nameof(ButtonSystem)}'s state changed from {lastState} to {state}"); @@ -284,15 +298,15 @@ namespace osu.Game.Screens.Menu case ButtonSystemState.Initial: logoDelayedAction?.Cancel(); logoDelayedAction = Scheduler.AddDelayed(() => - { - logoTrackingContainer.StopTracking(); + { + logoTrackingContainer.StopTracking(); - game?.Toolbar.Hide(); + game?.Toolbar.Hide(); - logo.ClearTransforms(targetMember: nameof(Position)); - logo.MoveTo(new Vector2(0.5f), 800, Easing.OutExpo); - logo.ScaleTo(1, 800, Easing.OutExpo); - }, buttonArea.Alpha * 150); + logo.ClearTransforms(targetMember: nameof(Position)); + logo.MoveTo(new Vector2(0.5f), 800, Easing.OutExpo); + logo.ScaleTo(1, 800, Easing.OutExpo); + }, buttonArea.Alpha * 150); break; case ButtonSystemState.TopLevel: @@ -307,10 +321,9 @@ namespace osu.Game.Screens.Menu bool impact = logo.Scale.X > 0.6f; - if (lastState == ButtonSystemState.Initial) - logo.ScaleTo(0.5f, 200, Easing.In); + logo.ScaleTo(0.5f, 200, Easing.In); - logoTrackingContainer.StartTracking(logo, lastState == ButtonSystemState.EnteringMode ? 0 : 200, Easing.In); + logoTrackingContainer.StartTracking(logo, 200, Easing.In); logoDelayedAction?.Cancel(); logoDelayedAction = Scheduler.AddDelayed(() => diff --git a/osu.Game/Screens/Menu/ConfirmExitDialog.cs b/osu.Game/Screens/Menu/ConfirmExitDialog.cs new file mode 100644 index 0000000000..d120eb21a8 --- /dev/null +++ b/osu.Game/Screens/Menu/ConfirmExitDialog.cs @@ -0,0 +1,34 @@ +// 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. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Menu +{ + public class ConfirmExitDialog : PopupDialog + { + public ConfirmExitDialog(Action confirm, Action cancel) + { + HeaderText = "Are you sure you want to exit?"; + BodyText = "Last chance to back out."; + + Icon = FontAwesome.Solid.ExclamationTriangle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Goodbye", + Action = confirm + }, + new PopupDialogCancelButton + { + Text = @"Just a little more", + Action = cancel + }, + }; + } + } +} diff --git a/osu.Game/Screens/Menu/Disclaimer.cs b/osu.Game/Screens/Menu/Disclaimer.cs index 97231a1331..8368047d5a 100644 --- a/osu.Game/Screens/Menu/Disclaimer.cs +++ b/osu.Game/Screens/Menu/Disclaimer.cs @@ -1,7 +1,6 @@ // 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. -using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -10,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Screens; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Online.API; @@ -21,7 +21,6 @@ namespace osu.Game.Screens.Menu { public class Disclaimer : StartupScreen { - private Intro intro; private SpriteIcon icon; private Color4 iconColour; private LinkFlowContainer textFlow; @@ -32,15 +31,22 @@ namespace osu.Game.Screens.Menu private const float icon_y = -85; private const float icon_size = 30; - private readonly Bindable<User> currentUser = new Bindable<User>(); + private readonly OsuScreen nextScreen; - public Disclaimer() + private readonly Bindable<User> currentUser = new Bindable<User>(); + private FillFlowContainer fill; + + public Disclaimer(OsuScreen nextScreen = null) { + this.nextScreen = nextScreen; ValidForResume = false; } + [Resolved] + private IAPIProvider api { get; set; } + [BackgroundDependencyLoader] - private void load(OsuColour colours, IAPIProvider api) + private void load(OsuColour colours) { InternalChildren = new Drawable[] { @@ -48,16 +54,16 @@ namespace osu.Game.Screens.Menu { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Icon = FontAwesome.Solid.ExclamationTriangle, + Icon = FontAwesome.Solid.Flask, Size = new Vector2(icon_size), Y = icon_y, }, - new FillFlowContainer + fill = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, - Y = icon_y + icon_size, + Y = icon_y, Anchor = Anchor.Centre, Origin = Anchor.TopCentre, Children = new Drawable[] @@ -70,6 +76,8 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Spacing = new Vector2(0, 2), + LayoutDuration = 2000, + LayoutEasing = Easing.OutQuint }, supportFlow = new LinkFlowContainer { @@ -85,35 +93,30 @@ namespace osu.Game.Screens.Menu } }; - textFlow.AddText("This is an ", t => t.Font = t.Font.With(Typeface.Exo, 30, FontWeight.Light)); - textFlow.AddText("early development build", t => t.Font = t.Font.With(Typeface.Exo, 30, FontWeight.SemiBold)); - - textFlow.AddParagraph("Things may not work as expected", t => t.Font = t.Font.With(size: 20)); - textFlow.NewParagraph(); - - Action<SpriteText> format = t => t.Font = OsuFont.GetFont(size: 15, weight: FontWeight.SemiBold); - - textFlow.AddParagraph("Detailed bug reports are welcomed via github issues.", format); - textFlow.NewParagraph(); - - textFlow.AddText("Visit ", format); - textFlow.AddLink("discord.gg/ppy", "https://discord.gg/ppy", creationParameters: format); - textFlow.AddText(" to help out or follow progress!", format); + textFlow.AddText("This project is an ongoing ", t => t.Font = t.Font.With(Typeface.Torus, 30, FontWeight.Light)); + textFlow.AddText("work in progress", t => t.Font = t.Font.With(Typeface.Torus, 30, FontWeight.SemiBold)); textFlow.NewParagraph(); + + static void format(SpriteText t) => t.Font = OsuFont.GetFont(size: 15, weight: FontWeight.SemiBold); + + textFlow.AddParagraph(getRandomTip(), t => t.Font = t.Font.With(Typeface.Torus, 20, FontWeight.SemiBold)); textFlow.NewParagraph(); + textFlow.NewParagraph(); iconColour = colours.Yellow; - currentUser.BindTo(api.LocalUser); + // manually transfer the user once, but only do the final bind in LoadComplete to avoid thread woes (API scheduler could run while this screen is still loading). + // the manual transfer is here to ensure all text content is loaded ahead of time as this is very early in the game load process and we want to avoid stutters. + currentUser.Value = api.LocalUser.Value; currentUser.BindValueChanged(e => { supportFlow.Children.ForEach(d => d.FadeOut().Expire()); if (e.NewValue.IsSupporter) { - supportFlow.AddText("Thank you for supporting osu!", format); + supportFlow.AddText("Eternal thanks to you for supporting osu!", format); } else { @@ -124,7 +127,7 @@ namespace osu.Game.Screens.Menu heart = supportFlow.AddIcon(FontAwesome.Solid.Heart, t => { - t.Padding = new MarginPadding { Left = 5 }; + t.Padding = new MarginPadding { Left = 5, Top = 3 }; t.Font = t.Font.With(size: 12); t.Origin = Anchor.Centre; t.Colour = colours.Pink; @@ -138,30 +141,41 @@ namespace osu.Game.Screens.Menu }, true); } - private void animateHeart() - { - heart.FlashColour(Color4.White, 750, Easing.OutQuint).Loop(); - } - protected override void LoadComplete() { base.LoadComplete(); - LoadComponentAsync(intro = new Intro()); + if (nextScreen != null) + LoadComponentAsync(nextScreen); + + currentUser.BindTo(api.LocalUser); } public override void OnEntering(IScreen last) { base.OnEntering(last); - icon.Delay(1000).FadeColour(iconColour, 200, Easing.OutQuint); - icon.Delay(1000) - .MoveToY(icon_y * 1.1f, 160, Easing.OutCirc) - .RotateTo(-10, 160, Easing.OutCirc) - .Then() - .MoveToY(icon_y, 160, Easing.InCirc) - .RotateTo(0, 160, Easing.InCirc); + icon.RotateTo(10); + icon.FadeOut(); + icon.ScaleTo(0.5f); + + icon.Delay(500).FadeIn(500).ScaleTo(1, 500, Easing.OutQuint); + + using (BeginDelayedSequence(3000, true)) + { + icon.FadeColour(iconColour, 200, Easing.OutQuint); + icon.MoveToY(icon_y * 1.3f, 500, Easing.OutCirc) + .RotateTo(-360, 520, Easing.OutQuint) + .Then() + .MoveToY(icon_y, 160, Easing.InQuart) + .FadeColour(Color4.White, 160); + + fill.Delay(520 + 160).MoveToOffset(new Vector2(0, 15), 160, Easing.OutQuart); + } supportFlow.FadeOut().Delay(2000).FadeIn(500); + double delay = 500; + foreach (var c in textFlow.Children) + c.FadeTo(0.001f).Delay(delay += 20).FadeIn(500); animateHeart(); @@ -170,7 +184,41 @@ namespace osu.Game.Screens.Menu .Then(5500) .FadeOut(250) .ScaleTo(0.9f, 250, Easing.InQuint) - .Finally(d => this.Push(intro)); + .Finally(d => + { + if (nextScreen != null) + this.Push(nextScreen); + }); + } + + private string getRandomTip() + { + string[] tips = + { + "You can press Ctrl-T anywhere in the game to toggle the toolbar!", + "You can press Ctrl-O anywhere in the game to access options!", + "All settings are dynamic and take effect in real-time. Try pausing and changing the skin while playing!", + "New features are coming online every update. Make sure to stay up-to-date!", + "If you find the UI too large or small, try adjusting UI scale in settings!", + "Try adjusting the \"Screen Scaling\" mode to change your gameplay or UI area, even in fullscreen!", + "For now, osu!direct is available to all users on lazer. You can access it anywhere using Ctrl-D!", + "Seeking in replays is available by dragging on the difficulty bar at the bottom of the screen!", + "Multithreading support means that even with low \"FPS\" your input and judgements will be accurate!", + "Try scrolling down in the mod select panel to find a bunch of new fun mods!", + "Most of the web content (profiles, rankings, etc.) are available natively in-game from the icons on the toolbar!", + "Get more details, hide or delete a beatmap by right-clicking on its panel at song select!", + "All delete operations are temporary until exiting. Restore accidentally deleted content from the maintenance settings!", + "Check out the \"timeshift\" multiplayer system, which has local permanent leaderboards and playlist support!", + "Toggle advanced frame / thread statistics with Ctrl-F11!", + "Take a look under the hood at performance counters and enable verbose performance logging with Ctrl-F2!", + }; + + return tips[RNG.Next(0, tips.Length)]; + } + + private void animateHeart() + { + heart.FlashColour(Color4.White, 750, Easing.OutQuint).Loop(); } } } diff --git a/osu.Game/Screens/Menu/ExitConfirmOverlay.cs b/osu.Game/Screens/Menu/ExitConfirmOverlay.cs index 519834859d..db2faeb60a 100644 --- a/osu.Game/Screens/Menu/ExitConfirmOverlay.cs +++ b/osu.Game/Screens/Menu/ExitConfirmOverlay.cs @@ -9,6 +9,10 @@ namespace osu.Game.Screens.Menu { public class ExitConfirmOverlay : HoldToConfirmOverlay, IKeyBindingHandler<GlobalAction> { + protected override bool AllowMultipleFires => true; + + public void Abort() => AbortConfirm(); + public bool OnPressed(GlobalAction action) { if (action == GlobalAction.Back) @@ -20,15 +24,13 @@ namespace osu.Game.Screens.Menu return false; } - public bool OnReleased(GlobalAction action) + public void OnReleased(GlobalAction action) { if (action == GlobalAction.Back) { - AbortConfirm(); - return true; + if (!Fired) + AbortConfirm(); } - - return false; } } } diff --git a/osu.Game/Screens/Menu/Intro.cs b/osu.Game/Screens/Menu/Intro.cs deleted file mode 100644 index f6fbcf6498..0000000000 --- a/osu.Game/Screens/Menu/Intro.cs +++ /dev/null @@ -1,178 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Audio.Track; -using osu.Framework.Bindables; -using osu.Framework.Screens; -using osu.Framework.Graphics; -using osu.Framework.MathUtils; -using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.IO.Archives; -using osu.Game.Screens.Backgrounds; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Menu -{ - public class Intro : StartupScreen - { - private const string menu_music_beatmap_hash = "3c8b1fcc9434dbb29e2fb613d3b9eada9d7bb6c125ceb32396c3b53437280c83"; - - /// <summary> - /// Whether we have loaded the menu previously. - /// </summary> - public bool DidLoadMenu; - - private MainMenu mainMenu; - private SampleChannel welcome; - private SampleChannel seeya; - - protected override BackgroundScreen CreateBackground() => new BackgroundScreenBlack(); - - private readonly BindableDouble exitingVolumeFade = new BindableDouble(1); - - [Resolved] - private AudioManager audio { get; set; } - - private Bindable<bool> menuVoice; - private Bindable<bool> menuMusic; - private Track track; - private WorkingBeatmap introBeatmap; - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config, BeatmapManager beatmaps, Framework.Game game) - { - menuVoice = config.GetBindable<bool>(OsuSetting.MenuVoice); - menuMusic = config.GetBindable<bool>(OsuSetting.MenuMusic); - - BeatmapSetInfo setInfo = null; - - if (!menuMusic.Value) - { - var sets = beatmaps.GetAllUsableBeatmapSets(); - if (sets.Count > 0) - setInfo = beatmaps.QueryBeatmapSet(s => s.ID == sets[RNG.Next(0, sets.Count - 1)].ID); - } - - if (setInfo == null) - { - setInfo = beatmaps.QueryBeatmapSet(b => b.Hash == menu_music_beatmap_hash); - - if (setInfo == null) - { - // we need to import the default menu background beatmap - setInfo = beatmaps.Import(new ZipArchiveReader(game.Resources.GetStream(@"Tracks/circles.osz"), "circles.osz")).Result; - - setInfo.Protected = true; - beatmaps.Update(setInfo); - } - } - - introBeatmap = beatmaps.GetWorkingBeatmap(setInfo.Beatmaps[0]); - track = introBeatmap.Track; - - welcome = audio.Samples.Get(@"welcome"); - seeya = audio.Samples.Get(@"seeya"); - } - - private const double delay_step_one = 2300; - private const double delay_step_two = 600; - - public const int EXIT_DELAY = 3000; - - protected override void LogoArriving(OsuLogo logo, bool resuming) - { - base.LogoArriving(logo, resuming); - - if (!resuming) - { - Beatmap.Value = introBeatmap; - introBeatmap = null; - - if (menuVoice.Value) - welcome.Play(); - - Scheduler.AddDelayed(delegate - { - // Only start the current track if it is the menu music. A beatmap's track is started when entering the Main Manu. - if (menuMusic.Value) - { - track.Start(); - track = null; - } - - LoadComponentAsync(mainMenu = new MainMenu()); - - Scheduler.AddDelayed(delegate - { - DidLoadMenu = true; - this.Push(mainMenu); - }, delay_step_one); - }, delay_step_two); - } - - logo.Colour = Color4.White; - logo.Ripple = false; - - const int quick_appear = 350; - - int initialMovementTime = logo.Alpha > 0.2f ? quick_appear : 0; - - logo.MoveTo(new Vector2(0.5f), initialMovementTime, Easing.OutQuint); - - if (!resuming) - { - logo.ScaleTo(1); - logo.FadeIn(); - logo.PlayIntro(); - } - else - { - logo.Triangles = false; - - logo - .ScaleTo(1, initialMovementTime, Easing.OutQuint) - .FadeIn(quick_appear, Easing.OutQuint) - .Then() - .RotateTo(20, EXIT_DELAY * 1.5f) - .FadeOut(EXIT_DELAY); - } - } - - public override void OnSuspending(IScreen next) - { - this.FadeOut(300); - base.OnSuspending(next); - } - - public override bool OnExiting(IScreen next) - { - //cancel exiting if we haven't loaded the menu yet. - return !DidLoadMenu; - } - - public override void OnResuming(IScreen last) - { - this.FadeIn(300); - - double fadeOutTime = EXIT_DELAY; - //we also handle the exit transition. - if (menuVoice.Value) - seeya.Play(); - else - fadeOutTime = 500; - - audio.AddAdjustment(AdjustableProperty.Volume, exitingVolumeFade); - this.TransformBindableTo(exitingVolumeFade, 0, fadeOutTime).OnComplete(_ => this.Exit()); - - //don't want to fade out completely else we will stop running updates. - Game.FadeTo(0.01f, fadeOutTime); - - base.OnResuming(last); - } - } -} diff --git a/osu.Game/Screens/Menu/IntroCircles.cs b/osu.Game/Screens/Menu/IntroCircles.cs new file mode 100644 index 0000000000..d4cd073b7a --- /dev/null +++ b/osu.Game/Screens/Menu/IntroCircles.cs @@ -0,0 +1,59 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Screens; +using osu.Framework.Graphics; + +namespace osu.Game.Screens.Menu +{ + public class IntroCircles : IntroScreen + { + protected override string BeatmapHash => "3c8b1fcc9434dbb29e2fb613d3b9eada9d7bb6c125ceb32396c3b53437280c83"; + + protected override string BeatmapFile => "circles.osz"; + + private const double delay_step_one = 2300; + private const double delay_step_two = 600; + + private SampleChannel welcome; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + if (MenuVoice.Value) + welcome = audio.Samples.Get(@"Intro/welcome"); + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); + + if (!resuming) + { + welcome?.Play(); + + Scheduler.AddDelayed(delegate + { + StartTrack(); + + PrepareMenuLoad(); + + Scheduler.AddDelayed(LoadMenu, delay_step_one); + }, delay_step_two); + + logo.ScaleTo(1); + logo.FadeIn(); + logo.PlayIntro(); + } + } + + public override void OnSuspending(IScreen next) + { + this.FadeOut(300); + base.OnSuspending(next); + } + } +} diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs new file mode 100644 index 0000000000..1df5c503d6 --- /dev/null +++ b/osu.Game/Screens/Menu/IntroScreen.cs @@ -0,0 +1,223 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.IO.Archives; +using osu.Game.Overlays; +using osu.Game.Screens.Backgrounds; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Menu +{ + public abstract class IntroScreen : StartupScreen + { + /// <summary> + /// Whether we have loaded the menu previously. + /// </summary> + public bool DidLoadMenu { get; private set; } + + /// <summary> + /// A hash used to find the associated beatmap if already imported. + /// </summary> + protected abstract string BeatmapHash { get; } + + /// <summary> + /// A source file to use as an import source if the intro beatmap is not yet present. + /// Should be within the "Tracks" namespace of game resources. + /// </summary> + protected abstract string BeatmapFile { get; } + + protected IBindable<bool> MenuVoice { get; private set; } + + protected IBindable<bool> MenuMusic { get; private set; } + + private WorkingBeatmap initialBeatmap; + + protected ITrack Track { get; private set; } + + private const int exit_delay = 3000; + + private SampleChannel seeya; + + protected virtual string SeeyaSampleName => "Intro/seeya"; + + private LeasedBindable<WorkingBeatmap> beatmap; + + private MainMenu mainMenu; + + [Resolved] + private AudioManager audio { get; set; } + + [Resolved] + private MusicController musicController { get; set; } + + /// <summary> + /// Whether the <see cref="Track"/> is provided by osu! resources, rather than a user beatmap. + /// Only valid during or after <see cref="LogoArriving"/>. + /// </summary> + protected bool UsingThemedIntro { get; private set; } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, SkinManager skinManager, BeatmapManager beatmaps, Framework.Game game) + { + // prevent user from changing beatmap while the intro is still runnning. + beatmap = Beatmap.BeginLease(false); + + MenuVoice = config.GetBindable<bool>(OsuSetting.MenuVoice); + MenuMusic = config.GetBindable<bool>(OsuSetting.MenuMusic); + seeya = audio.Samples.Get(SeeyaSampleName); + + BeatmapSetInfo setInfo = null; + + // if the user has requested not to play theme music, we should attempt to find a random beatmap from their collection. + if (!MenuMusic.Value) + { + var sets = beatmaps.GetAllUsableBeatmapSets(IncludedDetails.Minimal); + + if (sets.Count > 0) + { + setInfo = beatmaps.QueryBeatmapSet(s => s.ID == sets[RNG.Next(0, sets.Count - 1)].ID); + initialBeatmap = beatmaps.GetWorkingBeatmap(setInfo.Beatmaps[0]); + } + } + + // we generally want a song to be playing on startup, so use the intro music even if a user has specified not to if no other track is available. + if (setInfo == null) + { + if (!loadThemedIntro()) + { + // if we detect that the theme track or beatmap is unavailable this is either first startup or things are in a bad state. + // this could happen if a user has nuked their files store. for now, reimport to repair this. + var import = beatmaps.Import(new ZipArchiveReader(game.Resources.GetStream($"Tracks/{BeatmapFile}"), BeatmapFile)).Result; + import.Protected = true; + beatmaps.Update(import); + + loadThemedIntro(); + } + } + + bool loadThemedIntro() + { + setInfo = beatmaps.QueryBeatmapSet(b => b.Hash == BeatmapHash); + + if (setInfo != null) + { + initialBeatmap = beatmaps.GetWorkingBeatmap(setInfo.Beatmaps[0]); + } + + return UsingThemedIntro; + } + } + + public override void OnResuming(IScreen last) + { + this.FadeIn(300); + + double fadeOutTime = exit_delay; + + var track = musicController.CurrentTrack; + + // ensure the track doesn't change or loop as we are exiting. + track.Looping = false; + Beatmap.Disabled = true; + + // we also handle the exit transition. + if (MenuVoice.Value) + { + seeya.Play(); + + // if playing the outro voice, we have more time to have fun with the background track. + // initially fade to almost silent then ramp out over the remaining time. + const double initial_fade = 200; + track + .VolumeTo(0.03f, initial_fade).Then() + .VolumeTo(0, fadeOutTime - initial_fade, Easing.In); + } + else + { + fadeOutTime = 500; + + // if outro voice is turned off, just do a simple fade out. + track.VolumeTo(0, fadeOutTime, Easing.Out); + } + + //don't want to fade out completely else we will stop running updates. + Game.FadeTo(0.01f, fadeOutTime).OnComplete(_ => this.Exit()); + + base.OnResuming(last); + } + + public override void OnSuspending(IScreen next) + { + base.OnSuspending(next); + initialBeatmap = null; + } + + protected override BackgroundScreen CreateBackground() => new BackgroundScreenBlack(); + + protected void StartTrack() + { + // Only start the current track if it is the menu music. A beatmap's track is started when entering the Main Menu. + if (UsingThemedIntro) + Track.Restart(); + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); + + logo.Colour = Color4.White; + logo.Triangles = false; + logo.Ripple = false; + + if (!resuming) + { + beatmap.Value = initialBeatmap; + Track = initialBeatmap.Track; + UsingThemedIntro = !initialBeatmap.Track.IsDummyDevice; + + // ensure the track starts at maximum volume + musicController.CurrentTrack.FinishTransforms(); + + logo.MoveTo(new Vector2(0.5f)); + logo.ScaleTo(Vector2.One); + logo.Hide(); + } + else + { + const int quick_appear = 350; + var initialMovementTime = logo.Alpha > 0.2f ? quick_appear : 0; + + logo.MoveTo(new Vector2(0.5f), initialMovementTime, Easing.OutQuint); + + logo + .ScaleTo(1, initialMovementTime, Easing.OutQuint) + .FadeIn(quick_appear, Easing.OutQuint) + .Then() + .RotateTo(20, exit_delay * 1.5f) + .FadeOut(exit_delay); + } + } + + protected void PrepareMenuLoad() => LoadComponentAsync(mainMenu = new MainMenu()); + + protected void LoadMenu() + { + beatmap.Return(); + + DidLoadMenu = true; + this.Push(mainMenu); + } + } +} diff --git a/osu.Game/Screens/Menu/IntroSequence.cs b/osu.Game/Screens/Menu/IntroSequence.cs index 093d01f12d..d92d38da45 100644 --- a/osu.Game/Screens/Menu/IntroSequence.cs +++ b/osu.Game/Screens/Menu/IntroSequence.cs @@ -42,6 +42,7 @@ namespace osu.Game.Screens.Menu public IntroSequence() { RelativeSizeAxes = Axes.Both; + Alpha = 0; } [BackgroundDependencyLoader] @@ -93,7 +94,7 @@ namespace osu.Game.Screens.Menu }, } }, - bigRing = new Ring(OsuColour.FromHex(@"B6C5E9"), 0.85f), + bigRing = new Ring(Color4Extensions.FromHex(@"B6C5E9"), 0.85f), mediumRing = new Ring(Color4.White.Opacity(130), 0.7f), smallRing = new Ring(Color4.White, 0.6f), welcomeText = new OsuSpriteText @@ -120,7 +121,7 @@ namespace osu.Game.Screens.Menu Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Height = 0, - Colour = OsuColour.FromHex(@"C6D8FF").Opacity(160), + Colour = Color4Extensions.FromHex(@"C6D8FF").Opacity(160), }, foregroundFill = new Box { @@ -138,28 +139,28 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.Centre, Origin = Anchor.TopCentre, Position = new Vector2(0, circle_offset), - Colour = OsuColour.FromHex(@"AA92FF"), + Colour = Color4Extensions.FromHex(@"AA92FF"), }, blueCircle = new Circle { Anchor = Anchor.Centre, Origin = Anchor.CentreRight, Position = new Vector2(-circle_offset, 0), - Colour = OsuColour.FromHex(@"8FE5FE"), + Colour = Color4Extensions.FromHex(@"8FE5FE"), }, yellowCircle = new Circle { Anchor = Anchor.Centre, Origin = Anchor.BottomCentre, Position = new Vector2(0, -circle_offset), - Colour = OsuColour.FromHex(@"FFD64C"), + Colour = Color4Extensions.FromHex(@"FFD64C"), }, pinkCircle = new Circle { Anchor = Anchor.Centre, Origin = Anchor.CentreLeft, Position = new Vector2(circle_offset, 0), - Colour = OsuColour.FromHex(@"e967a1"), + Colour = Color4Extensions.FromHex(@"e967a1"), }, }; @@ -204,6 +205,7 @@ namespace osu.Game.Screens.Menu const int line_end_offset = 120; smallRing.Foreground.ResizeTo(1, line_duration, Easing.OutQuint); + smallRing.Delay(400).FadeColour(Color4.Black, 300); lineTopLeft.MoveTo(new Vector2(-line_end_offset, -line_end_offset), line_duration, Easing.OutQuint); lineTopRight.MoveTo(new Vector2(line_end_offset, -line_end_offset), line_duration, Easing.OutQuint); diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs new file mode 100644 index 0000000000..a96fddb5ad --- /dev/null +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -0,0 +1,392 @@ +// 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. + +using System; +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Screens; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Framework.Timing; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; +using osu.Game.Screens.Backgrounds; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Menu +{ + public class IntroTriangles : IntroScreen + { + protected override string BeatmapHash => "a1556d0801b3a6b175dda32ef546f0ec812b400499f575c44fccbe9c67f9b1e5"; + + protected override string BeatmapFile => "triangles.osz"; + + protected override BackgroundScreen CreateBackground() => background = new BackgroundScreenDefault(false) + { + Alpha = 0, + }; + + [Resolved] + private AudioManager audio { get; set; } + + private BackgroundScreenDefault background; + + private SampleChannel welcome; + + [BackgroundDependencyLoader] + private void load() + { + if (MenuVoice.Value) + welcome = audio.Samples.Get(@"Intro/welcome"); + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); + + if (!resuming) + { + PrepareMenuLoad(); + + LoadComponentAsync(new TrianglesIntroSequence(logo, background) + { + RelativeSizeAxes = Axes.Both, + Clock = new FramedClock(UsingThemedIntro ? Track : null), + LoadMenu = LoadMenu + }, t => + { + AddInternal(t); + if (!UsingThemedIntro) + welcome?.Play(); + + StartTrack(); + }); + } + } + + public override void OnResuming(IScreen last) + { + base.OnResuming(last); + background.FadeOut(100); + } + + private class TrianglesIntroSequence : CompositeDrawable + { + private readonly OsuLogo logo; + private readonly BackgroundScreenDefault background; + private OsuSpriteText welcomeText; + + private RulesetFlow rulesets; + private Container rulesetsScale; + private Container logoContainerSecondary; + private LazerLogo lazerLogo; + + private GlitchingTriangles triangles; + + public Action LoadMenu; + + public TrianglesIntroSequence(OsuLogo logo, BackgroundScreenDefault background) + { + this.logo = logo; + this.background = background; + } + + [Resolved] + private OsuGameBase game { get; set; } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + InternalChildren = new Drawable[] + { + triangles = new GlitchingTriangles + { + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.4f, 0.16f) + }, + welcomeText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding { Bottom = 10 }, + Font = OsuFont.GetFont(weight: FontWeight.Light, size: 42), + Alpha = 1, + Spacing = new Vector2(5), + }, + rulesetsScale = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + rulesets = new RulesetFlow() + } + }, + logoContainerSecondary = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = lazerLogo = new LazerLogo + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }, + }; + } + + private const double text_1 = 200; + private const double text_2 = 400; + private const double text_3 = 700; + private const double text_4 = 900; + private const double text_glitch = 1060; + + private const double rulesets_1 = 1450; + private const double rulesets_2 = 1650; + private const double rulesets_3 = 1850; + + private const double logo_scale_duration = 920; + private const double logo_1 = 2080; + private const double logo_2 = logo_1 + logo_scale_duration; + + protected override void LoadComplete() + { + base.LoadComplete(); + + const float scale_start = 1.2f; + const float scale_adjust = 0.8f; + + rulesets.Hide(); + lazerLogo.Hide(); + background.Hide(); + + using (BeginAbsoluteSequence(0, true)) + { + using (BeginDelayedSequence(text_1, true)) + welcomeText.FadeIn().OnComplete(t => t.Text = "wel"); + + using (BeginDelayedSequence(text_2, true)) + welcomeText.FadeIn().OnComplete(t => t.Text = "welcome"); + + using (BeginDelayedSequence(text_3, true)) + welcomeText.FadeIn().OnComplete(t => t.Text = "welcome to"); + + using (BeginDelayedSequence(text_4, true)) + { + welcomeText.FadeIn().OnComplete(t => t.Text = "welcome to osu!"); + welcomeText.TransformTo(nameof(welcomeText.Spacing), new Vector2(50, 0), 5000); + } + + using (BeginDelayedSequence(text_glitch, true)) + triangles.FadeIn(); + + using (BeginDelayedSequence(rulesets_1, true)) + { + rulesetsScale.ScaleTo(0.8f, 1000); + rulesets.FadeIn().ScaleTo(1).TransformSpacingTo(new Vector2(200, 0)); + welcomeText.FadeOut(); + triangles.FadeOut(); + } + + using (BeginDelayedSequence(rulesets_2, true)) + { + rulesets.ScaleTo(2).TransformSpacingTo(new Vector2(30, 0)); + } + + using (BeginDelayedSequence(rulesets_3, true)) + { + rulesets.ScaleTo(4).TransformSpacingTo(new Vector2(10, 0)); + rulesetsScale.ScaleTo(1.3f, 1000); + } + + using (BeginDelayedSequence(logo_1, true)) + { + rulesets.FadeOut(); + + // matching flyte curve y = 0.25x^2 + (max(0, x - 0.7) / 0.3) ^ 5 + lazerLogo.FadeIn().ScaleTo(scale_start).Then().Delay(logo_scale_duration * 0.7f).ScaleTo(scale_start - scale_adjust, logo_scale_duration * 0.3f, Easing.InQuint); + + lazerLogo.TransformTo(nameof(LazerLogo.Progress), 1f, logo_scale_duration); + + logoContainerSecondary.ScaleTo(scale_start).Then().ScaleTo(scale_start - scale_adjust * 0.25f, logo_scale_duration, Easing.InQuad); + } + + using (BeginDelayedSequence(logo_2, true)) + { + lazerLogo.FadeOut().OnComplete(_ => + { + logoContainerSecondary.Remove(lazerLogo); + lazerLogo.Dispose(); // explicit disposal as we are pushing a new screen and the expire may not get run. + + logo.FadeIn(); + background.FadeIn(); + + game.Add(new GameWideFlash()); + + LoadMenu(); + }); + } + } + } + + private class GameWideFlash : Box + { + private const double flash_length = 1000; + + public GameWideFlash() + { + Colour = Color4.White; + RelativeSizeAxes = Axes.Both; + Blending = BlendingParameters.Additive; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + this.FadeOutFromOne(flash_length, Easing.Out); + } + } + + private class LazerLogo : CompositeDrawable + { + private LogoAnimation highlight, background; + + public float Progress + { + get => background.AnimationProgress; + set + { + background.AnimationProgress = value; + highlight.AnimationProgress = value; + } + } + + public LazerLogo() + { + Size = new Vector2(960); + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + InternalChildren = new Drawable[] + { + highlight = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(@"Intro/Triangles/logo-highlight"), + Colour = Color4.White, + }, + background = new LogoAnimation + { + RelativeSizeAxes = Axes.Both, + Texture = textures.Get(@"Intro/Triangles/logo-background"), + Colour = OsuColour.Gray(0.6f), + }, + }; + } + } + + private class RulesetFlow : FillFlowContainer + { + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + var modes = new List<Drawable>(); + + foreach (var ruleset in rulesets.AvailableRulesets) + { + var icon = new ConstrainedIconContainer + { + Icon = ruleset.CreateInstance().CreateIcon(), + Size = new Vector2(30), + }; + + modes.Add(icon); + } + + AutoSizeAxes = Axes.Both; + Children = modes; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + } + + private class GlitchingTriangles : CompositeDrawable + { + public GlitchingTriangles() + { + RelativeSizeAxes = Axes.Both; + } + + private double? lastGenTime; + + private const double time_between_triangles = 22; + + protected override void Update() + { + base.Update(); + + if (lastGenTime == null || Time.Current - lastGenTime > time_between_triangles) + { + lastGenTime = (lastGenTime ?? Time.Current) + time_between_triangles; + + Drawable triangle = new OutlineTriangle(RNG.NextBool(), (RNG.NextSingle() + 0.2f) * 80) + { + RelativePositionAxes = Axes.Both, + Position = new Vector2(RNG.NextSingle(), RNG.NextSingle()), + }; + + AddInternal(triangle); + + triangle.FadeOutFromOne(120); + } + } + + /// <summary> + /// Represents a sprite that is drawn in a triangle shape, instead of a rectangle shape. + /// </summary> + public class OutlineTriangle : BufferedContainer + { + public OutlineTriangle(bool outlineOnly, float size) + { + Size = new Vector2(size); + + InternalChildren = new Drawable[] + { + new Triangle { RelativeSizeAxes = Axes.Both }, + }; + + if (outlineOnly) + { + AddInternal(new Triangle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.Black, + Size = new Vector2(size - 5), + Blending = BlendingParameters.None, + }); + } + + Blending = BlendingParameters.Additive; + CacheDrawnFrameBuffer = true; + } + } + } + } + } +} diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs new file mode 100644 index 0000000000..e81646456f --- /dev/null +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -0,0 +1,152 @@ +// 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. + +using osuTK; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Screens; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Screens.Backgrounds; +using osuTK.Graphics; + +namespace osu.Game.Screens.Menu +{ + public class IntroWelcome : IntroScreen + { + protected override string BeatmapHash => "64e00d7022195959bfa3109d09c2e2276c8f12f486b91fcf6175583e973b48f2"; + protected override string BeatmapFile => "welcome.osz"; + private const double delay_step_two = 2142; + private SampleChannel welcome; + private SampleChannel pianoReverb; + protected override string SeeyaSampleName => "Intro/Welcome/seeya"; + + protected override BackgroundScreen CreateBackground() => background = new BackgroundScreenDefault(false) + { + Alpha = 0, + }; + + private BackgroundScreenDefault background; + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + if (MenuVoice.Value) + welcome = audio.Samples.Get(@"Intro/Welcome/welcome"); + + pianoReverb = audio.Samples.Get(@"Intro/Welcome/welcome_piano"); + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + base.LogoArriving(logo, resuming); + + if (!resuming) + { + Track.Looping = true; + + LoadComponentAsync(new WelcomeIntroSequence + { + RelativeSizeAxes = Axes.Both + }, intro => + { + PrepareMenuLoad(); + + intro.LogoVisualisation.AddAmplitudeSource(pianoReverb); + + AddInternal(intro); + + welcome?.Play(); + pianoReverb?.Play(); + + Scheduler.AddDelayed(() => + { + StartTrack(); + + const float fade_in_time = 200; + + logo.ScaleTo(1); + logo.FadeIn(fade_in_time); + + background.FadeIn(fade_in_time); + + LoadMenu(); + }, delay_step_two); + }); + } + } + + public override void OnResuming(IScreen last) + { + base.OnResuming(last); + background.FadeOut(100); + } + + private class WelcomeIntroSequence : Container + { + private Sprite welcomeText; + private Container scaleContainer; + + public LogoVisualisation LogoVisualisation { get; private set; } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + Origin = Anchor.Centre; + Anchor = Anchor.Centre; + + Children = new Drawable[] + { + scaleContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + LogoVisualisation = new LogoVisualisation + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0.5f, + AccentColour = Color4.DarkBlue, + Size = new Vector2(0.96f) + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(480), + Colour = Color4.Black + }, + welcomeText = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Texture = textures.Get(@"Intro/Welcome/welcome_text") + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + using (BeginDelayedSequence(0, true)) + { + scaleContainer.ScaleTo(0.9f).ScaleTo(1, delay_step_two).OnComplete(_ => Expire()); + scaleContainer.FadeInFromZero(1800); + + welcomeText.ScaleTo(new Vector2(1, 0)).ScaleTo(Vector2.One, 400, Easing.Out); + } + } + } + } +} diff --git a/osu.Game/Screens/Menu/LogoVisualisation.cs b/osu.Game/Screens/Menu/LogoVisualisation.cs index 39bda799b5..ebbb19636c 100644 --- a/osu.Game/Screens/Menu/LogoVisualisation.cs +++ b/osu.Game/Screens/Menu/LogoVisualisation.cs @@ -12,16 +12,20 @@ using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Graphics; -using osu.Game.Skinning; -using osu.Game.Online.API; -using osu.Game.Users; using System; +using System.Collections.Generic; +using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Utils; namespace osu.Game.Screens.Menu { + /// <summary> + /// A visualiser that reacts to music coming from beatmaps. + /// </summary> public class LogoVisualisation : Drawable, IHasAccentColour { private readonly IBindable<WorkingBeatmap> beatmap = new Bindable<WorkingBeatmap>(); @@ -47,7 +51,7 @@ namespace osu.Game.Screens.Menu private const float visualiser_rounds = 5; /// <summary> - /// How much should each bar go down each milisecond (based on a full bar). + /// How much should each bar go down each millisecond (based on a full bar). /// </summary> private const float decay_per_milisecond = 0.0024f; @@ -65,68 +69,63 @@ namespace osu.Game.Screens.Menu public Color4 AccentColour { get; set; } + /// <summary> + /// The relative movement of bars based on input amplification. Defaults to 1. + /// </summary> + public float Magnitude { get; set; } = 1; + private readonly float[] frequencyAmplitudes = new float[256]; private IShader shader; private readonly Texture texture; - private Bindable<User> user; - private Bindable<Skin> skin; - public LogoVisualisation() { texture = Texture.WhitePixel; - Blending = BlendingMode.Additive; + Blending = BlendingParameters.Additive; + } + + private readonly List<IHasAmplitudes> amplitudeSources = new List<IHasAmplitudes>(); + + public void AddAmplitudeSource(IHasAmplitudes amplitudeSource) + { + amplitudeSources.Add(amplitudeSource); } [BackgroundDependencyLoader] - private void load(ShaderManager shaders, IBindable<WorkingBeatmap> beatmap, IAPIProvider api, SkinManager skinManager) + private void load(ShaderManager shaders, IBindable<WorkingBeatmap> beatmap) { this.beatmap.BindTo(beatmap); shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED); - user = api.LocalUser.GetBoundCopy(); - skin = skinManager.CurrentSkin.GetBoundCopy(); - - user.ValueChanged += _ => updateColour(); - skin.BindValueChanged(_ => updateColour(), true); } + private readonly float[] temporalAmplitudes = new float[ChannelAmplitudes.AMPLITUDES_SIZE]; + private void updateAmplitudes() { - var track = beatmap.Value.TrackLoaded ? beatmap.Value.Track : null; - var effect = beatmap.Value.BeatmapLoaded ? beatmap.Value.Beatmap.ControlPointInfo.EffectPointAt(track?.CurrentTime ?? Time.Current) : null; + var effect = beatmap.Value.BeatmapLoaded && beatmap.Value.TrackLoaded + ? beatmap.Value.Beatmap?.ControlPointInfo.EffectPointAt(beatmap.Value.Track.CurrentTime) + : null; - float[] temporalAmplitudes = track?.CurrentAmplitudes.FrequencyAmplitudes; + for (int i = 0; i < temporalAmplitudes.Length; i++) + temporalAmplitudes[i] = 0; + + if (beatmap.Value.TrackLoaded) + addAmplitudesFromSource(beatmap.Value.Track); + + foreach (var source in amplitudeSources) + addAmplitudesFromSource(source); for (int i = 0; i < bars_per_visualiser; i++) { - if (track?.IsRunning ?? false) - { - float targetAmplitude = (temporalAmplitudes?[(i + indexOffset) % bars_per_visualiser] ?? 0) * (effect?.KiaiMode == true ? 1 : 0.5f); - if (targetAmplitude > frequencyAmplitudes[i]) - frequencyAmplitudes[i] = targetAmplitude; - } - else - { - int index = (i + index_change) % bars_per_visualiser; - if (frequencyAmplitudes[index] > frequencyAmplitudes[i]) - frequencyAmplitudes[i] = frequencyAmplitudes[index]; - } + float targetAmplitude = (temporalAmplitudes[(i + indexOffset) % bars_per_visualiser]) * (effect?.KiaiMode == true ? 1 : 0.5f); + if (targetAmplitude > frequencyAmplitudes[i]) + frequencyAmplitudes[i] = targetAmplitude; } indexOffset = (indexOffset + index_change) % bars_per_visualiser; } - private void updateColour() - { - Color4 defaultColour = Color4.White.Opacity(0.2f); - - if (user.Value?.IsSupporter ?? false) - AccentColour = skin.Value.GetValue<SkinConfiguration, Color4?>(s => s.CustomColours.ContainsKey("MenuGlow") ? s.CustomColours["MenuGlow"] : (Color4?)null) ?? defaultColour; - else - AccentColour = defaultColour; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -149,11 +148,24 @@ namespace osu.Game.Screens.Menu frequencyAmplitudes[i] = 0; } - Invalidate(Invalidation.DrawNode, shallPropagate: false); + Invalidate(Invalidation.DrawNode); } protected override DrawNode CreateDrawNode() => new VisualisationDrawNode(this); + private void addAmplitudesFromSource([NotNull] IHasAmplitudes source) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + + var amplitudes = source.CurrentAmplitudes.FrequencyAmplitudes.Span; + + for (int i = 0; i < amplitudes.Length; i++) + { + if (i < temporalAmplitudes.Length) + temporalAmplitudes[i] += amplitudes[i]; + } + } + private class VisualisationDrawNode : DrawNode { protected new LogoVisualisation Source => (LogoVisualisation)base.Source; @@ -161,7 +173,7 @@ namespace osu.Game.Screens.Menu private IShader shader; private Texture texture; - //Asuming the logo is a circle, we don't need a second dimension. + // Assuming the logo is a circle, we don't need a second dimension. private float size; private Color4 colour; @@ -205,16 +217,16 @@ namespace osu.Game.Screens.Menu if (audioData[i] < amplitude_dead_zone) continue; - float rotation = MathHelper.DegreesToRadians(i / (float)bars_per_visualiser * 360 + j * 360 / visualiser_rounds); - float rotationCos = (float)Math.Cos(rotation); - float rotationSin = (float)Math.Sin(rotation); - //taking the cos and sin to the 0..1 range + float rotation = MathUtils.DegreesToRadians(i / (float)bars_per_visualiser * 360 + j * 360 / visualiser_rounds); + float rotationCos = MathF.Cos(rotation); + float rotationSin = MathF.Sin(rotation); + // taking the cos and sin to the 0..1 range var barPosition = new Vector2(rotationCos / 2 + 0.5f, rotationSin / 2 + 0.5f) * size; - var barSize = new Vector2(size * (float)Math.Sqrt(2 * (1 - Math.Cos(MathHelper.DegreesToRadians(360f / bars_per_visualiser)))) / 2f, bar_length * audioData[i]); - //The distance between the position and the sides of the bar. + var barSize = new Vector2(size * MathF.Sqrt(2 * (1 - MathF.Cos(MathUtils.DegreesToRadians(360f / bars_per_visualiser)))) / 2f, bar_length * audioData[i]); + // The distance between the position and the sides of the bar. var bottomOffset = new Vector2(-rotationSin * barSize.X / 2, rotationCos * barSize.X / 2); - //The distance between the bottom side of the bar and the top side. + // The distance between the bottom side of the bar and the top side. var amplitudeOffset = new Vector2(rotationCos * barSize.Y, rotationSin * barSize.Y); var rectangle = new Quad( @@ -230,7 +242,7 @@ namespace osu.Game.Screens.Menu colourInfo, null, vertexBatch.AddAction, - //barSize by itself will make it smooth more in the X axis than in the Y axis, this reverts that. + // barSize by itself will make it smooth more in the X axis than in the Y axis, this reverts that. Vector2.Divide(inflation, barSize.Yx)); } } diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index 5999cbdfb5..c3ecd75963 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -1,6 +1,7 @@ // 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. +using System.Linq; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; @@ -8,13 +9,13 @@ 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.Screens.Backgrounds; -using osu.Game.Screens.Charts; -using osu.Game.Screens.Direct; using osu.Game.Screens.Edit; using osu.Game.Screens.Multi; using osu.Game.Screens.Select; @@ -33,6 +34,8 @@ namespace osu.Game.Screens.Menu public override bool AllowExternalScreenChange => true; + public override bool AllowRateAdjustments => false; + private Screen songSelect; private MenuSideFlashes sideFlashes; @@ -42,38 +45,78 @@ namespace osu.Game.Screens.Menu [Resolved] private GameHost host { get; set; } + [Resolved] + private MusicController musicController { get; set; } + [Resolved(canBeNull: true)] - private MusicController music { get; set; } + private LoginOverlay login { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved(canBeNull: true)] + private DialogOverlay dialogOverlay { get; set; } private BackgroundScreenDefault background; protected override BackgroundScreen CreateBackground() => background; - [BackgroundDependencyLoader(true)] - private void load(DirectOverlay direct, SettingsOverlay settings) - { - if (host.CanExit) - AddInternal(new ExitConfirmOverlay { Action = this.Exit }); + private Bindable<float> holdDelay; + private Bindable<bool> loginDisplayed; - AddRangeInternal(new Drawable[] + private ExitConfirmOverlay exitConfirmOverlay; + + private ParallaxContainer buttonsContainer; + private SongTicker songTicker; + + [BackgroundDependencyLoader(true)] + private void load(BeatmapListingOverlay beatmapListing, SettingsOverlay settings, RankingsOverlay rankings, OsuConfigManager config, SessionStatics statics) + { + holdDelay = config.GetBindable<float>(OsuSetting.UIHoldActivationDelay); + loginDisplayed = statics.GetBindable<bool>(Static.LoginOverlayDisplayed); + + if (host.CanExit) { - new ParallaxContainer + AddInternal(exitConfirmOverlay = new ExitConfirmOverlay + { + Action = () => + { + if (holdDelay.Value > 0) + confirmAndExit(); + else + this.Exit(); + } + }); + } + + AddRangeInternal(new[] + { + buttonsContainer = new ParallaxContainer { ParallaxAmount = 0.01f, Children = new Drawable[] { buttons = new ButtonSystem { - OnChart = delegate { this.Push(new ChartListing()); }, - OnDirect = delegate { this.Push(new OnlineListing()); }, - OnEdit = delegate { this.Push(new Editor()); }, + OnEdit = delegate + { + Beatmap.SetDefault(); + this.Push(new Editor()); + }, OnSolo = onSolo, OnMulti = delegate { this.Push(new Multiplayer()); }, - OnExit = this.Exit, + OnExit = confirmAndExit, } } }, sideFlashes = new MenuSideFlashes(), + songTicker = new SongTicker + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Right = 15, Top = 5 } + }, + exitConfirmOverlay?.CreateProxy() ?? Drawable.Empty() }); buttons.StateChanged += state => @@ -92,12 +135,24 @@ namespace osu.Game.Screens.Menu }; buttons.OnSettings = () => settings?.ToggleVisibility(); - buttons.OnDirect = () => direct?.ToggleVisibility(); + buttons.OnBeatmapListing = () => beatmapListing?.ToggleVisibility(); + buttons.OnChart = () => rankings?.ShowSpotlights(); LoadComponentAsync(background = new BackgroundScreenDefault()); preloadSongSelect(); } + [Resolved(canBeNull: true)] + private OsuGame game { get; set; } + + private void confirmAndExit() + { + if (exitConfirmed) return; + + exitConfirmed = true; + game?.PerformFromScreen(menu => menu.Exit()); + } + private void preloadSongSelect() { if (songSelect == null) @@ -115,26 +170,31 @@ namespace osu.Game.Screens.Menu return s; } + [Resolved] + private Storage storage { get; set; } + public override void OnEntering(IScreen last) { base.OnEntering(last); buttons.FadeInFromZero(500); - var track = Beatmap.Value.Track; var metadata = Beatmap.Value.Metadata; - if (last is Intro && track != null) + if (last is IntroScreen && musicController.TrackLoaded) { - if (!track.IsRunning) + if (!musicController.CurrentTrack.IsRunning) { - track.Seek(metadata.PreviewTime != -1 ? metadata.PreviewTime : 0.4f * track.Length); - track.Start(); + musicController.CurrentTrack.Seek(metadata.PreviewTime != -1 ? metadata.PreviewTime : 0.4f * musicController.CurrentTrack.Length); + musicController.CurrentTrack.Start(); } } - Beatmap.ValueChanged += beatmap_ValueChanged; + if (storage is OsuStorage osuStorage && osuStorage.Error != OsuStorageError.None) + dialogOverlay?.Push(new StorageErrorDialog(osuStorage, osuStorage.Error)); } + private bool exitConfirmed; + protected override void LogoArriving(OsuLogo logo, bool resuming) { base.LogoArriving(logo, resuming); @@ -149,10 +209,25 @@ namespace osu.Game.Screens.Menu buttons.State = ButtonSystemState.TopLevel; this.FadeIn(FADE_IN_DURATION, Easing.OutQuint); - this.MoveTo(new Vector2(0, 0), FADE_IN_DURATION, Easing.OutQuint); + buttonsContainer.MoveTo(new Vector2(0, 0), FADE_IN_DURATION, Easing.OutQuint); sideFlashes.Delay(FADE_IN_DURATION).FadeIn(64, Easing.InQuint); } + else if (!api.IsLoggedIn) + { + logo.Action += displayLogin; + } + + bool displayLogin() + { + if (!loginDisplayed.Value) + { + Scheduler.AddDelayed(() => login?.Show(), 500); + loginDisplayed.Value = true; + } + + return true; + } } protected override void LogoSuspending(OsuLogo logo) @@ -164,14 +239,6 @@ namespace osu.Game.Screens.Menu seq.OnAbort(_ => buttons.SetOsuLogo(null)); } - private void beatmap_ValueChanged(ValueChangedEvent<WorkingBeatmap> e) - { - if (!this.IsCurrentScreen()) - return; - - ((BackgroundScreenDefault)Background).Next(); - } - public override void OnSuspending(IScreen next) { base.OnSuspending(next); @@ -179,7 +246,7 @@ namespace osu.Game.Screens.Menu buttons.State = ButtonSystemState.EnteringMode; this.FadeOut(FADE_OUT_DURATION, Easing.InSine); - this.MoveTo(new Vector2(-800, 0), FADE_OUT_DURATION, Easing.InSine); + buttonsContainer.MoveTo(new Vector2(-800, 0), FADE_OUT_DURATION, Easing.InSine); sideFlashes.FadeOut(64, Easing.OutQuint); } @@ -190,16 +257,33 @@ namespace osu.Game.Screens.Menu (Background as BackgroundScreenDefault)?.Next(); - //we may have consumed our preloaded instance, so let's make another. + // we may have consumed our preloaded instance, so let's make another. preloadSongSelect(); - if (Beatmap.Value.Track != null && music?.IsUserPaused != true) - Beatmap.Value.Track.Start(); + musicController.EnsurePlayingSomething(); } public override bool OnExiting(IScreen next) { + if (!exitConfirmed && dialogOverlay != null) + { + if (dialogOverlay.CurrentDialog is ConfirmExitDialog exitDialog) + { + exitConfirmed = true; + exitDialog.Buttons.First().Click(); + } + else + { + dialogOverlay.Push(new ConfirmExitDialog(confirmAndExit, () => exitConfirmOverlay.Abort())); + return true; + } + } + buttons.State = ButtonSystemState.Exit; + OverlayActivationMode.Value = OverlayActivation.Disabled; + + songTicker.Hide(); + this.FadeOut(3000); return base.OnExiting(next); } diff --git a/osu.Game/Screens/Menu/MenuLogoVisualisation.cs b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs new file mode 100644 index 0000000000..5eb3f1efa0 --- /dev/null +++ b/osu.Game/Screens/Menu/MenuLogoVisualisation.cs @@ -0,0 +1,39 @@ +// 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. + +using osuTK.Graphics; +using osu.Game.Skinning; +using osu.Game.Online.API; +using osu.Game.Users; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; + +namespace osu.Game.Screens.Menu +{ + internal class MenuLogoVisualisation : LogoVisualisation + { + private Bindable<User> user; + private Bindable<Skin> skin; + + [BackgroundDependencyLoader] + private void load(IAPIProvider api, SkinManager skinManager) + { + user = api.LocalUser.GetBoundCopy(); + skin = skinManager.CurrentSkin.GetBoundCopy(); + + user.ValueChanged += _ => updateColour(); + skin.BindValueChanged(_ => updateColour(), true); + } + + private void updateColour() + { + Color4 defaultColour = Color4.White.Opacity(0.2f); + + if (user.Value?.IsSupporter ?? false) + AccentColour = skin.Value.GetConfig<GlobalSkinColours, Color4>(GlobalSkinColours.MenuGlow)?.Value ?? defaultColour; + else + AccentColour = defaultColour; + } + } +} diff --git a/osu.Game/Screens/Menu/MenuSideFlashes.cs b/osu.Game/Screens/Menu/MenuSideFlashes.cs index 95d0bf04b4..2ff8132d47 100644 --- a/osu.Game/Screens/Menu/MenuSideFlashes.cs +++ b/osu.Game/Screens/Menu/MenuSideFlashes.cs @@ -3,7 +3,6 @@ using osuTK.Graphics; using osu.Framework.Allocation; -using osu.Framework.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -16,6 +15,7 @@ using osu.Game.Skinning; using osu.Game.Online.API; using osu.Game.Users; using System; +using osu.Framework.Audio.Track; using osu.Framework.Bindables; namespace osu.Game.Screens.Menu @@ -70,7 +70,7 @@ namespace osu.Game.Screens.Menu // align off-screen to make sure our edges don't become visible during parallax. X = -box_width, Alpha = 0, - Blending = BlendingMode.Additive + Blending = BlendingParameters.Additive }, rightBox = new Box { @@ -81,7 +81,7 @@ namespace osu.Game.Screens.Menu Height = 1.5f, X = box_width, Alpha = 0, - Blending = BlendingMode.Additive + Blending = BlendingParameters.Additive } }; @@ -89,7 +89,7 @@ namespace osu.Game.Screens.Menu skin.BindValueChanged(_ => updateColour(), true); } - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { if (beatIndex < 0) return; @@ -100,9 +100,9 @@ namespace osu.Game.Screens.Menu flash(rightBox, timingPoint.BeatLength, effectPoint.KiaiMode, amplitudes); } - private void flash(Drawable d, double beatLength, bool kiai, TrackAmplitudes amplitudes) + private void flash(Drawable d, double beatLength, bool kiai, ChannelAmplitudes amplitudes) { - d.FadeTo(Math.Max(0, ((d.Equals(leftBox) ? amplitudes.LeftChannel : amplitudes.RightChannel) - amplitude_dead_zone) / (kiai ? kiai_multiplier : alpha_multiplier)), box_fade_in_time) + d.FadeTo(Math.Max(0, ((ReferenceEquals(d, leftBox) ? amplitudes.LeftChannel : amplitudes.RightChannel) - amplitude_dead_zone) / (kiai ? kiai_multiplier : alpha_multiplier)), box_fade_in_time) .Then() .FadeOut(beatLength, Easing.In); } @@ -112,7 +112,7 @@ namespace osu.Game.Screens.Menu Color4 baseColour = colours.Blue; if (user.Value?.IsSupporter ?? false) - baseColour = skin.Value.GetValue<SkinConfiguration, Color4?>(s => s.CustomColours.ContainsKey("MenuGlow") ? s.CustomColours["MenuGlow"] : (Color4?)null) ?? baseColour; + baseColour = skin.Value.GetConfig<GlobalSkinColours, Color4>(GlobalSkinColours.MenuGlow)?.Value ?? baseColour; // linear colour looks better in this case, so let's use it for now. Color4 gradientDark = baseColour.Opacity(0).ToLinear(); diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index 479b3d80b6..4515ee8ed0 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -6,17 +6,18 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; +using osu.Game.Overlays; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -28,7 +29,7 @@ namespace osu.Game.Screens.Menu /// </summary> public class OsuLogo : BeatSyncedContainer { - public readonly Color4 OsuPink = OsuColour.FromHex(@"e967a1"); + public readonly Color4 OsuPink = Color4Extensions.FromHex(@"e967a1"); private const double transition_length = 300; @@ -38,7 +39,7 @@ namespace osu.Game.Screens.Menu private readonly Container logoBeatContainer; private readonly Container logoAmplitudeContainer; private readonly Container logoHoverContainer; - private readonly LogoVisualisation visualizer; + private readonly MenuLogoVisualisation visualizer; private readonly IntroSequence intro; @@ -46,7 +47,6 @@ namespace osu.Game.Screens.Menu private SampleChannel sampleBeat; private readonly Container colourAndTriangles; - private readonly Triangles triangles; /// <summary> @@ -124,7 +124,7 @@ namespace osu.Game.Screens.Menu { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Alpha = 0 } } @@ -139,7 +139,7 @@ namespace osu.Game.Screens.Menu AutoSizeAxes = Axes.Both, Children = new Drawable[] { - visualizer = new LogoVisualisation + visualizer = new MenuLogoVisualisation { RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, @@ -176,8 +176,8 @@ namespace osu.Game.Screens.Menu triangles = new Triangles { TriangleScale = 4, - ColourLight = OsuColour.FromHex(@"ff7db7"), - ColourDark = OsuColour.FromHex(@"de5b95"), + ColourLight = Color4Extensions.FromHex(@"ff7db7"), + ColourDark = Color4Extensions.FromHex(@"de5b95"), RelativeSizeAxes = Axes.Both, }, } @@ -185,7 +185,7 @@ namespace osu.Game.Screens.Menu flashLayer = new Box { RelativeSizeAxes = Axes.Both, - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Colour = Color4.White, Alpha = 0, }, @@ -229,7 +229,7 @@ namespace osu.Game.Screens.Menu } /// <summary> - /// Schedule a new extenral animation. Handled queueing and finishing previous animations in a sane way. + /// Schedule a new external animation. Handled queueing and finishing previous animations in a sane way. /// </summary> /// <param name="action">The animation to be performed</param> /// <param name="waitForPrevious">If true, the new animation is delayed until all previous transforms finish. If false, existing transformed are cleared.</param> @@ -264,7 +264,7 @@ namespace osu.Game.Screens.Menu private int lastBeatIndex; - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes) + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); @@ -319,6 +319,9 @@ namespace osu.Game.Screens.Menu intro.Delay(length + fade).FadeOut(); } + [Resolved] + private MusicController musicController { get; set; } + protected override void Update() { base.Update(); @@ -327,10 +330,10 @@ namespace osu.Game.Screens.Menu const float velocity_adjust_cutoff = 0.98f; const float paused_velocity = 0.5f; - if (Beatmap.Value.Track.IsRunning) + if (musicController.CurrentTrack.IsRunning) { - var maxAmplitude = lastBeatIndex >= 0 ? Beatmap.Value.Track.CurrentAmplitudes.Maximum : 0; - logoAmplitudeContainer.ScaleTo(1 - Math.Max(0, maxAmplitude - scale_adjust_cutoff) * 0.04f, 75, Easing.OutQuint); + var maxAmplitude = lastBeatIndex >= 0 ? musicController.CurrentTrack.CurrentAmplitudes.Maximum : 0; + logoAmplitudeContainer.Scale = new Vector2((float)Interpolation.Damp(logoAmplitudeContainer.Scale.X, 1 - Math.Max(0, maxAmplitude - scale_adjust_cutoff) * 0.04f, 0.9f, Time.Elapsed)); if (maxAmplitude > velocity_adjust_cutoff) triangles.Velocity = 1 + Math.Max(0, maxAmplitude - velocity_adjust_cutoff) * 50; @@ -353,12 +356,11 @@ namespace osu.Game.Screens.Menu return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { - if (e.Button != MouseButton.Left) return false; + if (e.Button != MouseButton.Left) return; logoBounceContainer.ScaleTo(1f, 500, Easing.OutElastic); - return true; } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/Menu/SongTicker.cs b/osu.Game/Screens/Menu/SongTicker.cs new file mode 100644 index 0000000000..c4943e77d5 --- /dev/null +++ b/osu.Game/Screens/Menu/SongTicker.cs @@ -0,0 +1,72 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osu.Game.Graphics; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Framework.Localisation; + +namespace osu.Game.Screens.Menu +{ + public class SongTicker : Container + { + private const int fade_duration = 800; + + [Resolved] + private Bindable<WorkingBeatmap> beatmap { get; set; } + + private readonly OsuSpriteText title, artist; + + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; + + public SongTicker() + { + AutoSizeAxes = Axes.Both; + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 3), + Children = new Drawable[] + { + title = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light, italics: true) + }, + artist = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.GetFont(size: 16) + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(_ => Scheduler.AddOnce(show), true); + } + + private void show() + { + var metadata = beatmap.Value.Metadata; + + title.Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)); + artist.Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)); + + this.FadeInFromZero(fade_duration / 2f) + .Delay(4000) + .Then().FadeOut(fade_duration); + } + } +} diff --git a/osu.Game/Screens/Menu/StorageErrorDialog.cs b/osu.Game/Screens/Menu/StorageErrorDialog.cs new file mode 100644 index 0000000000..dcaad4013a --- /dev/null +++ b/osu.Game/Screens/Menu/StorageErrorDialog.cs @@ -0,0 +1,79 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; +using osu.Game.IO; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Menu +{ + public class StorageErrorDialog : PopupDialog + { + [Resolved] + private DialogOverlay dialogOverlay { get; set; } + + [Resolved] + private OsuGameBase osuGame { get; set; } + + public StorageErrorDialog(OsuStorage storage, OsuStorageError error) + { + HeaderText = "osu! storage error"; + Icon = FontAwesome.Solid.ExclamationTriangle; + + var buttons = new List<PopupDialogButton>(); + + switch (error) + { + case OsuStorageError.NotAccessible: + BodyText = $"The specified osu! data location (\"{storage.CustomStoragePath}\") is not accessible. If it is on external storage, please reconnect the device and try again."; + + buttons.AddRange(new PopupDialogButton[] + { + new PopupDialogCancelButton + { + Text = "Try again", + Action = () => + { + if (!storage.TryChangeToCustomStorage(out var nextError)) + dialogOverlay.Push(new StorageErrorDialog(storage, nextError)); + } + }, + new PopupDialogCancelButton + { + Text = "Use default location until restart", + }, + new PopupDialogOkButton + { + Text = "Reset to default location", + Action = storage.ResetCustomStoragePath + }, + }); + break; + + case OsuStorageError.AccessibleButEmpty: + BodyText = $"The specified osu! data location (\"{storage.CustomStoragePath}\") is empty. If you have moved the files, please close osu! and move them back."; + + // Todo: Provide the option to search for the files similar to migration. + buttons.AddRange(new PopupDialogButton[] + { + new PopupDialogCancelButton + { + Text = "Start fresh at specified location" + }, + new PopupDialogOkButton + { + Text = "Reset to default location", + Action = storage.ResetCustomStoragePath + }, + }); + + break; + } + + Buttons = buttons; + } + } +} diff --git a/osu.Game/Screens/Multi/Components/BeatmapDetailAreaPlaylistTabItem.cs b/osu.Game/Screens/Multi/Components/BeatmapDetailAreaPlaylistTabItem.cs new file mode 100644 index 0000000000..3f2ab28f1a --- /dev/null +++ b/osu.Game/Screens/Multi/Components/BeatmapDetailAreaPlaylistTabItem.cs @@ -0,0 +1,12 @@ +// 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. + +using osu.Game.Screens.Select; + +namespace osu.Game.Screens.Multi.Components +{ + public class BeatmapDetailAreaPlaylistTabItem : BeatmapDetailAreaTabItem + { + public override string Name => "Playlist"; + } +} diff --git a/osu.Game/Screens/Multi/Components/BeatmapTitle.cs b/osu.Game/Screens/Multi/Components/BeatmapTitle.cs index e096fb33da..9e7a59d7d2 100644 --- a/osu.Game/Screens/Multi/Components/BeatmapTitle.cs +++ b/osu.Game/Screens/Multi/Components/BeatmapTitle.cs @@ -1,6 +1,7 @@ // 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. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -25,7 +26,9 @@ namespace osu.Game.Screens.Multi.Components [BackgroundDependencyLoader] private void load() { - CurrentItem.BindValueChanged(_ => updateText(), true); + Playlist.CollectionChanged += (_, __) => updateText(); + + updateText(); } private float textSize = OsuFont.DEFAULT_FONT_SIZE; @@ -54,21 +57,23 @@ namespace osu.Game.Screens.Multi.Components textFlow.Clear(); - var beatmap = CurrentItem.Value?.Beatmap; + var beatmap = Playlist.FirstOrDefault()?.Beatmap; if (beatmap == null) + { textFlow.AddText("No beatmap selected", s => { s.Font = s.Font.With(size: TextSize); s.Colour = colours.PinkLight; }); + } else { textFlow.AddLink(new[] { new OsuSpriteText { - Text = new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)), + Text = new LocalisedString((beatmap.Value.Metadata.ArtistUnicode, beatmap.Value.Metadata.Artist)), Font = OsuFont.GetFont(size: TextSize), }, new OsuSpriteText @@ -78,10 +83,10 @@ namespace osu.Game.Screens.Multi.Components }, new OsuSpriteText { - Text = new LocalisedString((beatmap.Metadata.TitleUnicode, beatmap.Metadata.Title)), + Text = new LocalisedString((beatmap.Value.Metadata.TitleUnicode, beatmap.Value.Metadata.Title)), Font = OsuFont.GetFont(size: TextSize), } - }, null, LinkAction.OpenBeatmap, beatmap.OnlineBeatmapID.ToString(), "Open beatmap"); + }, LinkAction.OpenBeatmap, beatmap.Value.OnlineBeatmapID.ToString(), "Open beatmap"); } } } diff --git a/osu.Game/Screens/Multi/Components/BeatmapTypeInfo.cs b/osu.Game/Screens/Multi/Components/BeatmapTypeInfo.cs index d63f2fecd2..ce3b612262 100644 --- a/osu.Game/Screens/Multi/Components/BeatmapTypeInfo.cs +++ b/osu.Game/Screens/Multi/Components/BeatmapTypeInfo.cs @@ -1,6 +1,7 @@ // 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. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,6 +13,8 @@ namespace osu.Game.Screens.Multi.Components { public class BeatmapTypeInfo : MultiplayerComposite { + private LinkFlowContainer beatmapAuthor; + public BeatmapTypeInfo() { AutoSizeAxes = Axes.Both; @@ -20,8 +23,6 @@ namespace osu.Game.Screens.Multi.Components [BackgroundDependencyLoader] private void load() { - LinkFlowContainer beatmapAuthor; - InternalChild = new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -50,18 +51,22 @@ namespace osu.Game.Screens.Multi.Components } }; - CurrentItem.BindValueChanged(item => + Playlist.CollectionChanged += (_, __) => updateInfo(); + + updateInfo(); + } + + private void updateInfo() + { + beatmapAuthor.Clear(); + + var beatmap = Playlist.FirstOrDefault()?.Beatmap; + + if (beatmap != null) { - beatmapAuthor.Clear(); - - var beatmap = item.NewValue?.Beatmap; - - if (beatmap != null) - { - beatmapAuthor.AddText("mapped by ", s => s.Colour = OsuColour.Gray(0.8f)); - beatmapAuthor.AddUserLink(beatmap.Metadata.Author); - } - }, true); + beatmapAuthor.AddText("mapped by ", s => s.Colour = OsuColour.Gray(0.8f)); + beatmapAuthor.AddUserLink(beatmap.Value.Metadata.Author); + } } } } diff --git a/osu.Game/Screens/Multi/Components/DrawableGameType.cs b/osu.Game/Screens/Multi/Components/DrawableGameType.cs index f4941dd73a..28240f0796 100644 --- a/osu.Game/Screens/Multi/Components/DrawableGameType.cs +++ b/osu.Game/Screens/Multi/Components/DrawableGameType.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -27,7 +28,7 @@ namespace osu.Game.Screens.Multi.Components new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"545454"), + Colour = Color4Extensions.FromHex(@"545454"), }, }; } diff --git a/osu.Game/Screens/Multi/Components/MatchBeatmapDetailArea.cs b/osu.Game/Screens/Multi/Components/MatchBeatmapDetailArea.cs new file mode 100644 index 0000000000..2c5fd2d397 --- /dev/null +++ b/osu.Game/Screens/Multi/Components/MatchBeatmapDetailArea.cs @@ -0,0 +1,98 @@ +// 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. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.Select; +using osuTK; + +namespace osu.Game.Screens.Multi.Components +{ + public class MatchBeatmapDetailArea : BeatmapDetailArea + { + public Action CreateNewItem; + + public readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>(); + + [Resolved(typeof(Room))] + protected BindableList<PlaylistItem> Playlist { get; private set; } + + private readonly Drawable playlistArea; + private readonly DrawableRoomPlaylist playlist; + + public MatchBeatmapDetailArea() + { + Add(playlistArea = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = 10 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 10 }, + Child = playlist = new DrawableRoomPlaylist(true, false) + { + RelativeSizeAxes = Axes.Both, + } + } + }, + new Drawable[] + { + new TriangleButton + { + Text = "Add new playlist entry", + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + Action = () => CreateNewItem?.Invoke() + } + }, + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 50), + } + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + playlist.Items.BindTo(Playlist); + playlist.SelectedItem.BindTo(SelectedItem); + } + + protected override void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) + { + base.OnTabChanged(tab, selectedMods); + + switch (tab) + { + case BeatmapDetailAreaPlaylistTabItem _: + playlistArea.Show(); + break; + + default: + playlistArea.Hide(); + break; + } + } + + protected override BeatmapDetailAreaTabItem[] CreateTabItems() => base.CreateTabItems().Prepend(new BeatmapDetailAreaPlaylistTabItem()).ToArray(); + } +} diff --git a/osu.Game/Screens/Multi/Components/ModeTypeInfo.cs b/osu.Game/Screens/Multi/Components/ModeTypeInfo.cs index 6080458aec..f07bd8c3b2 100644 --- a/osu.Game/Screens/Multi/Components/ModeTypeInfo.cs +++ b/osu.Game/Screens/Multi/Components/ModeTypeInfo.cs @@ -1,11 +1,11 @@ // 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. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps.Drawables; -using osu.Game.Online.Multiplayer; using osuTK; namespace osu.Game.Screens.Multi.Components @@ -46,17 +46,21 @@ namespace osu.Game.Screens.Multi.Components }, }; - CurrentItem.BindValueChanged(item => updateBeatmap(item.NewValue), true); - Type.BindValueChanged(type => gameTypeContainer.Child = new DrawableGameType(type.NewValue) { Size = new Vector2(height) }, true); + + Playlist.CollectionChanged += (_, __) => updateBeatmap(); + + updateBeatmap(); } - private void updateBeatmap(PlaylistItem item) + private void updateBeatmap() { + var item = Playlist.FirstOrDefault(); + if (item?.Beatmap != null) { drawableRuleset.FadeIn(transition_duration); - drawableRuleset.Child = new DifficultyIcon(item.Beatmap, item.Ruleset) { Size = new Vector2(height) }; + drawableRuleset.Child = new DifficultyIcon(item.Beatmap.Value, item.Ruleset.Value, item.RequiredMods) { Size = new Vector2(height) }; } else drawableRuleset.FadeOut(transition_duration); diff --git a/osu.Game/Screens/Multi/Components/MultiplayerBackgroundSprite.cs b/osu.Game/Screens/Multi/Components/MultiplayerBackgroundSprite.cs index 968fa6e72e..2240e55e2f 100644 --- a/osu.Game/Screens/Multi/Components/MultiplayerBackgroundSprite.cs +++ b/osu.Game/Screens/Multi/Components/MultiplayerBackgroundSprite.cs @@ -1,6 +1,7 @@ // 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. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps.Drawables; @@ -10,6 +11,7 @@ namespace osu.Game.Screens.Multi.Components public class MultiplayerBackgroundSprite : MultiplayerComposite { private readonly BeatmapSetCoverType beatmapSetCoverType; + private UpdateableBeatmapBackgroundSprite sprite; public MultiplayerBackgroundSprite(BeatmapSetCoverType beatmapSetCoverType = BeatmapSetCoverType.Cover) { @@ -19,11 +21,16 @@ namespace osu.Game.Screens.Multi.Components [BackgroundDependencyLoader] private void load() { - UpdateableBeatmapBackgroundSprite sprite; - InternalChild = sprite = CreateBackgroundSprite(); - CurrentItem.BindValueChanged(item => sprite.Beatmap.Value = item.NewValue?.Beatmap, true); + Playlist.CollectionChanged += (_, __) => updateBeatmap(); + + updateBeatmap(); + } + + private void updateBeatmap() + { + sprite.Beatmap.Value = Playlist.FirstOrDefault()?.Beatmap.Value; } protected virtual UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new UpdateableBeatmapBackgroundSprite(beatmapSetCoverType) { RelativeSizeAxes = Axes.Both }; diff --git a/osu.Game/Screens/Multi/Components/OverlinedHeader.cs b/osu.Game/Screens/Multi/Components/OverlinedHeader.cs new file mode 100644 index 0000000000..7ec20c8cae --- /dev/null +++ b/osu.Game/Screens/Multi/Components/OverlinedHeader.cs @@ -0,0 +1,89 @@ +// 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. + +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.Sprites; +using osuTK; + +namespace osu.Game.Screens.Multi.Components +{ + /// <summary> + /// A header used in the multiplayer interface which shows text / details beneath a line. + /// </summary> + public class OverlinedHeader : MultiplayerComposite + { + private bool showLine = true; + + public bool ShowLine + { + get => showLine; + set + { + showLine = value; + line.Alpha = value ? 1 : 0; + } + } + + public Bindable<string> Details = new Bindable<string>(); + + private readonly Circle line; + private readonly OsuSpriteText details; + + public OverlinedHeader(string title) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Margin = new MarginPadding { Bottom = 5 }; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + line = new Circle + { + RelativeSizeAxes = Axes.X, + Height = 2, + Margin = new MarginPadding { Bottom = 2 } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Top = 5 }, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = title, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold) + }, + details = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold) + }, + } + }, + } + }; + + Details.BindValueChanged(val => details.Text = val.NewValue); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + line.Colour = colours.Yellow; + details.Colour = colours.Yellow; + } + } +} diff --git a/osu.Game/Screens/Multi/Components/ParticipantsDisplay.cs b/osu.Game/Screens/Multi/Components/ParticipantsDisplay.cs new file mode 100644 index 0000000000..6ea4283379 --- /dev/null +++ b/osu.Game/Screens/Multi/Components/ParticipantsDisplay.cs @@ -0,0 +1,59 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Screens.Multi.Components +{ + public class ParticipantsDisplay : MultiplayerComposite + { + public Bindable<string> Details = new Bindable<string>(); + + public ParticipantsDisplay(Direction direction) + { + OsuScrollContainer scroll; + ParticipantsList list; + + AddInternal(scroll = new OsuScrollContainer(direction) + { + Child = list = new ParticipantsList() + }); + + switch (direction) + { + case Direction.Horizontal: + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + + scroll.RelativeSizeAxes = Axes.X; + scroll.Height = ParticipantsList.TILE_SIZE + OsuScrollContainer.SCROLL_BAR_HEIGHT + OsuScrollContainer.SCROLL_BAR_PADDING * 2; + + list.RelativeSizeAxes = Axes.Y; + list.AutoSizeAxes = Axes.X; + break; + + case Direction.Vertical: + RelativeSizeAxes = Axes.Both; + + scroll.RelativeSizeAxes = Axes.Both; + + list.RelativeSizeAxes = Axes.X; + list.AutoSizeAxes = Axes.Y; + break; + } + } + + [BackgroundDependencyLoader] + private void load() + { + ParticipantCount.BindValueChanged(_ => setParticipantCount()); + MaxParticipants.BindValueChanged(_ => setParticipantCount(), true); + } + + private void setParticipantCount() => + Details.Value = MaxParticipants.Value != null ? $"{ParticipantCount.Value}/{MaxParticipants.Value}" : ParticipantCount.Value.ToString(); + } +} diff --git a/osu.Game/Screens/Multi/Components/ParticipantsList.cs b/osu.Game/Screens/Multi/Components/ParticipantsList.cs new file mode 100644 index 0000000000..7978b4eaab --- /dev/null +++ b/osu.Game/Screens/Multi/Components/ParticipantsList.cs @@ -0,0 +1,124 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Threading; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.Multi.Components +{ + public class ParticipantsList : MultiplayerComposite + { + public const float TILE_SIZE = 35; + + public override Axes RelativeSizeAxes + { + get => base.RelativeSizeAxes; + set + { + base.RelativeSizeAxes = value; + + if (tiles != null) + tiles.RelativeSizeAxes = value; + } + } + + public new Axes AutoSizeAxes + { + get => base.AutoSizeAxes; + set + { + base.AutoSizeAxes = value; + + if (tiles != null) + tiles.AutoSizeAxes = value; + } + } + + private FillDirection direction = FillDirection.Full; + + public FillDirection Direction + { + get => direction; + set + { + direction = value; + + if (tiles != null) + tiles.Direction = value; + } + } + + [BackgroundDependencyLoader] + private void load() + { + RecentParticipants.CollectionChanged += (_, __) => updateParticipants(); + updateParticipants(); + } + + private ScheduledDelegate scheduledUpdate; + private FillFlowContainer<UserTile> tiles; + + private void updateParticipants() + { + scheduledUpdate?.Cancel(); + scheduledUpdate = Schedule(() => + { + tiles?.FadeOut(250, Easing.Out).Expire(); + + tiles = new FillFlowContainer<UserTile> + { + Alpha = 0, + Direction = Direction, + AutoSizeAxes = AutoSizeAxes, + RelativeSizeAxes = RelativeSizeAxes, + Spacing = Vector2.One + }; + + for (int i = 0; i < RecentParticipants.Count; i++) + tiles.Add(new UserTile { User = RecentParticipants[i] }); + + AddInternal(tiles); + + tiles.Delay(250).FadeIn(250, Easing.OutQuint); + }); + } + + private class UserTile : CompositeDrawable, IHasTooltip + { + public User User + { + get => avatar.User; + set => avatar.User = value; + } + + public string TooltipText => User?.Username ?? string.Empty; + + private readonly UpdateableAvatar avatar; + + public UserTile() + { + Size = new Vector2(TILE_SIZE); + CornerRadius = 5f; + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"27252d"), + }, + avatar = new UpdateableAvatar { RelativeSizeAxes = Axes.Both }, + }; + } + } + } +} diff --git a/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs b/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs index 97af6674bf..a115f06e7b 100644 --- a/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs +++ b/osu.Game/Screens/Multi/Components/StatusColouredContainer.cs @@ -17,6 +17,9 @@ namespace osu.Game.Screens.Multi.Components [Resolved(typeof(Room), nameof(Room.Status))] private Bindable<RoomStatus> status { get; set; } + [Resolved(typeof(Room), nameof(Room.Category))] + private Bindable<RoomCategory> category { get; set; } + public StatusColouredContainer(double transitionDuration = 100) { this.transitionDuration = transitionDuration; @@ -25,7 +28,11 @@ namespace osu.Game.Screens.Multi.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - status.BindValueChanged(s => this.FadeColour(s.NewValue.GetAppropriateColour(colours), transitionDuration), true); + status.BindValueChanged(s => + { + this.FadeColour(category.Value == RoomCategory.Spotlight ? colours.Pink : s.NewValue.GetAppropriateColour(colours) + , transitionDuration); + }, true); } } } diff --git a/osu.Game/Screens/Multi/DrawableRoomPlaylist.cs b/osu.Game/Screens/Multi/DrawableRoomPlaylist.cs new file mode 100644 index 0000000000..89c335183b --- /dev/null +++ b/osu.Game/Screens/Multi/DrawableRoomPlaylist.cs @@ -0,0 +1,76 @@ +// 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. + +using System.Collections.Specialized; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Multiplayer; +using osuTK; + +namespace osu.Game.Screens.Multi +{ + public class DrawableRoomPlaylist : OsuRearrangeableListContainer<PlaylistItem> + { + public readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>(); + + private readonly bool allowEdit; + private readonly bool allowSelection; + + public DrawableRoomPlaylist(bool allowEdit, bool allowSelection) + { + this.allowEdit = allowEdit; + this.allowSelection = allowSelection; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Scheduled since items are removed and re-added upon rearrangement + Items.CollectionChanged += (_, args) => Schedule(() => + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Remove: + if (args.OldItems.Contains(SelectedItem)) + SelectedItem.Value = null; + break; + } + }); + } + + protected override ScrollContainer<Drawable> CreateScrollContainer() => base.CreateScrollContainer().With(d => + { + d.ScrollbarVisible = false; + }); + + protected override FillFlowContainer<RearrangeableListItem<PlaylistItem>> CreateListFillFlowContainer() => new FillFlowContainer<RearrangeableListItem<PlaylistItem>> + { + LayoutDuration = 200, + LayoutEasing = Easing.OutQuint, + Spacing = new Vector2(0, 2) + }; + + protected override OsuRearrangeableListItem<PlaylistItem> CreateOsuDrawable(PlaylistItem item) => new DrawableRoomPlaylistItem(item, allowEdit, allowSelection) + { + SelectedItem = { BindTarget = SelectedItem }, + RequestDeletion = requestDeletion + }; + + private void requestDeletion(PlaylistItem item) + { + if (SelectedItem.Value == item) + { + if (Items.Count == 1) + SelectedItem.Value = null; + else + SelectedItem.Value = Items.GetNext(item) ?? Items[^2]; + } + + Items.Remove(item); + } + } +} diff --git a/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs new file mode 100644 index 0000000000..bda00b65b5 --- /dev/null +++ b/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs @@ -0,0 +1,320 @@ +// 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. + +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.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online; +using osu.Game.Online.Chat; +using osu.Game.Online.Multiplayer; +using osu.Game.Overlays.BeatmapListing.Panels; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play.HUD; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Multi +{ + public class DrawableRoomPlaylistItem : OsuRearrangeableListItem<PlaylistItem> + { + public Action<PlaylistItem> RequestDeletion; + + public readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>(); + + private Container maskingContainer; + private Container difficultyIconContainer; + private LinkFlowContainer beatmapText; + private LinkFlowContainer authorText; + private ModDisplay modDisplay; + + private readonly Bindable<BeatmapInfo> beatmap = new Bindable<BeatmapInfo>(); + private readonly Bindable<RulesetInfo> ruleset = new Bindable<RulesetInfo>(); + private readonly BindableList<Mod> requiredMods = new BindableList<Mod>(); + + public readonly PlaylistItem Item; + + private readonly bool allowEdit; + private readonly bool allowSelection; + + protected override bool ShouldBeConsideredForInput(Drawable child) => allowEdit || !allowSelection || SelectedItem.Value == Model; + + public DrawableRoomPlaylistItem(PlaylistItem item, bool allowEdit, bool allowSelection) + : base(item) + { + Item = item; + + // TODO: edit support should be moved out into a derived class + this.allowEdit = allowEdit; + this.allowSelection = allowSelection; + + beatmap.BindTo(item.Beatmap); + ruleset.BindTo(item.Ruleset); + requiredMods.BindTo(item.RequiredMods); + + ShowDragHandle.Value = allowEdit; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + if (!allowEdit) + HandleColour = HandleColour.Opacity(0); + + maskingContainer.BorderColour = colours.Yellow; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedItem.BindValueChanged(selected => maskingContainer.BorderThickness = selected.NewValue == Model ? 5 : 0, true); + + beatmap.BindValueChanged(_ => scheduleRefresh()); + ruleset.BindValueChanged(_ => scheduleRefresh()); + + requiredMods.CollectionChanged += (_, __) => scheduleRefresh(); + + refresh(); + } + + private ScheduledDelegate scheduledRefresh; + + private void scheduleRefresh() + { + scheduledRefresh?.Cancel(); + scheduledRefresh = Schedule(refresh); + } + + private void refresh() + { + 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()); + + authorText.Clear(); + + if (Item.Beatmap?.Value?.Metadata?.Author != null) + { + authorText.AddText("mapped by "); + authorText.AddUserLink(Item.Beatmap.Value?.Metadata.Author); + } + + modDisplay.Current.Value = requiredMods.ToArray(); + } + + protected override Drawable CreateContent() => maskingContainer = new Container + { + RelativeSizeAxes = Axes.X, + Height = 50, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box // A transparent box that forces the border to be drawn if the panel background is opaque + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + }, + new PanelBackground + { + RelativeSizeAxes = Axes.Both, + Beatmap = { BindTarget = beatmap } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 8 }, + Spacing = new Vector2(8, 0), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + difficultyIconContainer = new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + beatmapText = new LinkFlowContainer { AutoSizeAxes = Axes.Both }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(15, 0), + Children = new Drawable[] + { + authorText = new LinkFlowContainer { AutoSizeAxes = Axes.Both }, + modDisplay = new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Scale = new Vector2(0.4f), + DisplayUnrankedText = false, + ExpansionMode = ExpansionMode.AlwaysExpanded + } + } + } + } + } + } + }, + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + X = -18, + ChildrenEnumerable = CreateButtons() + } + } + }; + + protected virtual IEnumerable<Drawable> CreateButtons() => + new Drawable[] + { + new PlaylistDownloadButton(Item) + { + Size = new Vector2(50, 30) + }, + new IconButton + { + Icon = FontAwesome.Solid.MinusSquare, + Alpha = allowEdit ? 1 : 0, + Action = () => RequestDeletion?.Invoke(Model), + }, + }; + + protected override bool OnClick(ClickEvent e) + { + if (allowSelection) + SelectedItem.Value = Model; + return true; + } + + private class PlaylistDownloadButton : BeatmapPanelDownloadButton + { + private readonly PlaylistItem playlistItem; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + public PlaylistDownloadButton(PlaylistItem playlistItem) + : base(playlistItem.Beatmap.Value.BeatmapSet) + { + this.playlistItem = playlistItem; + Alpha = 0; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + State.BindValueChanged(stateChanged, true); + FinishTransforms(true); + } + + private void stateChanged(ValueChangedEvent<DownloadState> state) + { + switch (state.NewValue) + { + case DownloadState.LocallyAvailable: + // Perform a local query of the beatmap by beatmap checksum, and reset the state if not matching. + if (beatmapManager.QueryBeatmap(b => b.MD5Hash == playlistItem.Beatmap.Value.MD5Hash) == null) + State.Value = DownloadState.NotDownloaded; + else + this.FadeTo(0, 500); + + break; + + default: + this.FadeTo(1, 500); + break; + } + } + } + + // For now, this is the same implementation as in PanelBackground, but supports a beatmap info rather than a working beatmap + private class PanelBackground : Container // todo: should be a buffered container (https://github.com/ppy/osu-framework/issues/3222) + { + public readonly Bindable<BeatmapInfo> Beatmap = new Bindable<BeatmapInfo>(); + + public PanelBackground() + { + InternalChildren = new Drawable[] + { + new UpdateableBeatmapBackgroundSprite + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Beatmap = { BindTarget = Beatmap } + }, + new FillFlowContainer + { + Depth = -1, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + // This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle + Shear = new Vector2(0.8f, 0), + Alpha = 0.5f, + Children = new[] + { + // The left half with no gradient applied + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Width = 0.4f, + }, + // Piecewise-linear gradient with 3 segments to make it appear smoother + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)), + Width = 0.05f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)), + Width = 0.2f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)), + Width = 0.05f, + }, + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/Multi/DrawableRoomPlaylistWithResults.cs b/osu.Game/Screens/Multi/DrawableRoomPlaylistWithResults.cs new file mode 100644 index 0000000000..439aaaa275 --- /dev/null +++ b/osu.Game/Screens/Multi/DrawableRoomPlaylistWithResults.cs @@ -0,0 +1,66 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Screens.Multi +{ + public class DrawableRoomPlaylistWithResults : DrawableRoomPlaylist + { + public Action<PlaylistItem> RequestShowResults; + + public DrawableRoomPlaylistWithResults() + : base(false, true) + { + } + + protected override OsuRearrangeableListItem<PlaylistItem> CreateOsuDrawable(PlaylistItem item) => + new DrawableRoomPlaylistItemWithResults(item, false, true) + { + RequestShowResults = () => RequestShowResults(item), + SelectedItem = { BindTarget = SelectedItem }, + }; + + private class DrawableRoomPlaylistItemWithResults : DrawableRoomPlaylistItem + { + public Action RequestShowResults; + + public DrawableRoomPlaylistItemWithResults(PlaylistItem item, bool allowEdit, bool allowSelection) + : base(item, allowEdit, allowSelection) + { + } + + protected override IEnumerable<Drawable> CreateButtons() => + base.CreateButtons().Prepend(new FilledIconButton + { + Icon = FontAwesome.Solid.ChartPie, + Action = () => RequestShowResults?.Invoke(), + TooltipText = "View results" + }); + + private class FilledIconButton : IconButton + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Add(new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + Colour = colours.Gray4, + }); + } + } + } + } +} diff --git a/osu.Game/Screens/Multi/Header.cs b/osu.Game/Screens/Multi/Header.cs index 1cbf2a45e7..cd8695286b 100644 --- a/osu.Game/Screens/Multi/Header.cs +++ b/osu.Game/Screens/Multi/Header.cs @@ -1,55 +1,60 @@ // 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. +using Humanizer; using osu.Framework.Allocation; +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.Screens; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; -using osu.Game.Overlays.SearchableList; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Multi { public class Header : Container { - public const float HEIGHT = 121; - - private readonly HeaderBreadcrumbControl breadcrumbs; + public const float HEIGHT = 80; public Header(ScreenStack stack) { - MultiHeaderTitle title; RelativeSizeAxes = Axes.X; Height = HEIGHT; + HeaderBreadcrumbControl breadcrumbs; + MultiHeaderTitle title; + Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"2f2043"), + Colour = Color4Extensions.FromHex(@"#1f1921"), }, new Container { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, + Padding = new MarginPadding { Left = WaveOverlayContainer.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, Children = new Drawable[] { title = new MultiHeaderTitle { Anchor = Anchor.CentreLeft, Origin = Anchor.BottomLeft, - X = -ScreenTitle.ICON_WIDTH, }, breadcrumbs = new HeaderBreadcrumbControl(stack) { Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - }, + Origin = Anchor.BottomLeft + } }, }, }; @@ -63,25 +68,61 @@ namespace osu.Game.Screens.Multi breadcrumbs.Current.TriggerChange(); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + private class MultiHeaderTitle : CompositeDrawable { - breadcrumbs.StripColour = colours.Green; - } + private const float spacing = 6; + + private readonly OsuSpriteText dot; + private readonly OsuSpriteText pageTitle; - private class MultiHeaderTitle : ScreenTitle - { public IMultiplayerSubScreen Screen { - set => Section = value.ShortTitle.ToLowerInvariant(); + set => pageTitle.Text = value.ShortTitle.Titleize(); + } + + public MultiHeaderTitle() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(spacing, 0), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 24), + Text = "Multiplayer" + }, + dot = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 24), + Text = "·" + }, + pageTitle = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 24), + Text = "Lounge" + } + } + }, + }; } [BackgroundDependencyLoader] private void load(OsuColour colours) { - Title = "multi"; - Icon = OsuIcon.Multi; - AccentColour = colours.Yellow; + pageTitle.Colour = dot.Colour = colours.Yellow; } } @@ -90,12 +131,28 @@ namespace osu.Game.Screens.Multi public HeaderBreadcrumbControl(ScreenStack stack) : base(stack) { + RelativeSizeAxes = Axes.X; + StripColour = Color4.Transparent; } protected override void LoadComplete() { base.LoadComplete(); - AccentColour = Color4.White; + AccentColour = Color4Extensions.FromHex("#e35c99"); + } + + protected override TabItem<IScreen> CreateTabItem(IScreen value) => new HeaderBreadcrumbTabItem(value) + { + AccentColour = AccentColour + }; + + private class HeaderBreadcrumbTabItem : BreadcrumbTabItem + { + public HeaderBreadcrumbTabItem(IScreen value) + : base(value) + { + Bar.Colour = Color4.Transparent; + } } } } diff --git a/osu.Game/Screens/Multi/IRoomManager.cs b/osu.Game/Screens/Multi/IRoomManager.cs index f6c979851e..bf75843c3e 100644 --- a/osu.Game/Screens/Multi/IRoomManager.cs +++ b/osu.Game/Screens/Multi/IRoomManager.cs @@ -14,6 +14,11 @@ namespace osu.Game.Screens.Multi /// </summary> event Action RoomsUpdated; + /// <summary> + /// Whether an initial listing of rooms has been received. + /// </summary> + Bindable<bool> InitialRoomsReceived { get; } + /// <summary> /// All the active <see cref="Room"/>s. /// </summary> diff --git a/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs index 6ec8f2bfe5..01a85382e4 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs @@ -21,10 +21,12 @@ using osu.Game.Online.Multiplayer; using osu.Game.Screens.Multi.Components; using osuTK; using osuTK.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; namespace osu.Game.Screens.Multi.Lounge.Components { - public class DrawableRoom : OsuClickableContainer, IStateful<SelectionState>, IFilterable + public class DrawableRoom : OsuClickableContainer, IStateful<SelectionState>, IFilterable, IHasContextMenu { public const float SELECTION_BORDER_WIDTH = 4; private const float corner_radius = 5; @@ -39,6 +41,9 @@ namespace osu.Game.Screens.Multi.Lounge.Components private readonly Box selectionBox; private CachedModelDependencyContainer<Room> dependencies; + [Resolved(canBeNull: true)] + private Multiplayer multiplayer { get; set; } + [Resolved] private BeatmapManager beatmaps { get; set; } @@ -74,7 +79,14 @@ namespace osu.Game.Screens.Multi.Lounge.Components set { matchingFilter = value; - this.FadeTo(MatchingFilter ? 1 : 0, 200); + + if (!IsLoaded) + return; + + if (matchingFilter) + this.FadeIn(200); + else + Hide(); } } @@ -100,6 +112,8 @@ namespace osu.Game.Screens.Multi.Lounge.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { + float stripWidth = side_strip_width * (Room.Category.Value == RoomCategory.Spotlight ? 2 : 1); + Children = new Drawable[] { new StatusColouredContainer(transition_duration) @@ -127,12 +141,12 @@ namespace osu.Game.Screens.Multi.Lounge.Components new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"212121"), + Colour = Color4Extensions.FromHex(@"212121"), }, new StatusColouredContainer(transition_duration) { RelativeSizeAxes = Axes.Y, - Width = side_strip_width, + Width = stripWidth, Child = new Box { RelativeSizeAxes = Axes.Both } }, new Container @@ -140,7 +154,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components RelativeSizeAxes = Axes.Y, Width = cover_width, Masking = true, - Margin = new MarginPadding { Left = side_strip_width }, + Margin = new MarginPadding { Left = stripWidth }, Child = new MultiplayerBackgroundSprite(BeatmapSetCoverType.List) { RelativeSizeAxes = Axes.Both } }, new Container @@ -149,7 +163,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components Padding = new MarginPadding { Vertical = content_padding, - Left = side_strip_width + cover_width + content_padding, + Left = stripWidth + cover_width + content_padding, Right = content_padding, }, Children = new Drawable[] @@ -203,9 +217,15 @@ namespace osu.Game.Screens.Multi.Lounge.Components protected override void LoadComplete() { base.LoadComplete(); - this.FadeInFromZero(transition_duration); + + if (matchingFilter) + this.FadeInFromZero(transition_duration); + else + Alpha = 0; } + protected override bool ShouldBeConsideredForInput(Drawable child) => state == SelectionState.Selected; + private class RoomName : OsuSpriteText { [Resolved(typeof(Room), nameof(Online.Multiplayer.Room.Name))] @@ -217,5 +237,13 @@ namespace osu.Game.Screens.Multi.Lounge.Components Current = name; } } + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("Create copy", MenuItemType.Standard, () => + { + multiplayer?.CreateRoom(Room.CreateCopy()); + }) + }; } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs b/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs index 8e14f76e4b..be1083ce8d 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/FilterControl.cs @@ -4,22 +4,28 @@ using System.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Game.Graphics; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Threading; using osu.Game.Overlays.SearchableList; +using osu.Game.Rulesets; using osuTK.Graphics; namespace osu.Game.Screens.Multi.Lounge.Components { - public class FilterControl : SearchableListFilterControl<PrimaryFilter, SecondaryFilter> + public class FilterControl : SearchableListFilterControl<RoomStatusFilter, RoomCategoryFilter> { - protected override Color4 BackgroundColour => OsuColour.FromHex(@"362e42"); - protected override PrimaryFilter DefaultTab => PrimaryFilter.Open; + protected override Color4 BackgroundColour => Color4.Black.Opacity(0.5f); + protected override RoomStatusFilter DefaultTab => RoomStatusFilter.Open; + protected override RoomCategoryFilter DefaultCategory => RoomCategoryFilter.Any; protected override float ContentHorizontalPadding => base.ContentHorizontalPadding + OsuScreen.HORIZONTAL_OVERFLOW_PADDING; [Resolved(CanBeNull = true)] private Bindable<FilterCriteria> filter { get; set; } + [Resolved] + private IBindable<RulesetInfo> ruleset { get; set; } + public FilterControl() { DisplayStyleControl.Hide(); @@ -28,42 +34,55 @@ namespace osu.Game.Screens.Multi.Lounge.Components [BackgroundDependencyLoader] private void load() { - if (filter == null) - filter = new Bindable<FilterCriteria>(); + filter ??= new Bindable<FilterCriteria>(); } protected override void LoadComplete() { base.LoadComplete(); - Search.Current.BindValueChanged(_ => updateFilter()); + ruleset.BindValueChanged(_ => updateFilter()); + Search.Current.BindValueChanged(_ => scheduleUpdateFilter()); + Dropdown.Current.BindValueChanged(_ => updateFilter()); Tabs.Current.BindValueChanged(_ => updateFilter(), true); } + private ScheduledDelegate scheduledFilterUpdate; + + private void scheduleUpdateFilter() + { + scheduledFilterUpdate?.Cancel(); + scheduledFilterUpdate = Scheduler.AddDelayed(updateFilter, 200); + } + private void updateFilter() { + scheduledFilterUpdate?.Cancel(); + filter.Value = new FilterCriteria { SearchString = Search.Current.Value ?? string.Empty, - PrimaryFilter = Tabs.Current.Value, - SecondaryFilter = DisplayStyleControl.Dropdown.Current.Value + StatusFilter = Tabs.Current.Value, + RoomCategoryFilter = Dropdown.Current.Value, + Ruleset = ruleset.Value }; } } - public enum PrimaryFilter + public enum RoomStatusFilter { Open, [Description("Recently Ended")] - RecentlyEnded, + Ended, Participated, Owned, } - public enum SecondaryFilter + public enum RoomCategoryFilter { - Public, - //Private, + Any, + Normal, + Spotlight } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs b/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs index 666bc44a8d..6d70225eec 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/FilterCriteria.cs @@ -1,12 +1,15 @@ // 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. +using osu.Game.Rulesets; + namespace osu.Game.Screens.Multi.Lounge.Components { public class FilterCriteria { public string SearchString; - public PrimaryFilter PrimaryFilter; - public SecondaryFilter SecondaryFilter; + public RoomStatusFilter StatusFilter; + public RoomCategoryFilter RoomCategoryFilter; + public RulesetInfo Ruleset; } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/ParticipantInfo.cs b/osu.Game/Screens/Multi/Lounge/Components/ParticipantInfo.cs index a55db096af..4152a9a3b2 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/ParticipantInfo.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/ParticipantInfo.cs @@ -77,7 +77,7 @@ namespace osu.Game.Screens.Multi.Lounge.Components if (host.NewValue != null) { hostText.AddText("hosted by "); - hostText.AddUserLink(host.NewValue, s => s.Font = s.Font.With(Typeface.Exo, weight: FontWeight.Bold, italics: true)); + hostText.AddUserLink(host.NewValue, s => s.Font = s.Font.With(Typeface.Torus, weight: FontWeight.Bold, italics: true)); flagContainer.Child = new UpdateableFlag(host.NewValue.Country) { RelativeSizeAxes = Axes.Both }; } diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomInfo.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomInfo.cs new file mode 100644 index 0000000000..e6f6ce5ed2 --- /dev/null +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomInfo.cs @@ -0,0 +1,93 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Screens.Multi.Components; +using osuTK; + +namespace osu.Game.Screens.Multi.Lounge.Components +{ + public class RoomInfo : MultiplayerComposite + { + private readonly List<Drawable> statusElements = new List<Drawable>(); + private readonly OsuTextFlowContainer roomName; + + public RoomInfo() + { + AutoSizeAxes = Axes.Y; + + RoomStatusInfo statusInfo; + ModeTypeInfo typeInfo; + ParticipantInfo participantInfo; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 4), + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + roomName = new OsuTextFlowContainer(t => t.Font = OsuFont.GetFont(size: 30)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + statusInfo = new RoomStatusInfo(), + } + }, + typeInfo = new ModeTypeInfo + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight + } + } + }, + participantInfo = new ParticipantInfo(), + } + }; + + statusElements.AddRange(new Drawable[] { statusInfo, typeInfo, participantInfo }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (RoomID.Value == null) + statusElements.ForEach(e => e.FadeOut()); + + RoomID.BindValueChanged(id => + { + if (id.NewValue == null) + statusElements.ForEach(e => e.FadeOut(100)); + else + statusElements.ForEach(e => e.FadeIn(100)); + }, true); + + RoomName.BindValueChanged(name => + { + roomName.Text = name.NewValue ?? "No room selected"; + }, true); + } + } +} diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs index 5030d8cb50..77fbd606f4 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomInspector.cs @@ -1,26 +1,13 @@ // 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. -using System; 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.Cursor; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Online.Multiplayer; using osu.Game.Screens.Multi.Components; -using osu.Game.Users; -using osu.Game.Users.Drawables; -using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Multi.Lounge.Components @@ -31,295 +18,74 @@ namespace osu.Game.Screens.Multi.Lounge.Components private readonly MarginPadding contentPadding = new MarginPadding { Horizontal = 20, Vertical = 10 }; - private ParticipantCountDisplay participantCount; - private OsuSpriteText name; - private BeatmapTypeInfo beatmapTypeInfo; - private ParticipantInfo participantInfo; - [Resolved] private BeatmapManager beatmaps { get; set; } - private readonly Bindable<RoomStatus> status = new Bindable<RoomStatus>(new RoomStatusNoneSelected()); - [BackgroundDependencyLoader] private void load(OsuColour colours) { + OverlinedHeader participantsHeader; + InternalChildren = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"343138"), + Colour = Color4.Black, + Alpha = 0.25f }, - new GridContainer + new Container { RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + Padding = new MarginPadding { Horizontal = 30 }, + Child = new GridContainer { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Distributed), - }, - Content = new[] - { - new Drawable[] + RelativeSizeAxes = Axes.Both, + Content = new[] { - new FillFlowContainer + new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new FillFlowContainer { - new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - Height = 200, - Masking = true, - Children = new Drawable[] + new RoomInfo { - new MultiplayerBackgroundSprite { RelativeSizeAxes = Axes.Both }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.5f), Color4.Black.Opacity(0)), - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding(20), - Children = new Drawable[] - { - participantCount = new ParticipantCountDisplay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }, - name = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Font = OsuFont.GetFont(size: 30), - Current = RoomName - }, - }, - }, + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Vertical = 60 }, }, - }, - new StatusColouredContainer(transition_duration) - { - RelativeSizeAxes = Axes.X, - Height = 5, - Child = new Box { RelativeSizeAxes = Axes.Both } - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] + participantsHeader = new OverlinedHeader("Recent Participants"), + new ParticipantsDisplay(Direction.Vertical) { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"28242d"), - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - LayoutDuration = transition_duration, - Padding = contentPadding, - Spacing = new Vector2(0f, 5f), - Children = new Drawable[] - { - new StatusColouredContainer(transition_duration) - { - AutoSizeAxes = Axes.Both, - Child = new StatusText - { - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), - } - }, - beatmapTypeInfo = new BeatmapTypeInfo(), - }, - }, - }, - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = contentPadding, - Children = new Drawable[] - { - participantInfo = new ParticipantInfo(), - }, - }, + RelativeSizeAxes = Axes.X, + Height = ParticipantsList.TILE_SIZE * 3, + Details = { BindTarget = participantsHeader.Details } + } + } + } + }, + new Drawable[] { new OverlinedHeader("Playlist"), }, + new Drawable[] + { + new DrawableRoomPlaylist(false, false) + { + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = Playlist } }, }, }, - new Drawable[] + RowDimensions = new[] { - new MatchParticipants - { - RelativeSizeAxes = Axes.Both, - } + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), } } } }; - - Status.BindValueChanged(_ => updateStatus(), true); - RoomID.BindValueChanged(_ => updateStatus(), true); - } - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.CacheAs(status, new CacheInfo(nameof(Room.Status), typeof(Room))); - return dependencies; - } - - private void updateStatus() - { - if (RoomID.Value == null) - { - status.Value = new RoomStatusNoneSelected(); - - participantCount.FadeOut(transition_duration); - beatmapTypeInfo.FadeOut(transition_duration); - name.FadeOut(transition_duration); - participantInfo.FadeOut(transition_duration); - } - else - { - status.Value = Status.Value; - - participantCount.FadeIn(transition_duration); - beatmapTypeInfo.FadeIn(transition_duration); - name.FadeIn(transition_duration); - participantInfo.FadeIn(transition_duration); - } - } - - private class RoomStatusNoneSelected : RoomStatus - { - public override string Message => @"No Room Selected"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.Gray8; - } - - private class StatusText : OsuSpriteText - { - [Resolved(typeof(Room), nameof(Room.Status))] - private Bindable<RoomStatus> status { get; set; } - - [BackgroundDependencyLoader] - private void load() - { - status.BindValueChanged(s => Text = s.NewValue.Message, true); - } - } - - private class MatchParticipants : MultiplayerComposite - { - private readonly FillFlowContainer fill; - - public MatchParticipants() - { - Padding = new MarginPadding { Horizontal = 10 }; - - InternalChild = new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Child = fill = new FillFlowContainer - { - Spacing = new Vector2(10), - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Full, - } - }; - } - - [BackgroundDependencyLoader] - private void load() - { - RoomID.BindValueChanged(_ => updateParticipants(), true); - } - - [Resolved] - private IAPIProvider api { get; set; } - - private GetRoomScoresRequest request; - - private void updateParticipants() - { - var roomId = RoomID.Value ?? 0; - - request?.Cancel(); - - // nice little progressive fade - int time = 500; - - foreach (var c in fill.Children) - { - c.Delay(500 - time).FadeOut(time, Easing.Out); - time = Math.Max(20, time - 20); - c.Expire(); - } - - if (roomId == 0) return; - - request = new GetRoomScoresRequest(roomId); - request.Success += scores => - { - if (roomId != RoomID.Value) - return; - - fill.Clear(); - foreach (var s in scores) - fill.Add(new UserTile(s.User)); - - fill.FadeInFromZero(1000, Easing.OutQuint); - }; - - api.Queue(request); - } - - protected override void Dispose(bool isDisposing) - { - request?.Cancel(); - base.Dispose(isDisposing); - } - - private class UserTile : CompositeDrawable, IHasTooltip - { - private readonly User user; - - public string TooltipText => user.Username; - - public UserTile(User user) - { - this.user = user; - Size = new Vector2(70f); - CornerRadius = 5f; - Masking = true; - - InternalChildren = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"27252d"), - }, - new UpdateableAvatar - { - RelativeSizeAxes = Axes.Both, - User = user, - }, - }; - } - } } } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs index 99a6de0064..c7c37cbc0d 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs @@ -3,19 +3,25 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Threading; +using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Online.Multiplayer; using osuTK; +using osu.Game.Graphics.Cursor; namespace osu.Game.Screens.Multi.Lounge.Components { - public class RoomsContainer : CompositeDrawable + public class RoomsContainer : CompositeDrawable, IKeyBindingHandler<GlobalAction> { public Action<Room> JoinRequested; @@ -24,40 +30,46 @@ namespace osu.Game.Screens.Multi.Lounge.Components private readonly FillFlowContainer<DrawableRoom> roomFlow; public IReadOnlyList<DrawableRoom> Rooms => roomFlow; + [Resolved(CanBeNull = true)] + private Bindable<FilterCriteria> filter { get; set; } + [Resolved] - private Bindable<Room> currentRoom { get; set; } + private Bindable<Room> selectedRoom { get; set; } [Resolved] private IRoomManager roomManager { get; set; } + [Resolved(CanBeNull = true)] + private LoungeSubScreen loungeSubScreen { get; set; } + public RoomsContainer() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - InternalChild = roomFlow = new FillFlowContainer<DrawableRoom> + InternalChild = new OsuContextMenuContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(2), + Child = roomFlow = new FillFlowContainer<DrawableRoom> + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + } }; } - [BackgroundDependencyLoader] - private void load() + protected override void LoadComplete() { - rooms.BindTo(roomManager.Rooms); - - rooms.ItemsAdded += addRooms; - rooms.ItemsRemoved += removeRooms; - + rooms.CollectionChanged += roomsChanged; roomManager.RoomsUpdated += updateSorting; - addRooms(rooms); - } + rooms.BindTo(roomManager.Rooms); - private FilterCriteria currentFilter; + filter?.BindValueChanged(criteria => Filter(criteria.NewValue)); + } public void Filter(FilterCriteria criteria) { @@ -68,29 +80,51 @@ namespace osu.Game.Screens.Multi.Lounge.Components else { bool matchingFilter = true; - matchingFilter &= r.FilterTerms.Any(term => term.IndexOf(criteria.SearchString, StringComparison.InvariantCultureIgnoreCase) >= 0); - switch (criteria.SecondaryFilter) - { - default: - case SecondaryFilter.Public: - r.MatchingFilter = r.Room.Availability.Value == RoomAvailability.Public; - break; - } + matchingFilter &= r.Room.Playlist.Count == 0 || r.Room.Playlist.Any(i => i.Ruleset.Value.Equals(criteria.Ruleset)); + + if (!string.IsNullOrEmpty(criteria.SearchString)) + matchingFilter &= r.FilterTerms.Any(term => term.Contains(criteria.SearchString, StringComparison.InvariantCultureIgnoreCase)); r.MatchingFilter = matchingFilter; } }); + } - currentFilter = criteria; + private void roomsChanged(object sender, NotifyCollectionChangedEventArgs args) + { + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + addRooms(args.NewItems.Cast<Room>()); + break; + + case NotifyCollectionChangedAction.Remove: + removeRooms(args.OldItems.Cast<Room>()); + break; + } } private void addRooms(IEnumerable<Room> rooms) { - foreach (var r in rooms) - roomFlow.Add(new DrawableRoom(r) { Action = () => selectRoom(r) }); + foreach (var room in rooms) + { + roomFlow.Add(new DrawableRoom(room) + { + Action = () => + { + if (room == selectedRoom.Value) + { + joinSelected(); + return; + } - Filter(currentFilter); + selectRoom(room); + } + }); + } + + Filter(filter?.Value); } private void removeRooms(IEnumerable<Room> rooms) @@ -114,16 +148,100 @@ namespace osu.Game.Screens.Multi.Lounge.Components private void selectRoom(Room room) { - var drawable = roomFlow.FirstOrDefault(r => r.Room == room); - - if (drawable != null && drawable.State == SelectionState.Selected) - JoinRequested?.Invoke(room); - else - roomFlow.Children.ForEach(r => r.State = r.Room == room ? SelectionState.Selected : SelectionState.NotSelected); - - currentRoom.Value = room; + roomFlow.Children.ForEach(r => r.State = r.Room == room ? SelectionState.Selected : SelectionState.NotSelected); + selectedRoom.Value = room; } + private void joinSelected() + { + if (selectedRoom.Value == null) return; + + JoinRequested?.Invoke(selectedRoom.Value); + } + + #region Key selection logic (shared with BeatmapCarousel) + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.Select: + joinSelected(); + return true; + + case GlobalAction.SelectNext: + beginRepeatSelection(() => selectNext(1), action); + return true; + + case GlobalAction.SelectPrevious: + beginRepeatSelection(() => selectNext(-1), action); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + switch (action) + { + case GlobalAction.SelectNext: + case GlobalAction.SelectPrevious: + endRepeatSelection(action); + break; + } + } + + private ScheduledDelegate repeatDelegate; + private object lastRepeatSource; + + /// <summary> + /// Begin repeating the specified selection action. + /// </summary> + /// <param name="action">The action to perform.</param> + /// <param name="source">The source of the action. Used in conjunction with <see cref="endRepeatSelection"/> to only cancel the correct action (most recently pressed key).</param> + private void beginRepeatSelection(Action action, object source) + { + endRepeatSelection(); + + lastRepeatSource = source; + repeatDelegate = this.BeginKeyRepeat(Scheduler, action); + } + + private void endRepeatSelection(object source = null) + { + // only the most recent source should be able to cancel the current action. + if (source != null && !EqualityComparer<object>.Default.Equals(lastRepeatSource, source)) + return; + + repeatDelegate?.Cancel(); + repeatDelegate = null; + lastRepeatSource = null; + } + + private void selectNext(int direction) + { + var visibleRooms = Rooms.AsEnumerable().Where(r => r.IsPresent); + + Room room; + + if (selectedRoom.Value == null) + room = visibleRooms.FirstOrDefault()?.Room; + else + { + if (direction < 0) + visibleRooms = visibleRooms.Reverse(); + + room = visibleRooms.SkipWhile(r => r.Room != selectedRoom.Value).Skip(1).FirstOrDefault()?.Room; + } + + // we already have a valid selection only change selection if we still have a room to switch to. + if (room != null) + selectRoom(room); + } + + #endregion + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs index 7f8e690516..4dc9ba549b 100644 --- a/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs @@ -1,6 +1,7 @@ // 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. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -10,26 +11,41 @@ using osu.Framework.Screens; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; -using osu.Game.Overlays.SearchableList; +using osu.Game.Overlays; using osu.Game.Screens.Multi.Lounge.Components; using osu.Game.Screens.Multi.Match; +using osu.Game.Users; namespace osu.Game.Screens.Multi.Lounge { + [Cached] public class LoungeSubScreen : MultiplayerSubScreen { public override string Title => "Lounge"; - protected readonly FilterControl Filter; + protected FilterControl Filter; - private readonly Container content; - private readonly ProcessingOverlay processingOverlay; + protected override UserActivity InitialActivity => new UserActivity.SearchingForLobby(); + + private readonly Bindable<bool> initialRoomsReceived = new Bindable<bool>(); + + private Container content; + private LoadingLayer loadingLayer; [Resolved] - private Bindable<Room> currentRoom { get; set; } + private Bindable<Room> selectedRoom { get; set; } - public LoungeSubScreen() + [Resolved] + private MusicController music { get; set; } + + private bool joiningRoom; + + [BackgroundDependencyLoader] + private void load() { + RoomsContainer roomsContainer; + OsuScrollContainer scrollContainer; + InternalChildren = new Drawable[] { Filter = new FilterControl { Depth = -1 }, @@ -44,19 +60,14 @@ namespace osu.Game.Screens.Multi.Lounge Width = 0.55f, Children = new Drawable[] { - new OsuScrollContainer + scrollContainer = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarOverlapsContent = false, Padding = new MarginPadding(10), - Child = new SearchContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = new RoomsContainer { JoinRequested = joinRequested } - }, + Child = roomsContainer = new RoomsContainer { JoinRequested = joinRequested } }, - processingOverlay = new ProcessingOverlay { Alpha = 0 } + loadingLayer = new LoadingLayer(roomsContainer), } }, new RoomInspector @@ -70,7 +81,21 @@ namespace osu.Game.Screens.Multi.Lounge }, }; - Filter.Search.Exit += this.Exit; + // scroll selected room into view on selection. + selectedRoom.BindValueChanged(val => + { + var drawable = roomsContainer.Rooms.FirstOrDefault(r => r.Room == val.NewValue); + if (drawable != null) + scrollContainer.ScrollIntoView(drawable); + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + initialRoomsReceived.BindTo(RoomManager.InitialRoomsReceived); + initialRoomsReceived.BindValueChanged(onInitialRoomsReceivedChanged, true); } protected override void UpdateAfterChildren() @@ -80,8 +105,8 @@ namespace osu.Game.Screens.Multi.Lounge content.Padding = new MarginPadding { Top = Filter.DrawHeight, - Left = SearchableListOverlay.WIDTH_PADDING - DrawableRoom.SELECTION_BORDER_WIDTH + HORIZONTAL_OVERFLOW_PADDING, - Right = SearchableListOverlay.WIDTH_PADDING + HORIZONTAL_OVERFLOW_PADDING, + Left = WaveOverlayContainer.WIDTH_PADDING - DrawableRoom.SELECTION_BORDER_WIDTH + HORIZONTAL_OVERFLOW_PADDING, + Right = WaveOverlayContainer.WIDTH_PADDING + HORIZONTAL_OVERFLOW_PADDING, }; } @@ -93,6 +118,24 @@ namespace osu.Game.Screens.Multi.Lounge public override void OnEntering(IScreen last) { base.OnEntering(last); + + onReturning(); + } + + public override void OnResuming(IScreen last) + { + base.OnResuming(last); + + if (selectedRoom.Value?.RoomID.Value == null) + selectedRoom.Value = new Room(); + + music?.EnsurePlayingSomething(); + + onReturning(); + } + + private void onReturning() + { Filter.Search.HoldFocus = true; } @@ -108,22 +151,31 @@ namespace osu.Game.Screens.Multi.Lounge Filter.Search.HoldFocus = false; } - public override void OnResuming(IScreen last) - { - base.OnResuming(last); - - if (currentRoom.Value?.RoomID.Value == null) - currentRoom.Value = new Room(); - } - private void joinRequested(Room room) { - processingOverlay.Show(); + joiningRoom = true; + updateLoadingLayer(); + RoomManager?.JoinRoom(room, r => { Open(room); - processingOverlay.Hide(); - }, _ => processingOverlay.Hide()); + joiningRoom = false; + updateLoadingLayer(); + }, _ => + { + joiningRoom = false; + updateLoadingLayer(); + }); + } + + private void onInitialRoomsReceivedChanged(ValueChangedEvent<bool> received) => updateLoadingLayer(); + + private void updateLoadingLayer() + { + if (joiningRoom || !initialRoomsReceived.Value) + loadingLayer.Show(); + else + loadingLayer.Hide(); } /// <summary> @@ -135,7 +187,7 @@ namespace osu.Game.Screens.Multi.Lounge if (!this.IsCurrentScreen()) return; - currentRoom.Value = room; + selectedRoom.Value = room; this.Push(new MatchSubScreen(room)); } diff --git a/osu.Game/Screens/Multi/Match/Components/Footer.cs b/osu.Game/Screens/Multi/Match/Components/Footer.cs new file mode 100644 index 0000000000..be4ee873fa --- /dev/null +++ b/osu.Game/Screens/Multi/Match/Components/Footer.cs @@ -0,0 +1,51 @@ +// 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. + +using System; +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.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osuTK; + +namespace osu.Game.Screens.Multi.Match.Components +{ + public class Footer : CompositeDrawable + { + public const float HEIGHT = 50; + + public Action OnStart; + public readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>(); + + private readonly Drawable background; + + public Footer() + { + RelativeSizeAxes = Axes.X; + Height = HEIGHT; + + InternalChildren = new[] + { + background = new Box { RelativeSizeAxes = Axes.Both }, + new ReadyButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(600, 50), + SelectedItem = { BindTarget = SelectedItem }, + Action = () => OnStart?.Invoke() + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + background.Colour = Color4Extensions.FromHex(@"28242d"); + } + } +} diff --git a/osu.Game/Screens/Multi/Match/Components/Header.cs b/osu.Game/Screens/Multi/Match/Components/Header.cs index 73994fa369..134a0b3f2e 100644 --- a/osu.Game/Screens/Multi/Match/Components/Header.cs +++ b/osu.Game/Screens/Multi/Match/Components/Header.cs @@ -1,33 +1,23 @@ // 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. -using System; 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.Drawables; using osu.Game.Graphics; -using osu.Game.Online.Multiplayer; -using osu.Game.Overlays.SearchableList; -using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Multi.Components; -using osu.Game.Screens.Play.HUD; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Users.Drawables; using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.Multi.Match.Components { public class Header : MultiplayerComposite { - public const float HEIGHT = 200; + public const float HEIGHT = 50; - public MatchTabControl Tabs; - - public Action RequestBeatmapSelection; + private UpdateableAvatar avatar; + private LinkFlowContainer hostText; public Header() { @@ -38,107 +28,52 @@ namespace osu.Game.Screens.Multi.Match.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { - BeatmapSelectButton beatmapButton; - ModDisplay modDisplay; - - InternalChildren = new Drawable[] + InternalChild = new FillFlowContainer { - new Container + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new Drawable[] + avatar = new UpdateableAvatar { - new HeaderBackgroundSprite { RelativeSizeAxes = Axes.Both }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical(Color4.Black.Opacity(0.4f), Color4.Black.Opacity(0.6f)), - }, - } - }, - new Box - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Height = 1, - Colour = colours.Yellow - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 20 }, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new BeatmapTypeInfo(), - modDisplay = new ModDisplay - { - Scale = new Vector2(0.75f), - DisplayUnrankedText = false - }, - } - }, - new Container - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.Y, - Width = 200, - Padding = new MarginPadding { Vertical = 10 }, - Child = beatmapButton = new BeatmapSelectButton - { - RelativeSizeAxes = Axes.Both, - Height = 1, - }, - }, - Tabs = new MatchTabControl - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X - }, + Size = new Vector2(50), + Masking = true, + CornerRadius = 10, }, - }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Font = OsuFont.GetFont(size: 30), + Current = { BindTarget = RoomName } + }, + hostText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 20)) + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + } + } + } + } }; - CurrentItem.BindValueChanged(item => modDisplay.Current.Value = item.NewValue?.RequiredMods?.ToArray() ?? Array.Empty<Mod>(), true); - - beatmapButton.Action = () => RequestBeatmapSelection?.Invoke(); - } - - private class BeatmapSelectButton : HeaderButton - { - [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable<int?> roomId { get; set; } - - public BeatmapSelectButton() + Host.BindValueChanged(host => { - Text = "Select beatmap"; - } + avatar.User = host.NewValue; - [BackgroundDependencyLoader] - private void load() - { - roomId.BindValueChanged(id => this.FadeTo(id.NewValue.HasValue ? 0 : 1), true); - } - } + hostText.Clear(); - private class HeaderBackgroundSprite : MultiplayerBackgroundSprite - { - protected override UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new BackgroundSprite { RelativeSizeAxes = Axes.Both }; - - private class BackgroundSprite : UpdateableBeatmapBackgroundSprite - { - protected override double TransformDuration => 200; - } + if (host.NewValue != null) + { + hostText.AddText("hosted by "); + hostText.AddUserLink(host.NewValue, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + } + }, true); } } } diff --git a/osu.Game/Screens/Multi/Match/Components/HeaderButton.cs b/osu.Game/Screens/Multi/Match/Components/HeaderButton.cs deleted file mode 100644 index f3412d0be7..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/HeaderButton.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class HeaderButton : TriangleButton - { - [BackgroundDependencyLoader] - private void load() - { - BackgroundColour = OsuColour.FromHex(@"1187aa"); - - Triangles.ColourLight = OsuColour.FromHex(@"277b9c"); - Triangles.ColourDark = OsuColour.FromHex(@"1f6682"); - Triangles.TriangleScale = 1.5f; - - Add(new Container - { - RelativeSizeAxes = Axes.Both, - Alpha = 1f, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.15f, - Blending = BlendingMode.Additive, - }, - }); - } - - protected override SpriteText CreateText() => new OsuSpriteText - { - Depth = -1, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Font = OsuFont.GetFont(weight: FontWeight.Light, size: 30), - }; - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/HostInfo.cs b/osu.Game/Screens/Multi/Match/Components/HostInfo.cs deleted file mode 100644 index 8851a96605..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/HostInfo.cs +++ /dev/null @@ -1,61 +0,0 @@ -// 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. - -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Users; -using osu.Game.Users.Drawables; -using osuTK; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class HostInfo : CompositeDrawable - { - public readonly IBindable<User> Host = new Bindable<User>(); - - private readonly LinkFlowContainer linkContainer; - private readonly UpdateableAvatar avatar; - - public HostInfo() - { - AutoSizeAxes = Axes.X; - Height = 50; - - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5, 0), - Children = new Drawable[] - { - avatar = new UpdateableAvatar { Size = new Vector2(50) }, - new FillFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Child = linkContainer = new LinkFlowContainer { AutoSizeAxes = Axes.Both } - } - } - }; - - Host.BindValueChanged(host => updateHost(host.NewValue)); - } - - private void updateHost(User host) - { - avatar.User = host; - - if (host != null) - { - linkContainer.AddText("hosted by"); - linkContainer.NewLine(); - linkContainer.AddUserLink(host, s => s.Font = s.Font.With(Typeface.Exo, weight: FontWeight.Bold, italics: true)); - } - } - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/Info.cs b/osu.Game/Screens/Multi/Match/Components/Info.cs deleted file mode 100644 index a185c4db50..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/Info.cs +++ /dev/null @@ -1,103 +0,0 @@ -// 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. - -using System; -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.Sprites; -using osu.Game.Overlays.SearchableList; -using osu.Game.Screens.Multi.Components; -using osuTK; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class Info : MultiplayerComposite - { - public Action OnStart; - - public Info() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - } - - [BackgroundDependencyLoader] - private void load() - { - ReadyButton readyButton; - ViewBeatmapButton viewBeatmapButton; - HostInfo hostInfo; - - InternalChildren = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"28242d"), - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING + OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10), - Padding = new MarginPadding { Vertical = 20 }, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new OsuSpriteText - { - Font = OsuFont.GetFont(size: 30), - Current = RoomName - }, - new RoomStatusInfo(), - } - }, - hostInfo = new HostInfo(), - }, - }, - new FillFlowContainer - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - AutoSizeAxes = Axes.X, - Height = 70, - Spacing = new Vector2(10, 0), - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - viewBeatmapButton = new ViewBeatmapButton(), - readyButton = new ReadyButton - { - Action = () => OnStart?.Invoke() - } - } - } - }, - }, - }; - - CurrentItem.BindValueChanged(item => - { - viewBeatmapButton.Beatmap.Value = item.NewValue?.Beatmap; - readyButton.Beatmap.Value = item.NewValue?.Beatmap; - }, true); - - hostInfo.Host.BindTo(Host); - } - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/MatchLeaderboard.cs b/osu.Game/Screens/Multi/Match/Components/MatchLeaderboard.cs index ae27e53813..f2409d64e7 100644 --- a/osu.Game/Screens/Multi/Match/Components/MatchLeaderboard.cs +++ b/osu.Game/Screens/Multi/Match/Components/MatchLeaderboard.cs @@ -6,17 +6,14 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Leaderboards; using osu.Game.Online.Multiplayer; namespace osu.Game.Screens.Multi.Match.Components { - public class MatchLeaderboard : Leaderboard<MatchLeaderboardScope, APIRoomScoreInfo> + public class MatchLeaderboard : Leaderboard<MatchLeaderboardScope, APIUserScoreAggregate> { - public Action<IEnumerable<APIRoomScoreInfo>> ScoresLoaded; - [Resolved(typeof(Room), nameof(Room.RoomID))] private Bindable<int?> roomId { get; set; } @@ -35,23 +32,25 @@ namespace osu.Game.Screens.Multi.Match.Components protected override bool IsOnlineScope => true; - protected override APIRequest FetchScores(Action<IEnumerable<APIRoomScoreInfo>> scoresCallback) + protected override APIRequest FetchScores(Action<IEnumerable<APIUserScoreAggregate>> scoresCallback) { if (roomId.Value == null) return null; - var req = new GetRoomScoresRequest(roomId.Value ?? 0); + var req = new GetRoomLeaderboardRequest(roomId.Value ?? 0); req.Success += r => { - scoresCallback?.Invoke(r); - ScoresLoaded?.Invoke(r); + scoresCallback?.Invoke(r.Leaderboard); + TopScore = r.UserScore; }; return req; } - protected override LeaderboardScore CreateDrawableScore(APIRoomScoreInfo model, int index) => new MatchLeaderboardScore(model, index); + protected override LeaderboardScore CreateDrawableScore(APIUserScoreAggregate model, int index) => new MatchLeaderboardScore(model, index); + + protected override LeaderboardScore CreateDrawableTopScore(APIUserScoreAggregate model) => new MatchLeaderboardScore(model, model.Position, false); } public enum MatchLeaderboardScope diff --git a/osu.Game/Screens/Multi/Match/Components/MatchLeaderboardScore.cs b/osu.Game/Screens/Multi/Match/Components/MatchLeaderboardScore.cs index 92074abe4b..1fabdbb86a 100644 --- a/osu.Game/Screens/Multi/Match/Components/MatchLeaderboardScore.cs +++ b/osu.Game/Screens/Multi/Match/Components/MatchLeaderboardScore.cs @@ -12,9 +12,12 @@ namespace osu.Game.Screens.Multi.Match.Components { public class MatchLeaderboardScore : LeaderboardScore { - public MatchLeaderboardScore(APIRoomScoreInfo score, int rank) - : base(score, rank) + private readonly APIUserScoreAggregate score; + + public MatchLeaderboardScore(APIUserScoreAggregate score, int? rank, bool allowHighlight = true) + : base(score.CreateScoreInfo(), rank, allowHighlight) { + this.score = score; } [BackgroundDependencyLoader] @@ -25,9 +28,9 @@ namespace osu.Game.Screens.Multi.Match.Components protected override IEnumerable<LeaderboardScoreStatistic> GetStatistics(ScoreInfo model) => new[] { - new LeaderboardScoreStatistic(FontAwesome.Solid.Crosshairs, "Accuracy", string.Format(model.Accuracy % 1 == 0 ? @"{0:P0}" : @"{0:P2}", model.Accuracy)), - new LeaderboardScoreStatistic(FontAwesome.Solid.Sync, "Total Attempts", ((APIRoomScoreInfo)model).TotalAttempts.ToString()), - new LeaderboardScoreStatistic(FontAwesome.Solid.Check, "Completed Beatmaps", ((APIRoomScoreInfo)model).CompletedBeatmaps.ToString()), + new LeaderboardScoreStatistic(FontAwesome.Solid.Crosshairs, "Accuracy", model.DisplayAccuracy), + new LeaderboardScoreStatistic(FontAwesome.Solid.Sync, "Total Attempts", score.TotalAttempts.ToString()), + new LeaderboardScoreStatistic(FontAwesome.Solid.Check, "Completed Beatmaps", score.CompletedBeatmaps.ToString()), }; } } diff --git a/osu.Game/Screens/Multi/Match/Components/MatchPage.cs b/osu.Game/Screens/Multi/Match/Components/MatchPage.cs deleted file mode 100644 index fc98db157b..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/MatchPage.cs +++ /dev/null @@ -1,28 +0,0 @@ -// 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. - -using osu.Framework.Bindables; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public abstract class MatchPage - { - public abstract string Name { get; } - - public readonly BindableBool Enabled = new BindableBool(true); - - public override string ToString() => Name; - public override int GetHashCode() => GetType().GetHashCode(); - public override bool Equals(object obj) => GetType() == obj?.GetType(); - } - - public class SettingsMatchPage : MatchPage - { - public override string Name => "Settings"; - } - - public class RoomMatchPage : MatchPage - { - public override string Name => "Room"; - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs b/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs index 410aaed788..caefc194b1 100644 --- a/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs +++ b/osu.Game/Screens/Multi/Match/Components/MatchSettingsOverlay.cs @@ -14,7 +14,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; -using osu.Game.Overlays.SearchableList; +using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -25,6 +25,8 @@ namespace osu.Game.Screens.Multi.Match.Components private const float transition_duration = 350; private const float field_padding = 45; + public Action EditPlaylist; + protected MatchSettings Settings { get; private set; } [BackgroundDependencyLoader] @@ -35,7 +37,8 @@ namespace osu.Game.Screens.Multi.Match.Components Child = Settings = new MatchSettings { RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Y + RelativePositionAxes = Axes.Y, + EditPlaylist = () => EditPlaylist?.Invoke() }; } @@ -53,6 +56,8 @@ namespace osu.Game.Screens.Multi.Match.Components { private const float disabled_alpha = 0.2f; + public Action EditPlaylist; + public OsuTextBox NameField, MaxParticipantsField; public OsuDropdown<TimeSpan> DurationField; public RoomAvailabilityPicker AvailabilityPicker; @@ -62,7 +67,8 @@ namespace osu.Game.Screens.Multi.Match.Components public OsuSpriteText ErrorText; private OsuSpriteText typeLabel; - private ProcessingOverlay processingOverlay; + private LoadingLayer loadingLayer; + private DrawableRoomPlaylist playlist; [Resolved(CanBeNull = true)] private IRoomManager manager { get; set; } @@ -73,196 +79,232 @@ namespace osu.Game.Screens.Multi.Match.Components [BackgroundDependencyLoader] private void load(OsuColour colours) { + Container dimContent; + InternalChildren = new Drawable[] { - new Box + dimContent = new Container { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"28242d"), - }, - new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] + Children = new Drawable[] { - new Dimension(GridSizeMode.Distributed), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new Drawable[] + new Box { - new OsuScrollContainer + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"28242d"), + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - Padding = new MarginPadding + new Dimension(GridSizeMode.Distributed), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] { - Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING, - Vertical = 10 - }, - RelativeSizeAxes = Axes.Both, - Children = new[] - { - new Container + new OsuScrollContainer { - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING }, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] + Padding = new MarginPadding { - new SectionContainer + Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING, + Vertical = 10 + }, + RelativeSizeAxes = Axes.Both, + Children = new[] + { + new Container { - Padding = new MarginPadding { Right = field_padding / 2 }, - Children = new[] + Padding = new MarginPadding { Horizontal = WaveOverlayContainer.WIDTH_PADDING }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] { - new Section("Room name") + new SectionContainer { - Child = NameField = new SettingsTextBox + Padding = new MarginPadding { Right = field_padding / 2 }, + Children = new[] { - RelativeSizeAxes = Axes.X, - TabbableContentContainer = this, - OnCommit = (sender, text) => apply(), - }, - }, - new Section("Room visibility") - { - Alpha = disabled_alpha, - Child = AvailabilityPicker = new RoomAvailabilityPicker - { - Enabled = { Value = false } - }, - }, - new Section("Game type") - { - Alpha = disabled_alpha, - Child = new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Spacing = new Vector2(7), - Children = new Drawable[] + new Section("Room name") { - TypePicker = new GameTypePicker + Child = NameField = new SettingsTextBox { RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + }, + }, + new Section("Duration") + { + Child = DurationField = new DurationDropdown + { + RelativeSizeAxes = Axes.X, + Items = new[] + { + TimeSpan.FromMinutes(30), + TimeSpan.FromHours(1), + TimeSpan.FromHours(2), + TimeSpan.FromHours(4), + TimeSpan.FromHours(8), + TimeSpan.FromHours(12), + //TimeSpan.FromHours(16), + TimeSpan.FromHours(24), + TimeSpan.FromDays(3), + TimeSpan.FromDays(7) + } + } + }, + new Section("Room visibility") + { + Alpha = disabled_alpha, + Child = AvailabilityPicker = new RoomAvailabilityPicker + { Enabled = { Value = false } }, - typeLabel = new OsuSpriteText + }, + new Section("Game type") + { + Alpha = disabled_alpha, + Child = new FillFlowContainer { - Font = OsuFont.GetFont(size: 14), - Colour = colours.Yellow + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(7), + Children = new Drawable[] + { + TypePicker = new GameTypePicker + { + RelativeSizeAxes = Axes.X, + Enabled = { Value = false } + }, + typeLabel = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 14), + Colour = colours.Yellow + }, + }, + }, + }, + new Section("Max participants") + { + Alpha = disabled_alpha, + Child = MaxParticipantsField = new SettingsNumberTextBox + { + RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + ReadOnly = true, + }, + }, + new Section("Password (optional)") + { + Alpha = disabled_alpha, + Child = new SettingsPasswordTextBox + { + RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + ReadOnly = true, }, }, }, }, - }, - }, - new SectionContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Padding = new MarginPadding { Left = field_padding / 2 }, - Children = new[] - { - new Section("Max participants") + new SectionContainer { - Alpha = disabled_alpha, - Child = MaxParticipantsField = new SettingsNumberTextBox + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Padding = new MarginPadding { Left = field_padding / 2 }, + Children = new[] { - RelativeSizeAxes = Axes.X, - TabbableContentContainer = this, - ReadOnly = true, - OnCommit = (sender, text) => apply() - }, - }, - new Section("Duration") - { - Child = DurationField = new DurationDropdown - { - RelativeSizeAxes = Axes.X, - Items = new[] + new Section("Playlist") { - TimeSpan.FromMinutes(30), - TimeSpan.FromHours(1), - TimeSpan.FromHours(2), - TimeSpan.FromHours(4), - TimeSpan.FromHours(8), - TimeSpan.FromHours(12), - //TimeSpan.FromHours(16), - TimeSpan.FromHours(24), - TimeSpan.FromDays(3), - TimeSpan.FromDays(7) - } - } - }, - new Section("Password (optional)") - { - Alpha = disabled_alpha, - Child = new SettingsPasswordTextBox - { - RelativeSizeAxes = Axes.X, - TabbableContentContainer = this, - ReadOnly = true, - OnCommit = (sender, text) => apply() + Child = new GridContainer + { + RelativeSizeAxes = Axes.X, + Height = 300, + Content = new[] + { + new Drawable[] + { + playlist = new DrawableRoomPlaylist(true, true) { RelativeSizeAxes = Axes.Both } + }, + new Drawable[] + { + new PurpleTriangleButton + { + RelativeSizeAxes = Axes.X, + Height = 40, + Text = "Edit playlist", + Action = () => EditPlaylist?.Invoke() + } + } + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + } + } + }, }, }, }, - }, + } }, - } - }, - }, - }, - new Drawable[] - { - new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Y = 2, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"28242d").Darken(0.5f).Opacity(1f), }, - new FillFlowContainer + }, + new Drawable[] + { + new Container { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Y = 2, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Margin = new MarginPadding { Vertical = 20 }, - Padding = new MarginPadding { Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, Children = new Drawable[] { - ApplyButton = new CreateRoomButton + new Box { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Size = new Vector2(230, 55), - Enabled = { Value = false }, - Action = apply, + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex(@"28242d").Darken(0.5f).Opacity(1f), }, - ErrorText = new OsuSpriteText + new FillFlowContainer { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Alpha = 0, - Depth = 1, - Colour = colours.RedDark + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Margin = new MarginPadding { Vertical = 20 }, + Padding = new MarginPadding { Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING }, + Children = new Drawable[] + { + ApplyButton = new CreateRoomButton + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Size = new Vector2(230, 55), + Enabled = { Value = false }, + Action = apply, + }, + ErrorText = new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Alpha = 0, + Depth = 1, + Colour = colours.RedDark + } + } } } } } } - } + }, } }, - processingOverlay = new ProcessingOverlay { Alpha = 0 } + loadingLayer = new LoadingLayer(dimContent) }; TypePicker.Current.BindValueChanged(type => typeLabel.Text = type.NewValue?.Name ?? string.Empty, true); @@ -271,6 +313,8 @@ namespace osu.Game.Screens.Multi.Match.Components Type.BindValueChanged(type => TypePicker.Current.Value = type.NewValue, true); MaxParticipants.BindValueChanged(count => MaxParticipantsField.Text = count.NewValue?.ToString(), true); Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue, true); + + playlist.Items.BindTo(Playlist); } protected override void Update() @@ -284,6 +328,9 @@ namespace osu.Game.Screens.Multi.Match.Components private void apply() { + if (!ApplyButton.Enabled.Value) + return; + hideError(); RoomName.Value = NameField.Text; @@ -299,19 +346,19 @@ namespace osu.Game.Screens.Multi.Match.Components manager?.CreateRoom(currentRoom.Value, onSuccess, onError); - processingOverlay.Show(); + loadingLayer.Show(); } private void hideError() => ErrorText.FadeOut(50); - private void onSuccess(Room room) => processingOverlay.Hide(); + private void onSuccess(Room room) => loadingLayer.Hide(); private void onError(string text) { ErrorText.Text = text; ErrorText.FadeIn(50); - processingOverlay.Hide(); + loadingLayer.Hide(); } } @@ -386,7 +433,7 @@ namespace osu.Game.Screens.Multi.Match.Components } } - private class CreateRoomButton : TriangleButton + public class CreateRoomButton : TriangleButton { public CreateRoomButton() { @@ -409,10 +456,7 @@ namespace osu.Game.Screens.Multi.Match.Components Menu.MaxHeight = 100; } - protected override string GenerateItemText(TimeSpan item) - { - return item.Humanize(); - } + protected override string GenerateItemText(TimeSpan item) => item.Humanize(); } } } diff --git a/osu.Game/Screens/Multi/Match/Components/MatchTabControl.cs b/osu.Game/Screens/Multi/Match/Components/MatchTabControl.cs deleted file mode 100644 index c700d7b88a..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/MatchTabControl.cs +++ /dev/null @@ -1,66 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.Multiplayer; -using osuTK.Graphics; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class MatchTabControl : PageTabControl<MatchPage> - { - [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable<int?> roomId { get; set; } - - public MatchTabControl() - { - AddItem(new RoomMatchPage()); - AddItem(new SettingsMatchPage()); - } - - [BackgroundDependencyLoader] - private void load() - { - roomId.BindValueChanged(id => - { - if (id.NewValue.HasValue) - { - Items.ForEach(t => t.Enabled.Value = !(t is SettingsMatchPage)); - Current.Value = new RoomMatchPage(); - } - else - { - Items.ForEach(t => t.Enabled.Value = t is SettingsMatchPage); - Current.Value = new SettingsMatchPage(); - } - }, true); - } - - protected override TabItem<MatchPage> CreateTabItem(MatchPage value) => new TabItem(value); - - private class TabItem : PageTabItem - { - private readonly IBindable<bool> enabled = new BindableBool(); - - public TabItem(MatchPage value) - : base(value) - { - enabled.BindTo(value.Enabled); - enabled.BindValueChanged(enabled => Colour = enabled.NewValue ? Color4.White : Color4.Gray, true); - } - - protected override bool OnClick(ClickEvent e) - { - if (!enabled.Value) - return true; - - return base.OnClick(e); - } - } - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/Participants.cs b/osu.Game/Screens/Multi/Match/Components/Participants.cs deleted file mode 100644 index ad38ec6a99..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/Participants.cs +++ /dev/null @@ -1,72 +0,0 @@ -// 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. - -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.Containers; -using osu.Game.Overlays.SearchableList; -using osu.Game.Screens.Multi.Components; -using osu.Game.Users; -using osuTK; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class Participants : MultiplayerComposite - { - [BackgroundDependencyLoader] - private void load() - { - FillFlowContainer<UserPanel> usersFlow; - - InternalChild = new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = SearchableListOverlay.WIDTH_PADDING }, - Children = new Drawable[] - { - new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 10 }, - Children = new Drawable[] - { - new ParticipantCountDisplay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }, - usersFlow = new FillFlowContainer<UserPanel> - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(5), - Padding = new MarginPadding { Top = 40 }, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint, - }, - }, - }, - }, - }; - - Participants.BindValueChanged(participants => - { - usersFlow.Children = participants.NewValue.Select(u => - { - var panel = new UserPanel(u) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Width = 300, - }; - - panel.OnLoadComplete += d => d.FadeInFromZero(60); - - return panel; - }).ToList(); - }, true); - } - } -} diff --git a/osu.Game/Screens/Multi/Match/Components/PurpleTriangleButton.cs b/osu.Game/Screens/Multi/Match/Components/PurpleTriangleButton.cs new file mode 100644 index 0000000000..1d93116d07 --- /dev/null +++ b/osu.Game/Screens/Multi/Match/Components/PurpleTriangleButton.cs @@ -0,0 +1,20 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Multi.Match.Components +{ + public class PurpleTriangleButton : TriangleButton + { + [BackgroundDependencyLoader] + private void load() + { + BackgroundColour = Color4Extensions.FromHex(@"593790"); + Triangles.ColourLight = Color4Extensions.FromHex(@"7247b6"); + Triangles.ColourDark = Color4Extensions.FromHex(@"593790"); + } + } +} diff --git a/osu.Game/Screens/Multi/Match/Components/ReadyButton.cs b/osu.Game/Screens/Multi/Match/Components/ReadyButton.cs index 8ab0b8f61f..a64f24dd7e 100644 --- a/osu.Game/Screens/Multi/Match/Components/ReadyButton.cs +++ b/osu.Game/Screens/Multi/Match/Components/ReadyButton.cs @@ -3,18 +3,19 @@ using System; using System.Linq; +using System.Linq.Expressions; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; -using osuTK; namespace osu.Game.Screens.Multi.Match.Components { - public class ReadyButton : HeaderButton + public class ReadyButton : TriangleButton { - public readonly Bindable<BeatmapInfo> Beatmap = new Bindable<BeatmapInfo>(); + public readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>(); [Resolved(typeof(Room), nameof(Room.EndDate))] private Bindable<DateTimeOffset> endDate { get; set; } @@ -29,44 +30,59 @@ namespace osu.Game.Screens.Multi.Match.Components public ReadyButton() { - RelativeSizeAxes = Axes.Y; - Size = new Vector2(200, 1); - Text = "Start"; } + private IBindable<WeakReference<BeatmapSetInfo>> managerUpdated; + private IBindable<WeakReference<BeatmapSetInfo>> managerRemoved; + [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours) { - beatmaps.ItemAdded += beatmapAdded; - beatmaps.ItemRemoved += beatmapRemoved; + managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(beatmapUpdated); + managerRemoved = beatmaps.ItemRemoved.GetBoundCopy(); + managerRemoved.BindValueChanged(beatmapRemoved); - Beatmap.BindValueChanged(b => updateBeatmap(b.NewValue), true); + SelectedItem.BindValueChanged(item => updateSelectedItem(item.NewValue), true); + + BackgroundColour = colours.Green; + Triangles.ColourDark = colours.Green; + Triangles.ColourLight = colours.GreenLight; } - private void updateBeatmap(BeatmapInfo beatmap) + private void updateSelectedItem(PlaylistItem item) { - hasBeatmap = false; - - if (beatmap?.OnlineBeatmapID == null) - return; - - hasBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == beatmap.OnlineBeatmapID) != null; + hasBeatmap = findBeatmap(expr => beatmaps.QueryBeatmap(expr)); } - private void beatmapAdded(BeatmapSetInfo model) + private void beatmapUpdated(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakSet) { - if (model.Beatmaps.Any(b => b.OnlineBeatmapID == Beatmap.Value.OnlineBeatmapID)) - Schedule(() => hasBeatmap = true); + if (weakSet.NewValue.TryGetTarget(out var set)) + { + if (findBeatmap(expr => set.Beatmaps.AsQueryable().FirstOrDefault(expr))) + Schedule(() => hasBeatmap = true); + } } - private void beatmapRemoved(BeatmapSetInfo model) + private void beatmapRemoved(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakSet) { - if (Beatmap.Value == null) - return; + if (weakSet.NewValue.TryGetTarget(out var set)) + { + if (findBeatmap(expr => set.Beatmaps.AsQueryable().FirstOrDefault(expr))) + Schedule(() => hasBeatmap = false); + } + } - if (model.OnlineBeatmapSetID == Beatmap.Value.BeatmapSet.OnlineBeatmapSetID) - Schedule(() => hasBeatmap = false); + private bool findBeatmap(Func<Expression<Func<BeatmapInfo, bool>>, BeatmapInfo> expression) + { + int? beatmapId = SelectedItem.Value?.Beatmap.Value?.OnlineBeatmapID; + string checksum = SelectedItem.Value?.Beatmap.Value?.MD5Hash; + + if (beatmapId == null || checksum == null) + return false; + + return expression(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum) != null; } protected override void Update() @@ -78,7 +94,7 @@ namespace osu.Game.Screens.Multi.Match.Components private void updateEnabledState() { - if (gameBeatmap.Value == null) + if (gameBeatmap.Value == null || SelectedItem.Value == null) { Enabled.Value = false; return; @@ -88,13 +104,5 @@ namespace osu.Game.Screens.Multi.Match.Components Enabled.Value = hasBeatmap && hasEnoughTime; } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (beatmaps != null) - beatmaps.ItemAdded -= beatmapAdded; - } } } diff --git a/osu.Game/Screens/Multi/Match/Components/RoomAvailabilityPicker.cs b/osu.Game/Screens/Multi/Match/Components/RoomAvailabilityPicker.cs index 9de4a61cde..7ef39c2a74 100644 --- a/osu.Game/Screens/Multi/Match/Components/RoomAvailabilityPicker.cs +++ b/osu.Game/Screens/Multi/Match/Components/RoomAvailabilityPicker.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; @@ -52,7 +53,7 @@ namespace osu.Game.Screens.Multi.Match.Components new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"3d3943"), + Colour = Color4Extensions.FromHex(@"3d3943"), }, selection = new Box { diff --git a/osu.Game/Screens/Multi/Match/Components/ViewBeatmapButton.cs b/osu.Game/Screens/Multi/Match/Components/ViewBeatmapButton.cs deleted file mode 100644 index 8d1ff21124..0000000000 --- a/osu.Game/Screens/Multi/Match/Components/ViewBeatmapButton.cs +++ /dev/null @@ -1,46 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osuTK; - -namespace osu.Game.Screens.Multi.Match.Components -{ - public class ViewBeatmapButton : HeaderButton - { - public readonly Bindable<BeatmapInfo> Beatmap = new Bindable<BeatmapInfo>(); - - [Resolved(CanBeNull = true)] - private OsuGame osuGame { get; set; } - - public ViewBeatmapButton() - { - RelativeSizeAxes = Axes.Y; - Size = new Vector2(200, 1); - - Text = "View beatmap"; - } - - [BackgroundDependencyLoader] - private void load() - { - if (osuGame != null) - Beatmap.BindValueChanged(beatmap => updateAction(beatmap.NewValue), true); - } - - private void updateAction(BeatmapInfo beatmap) - { - if (beatmap == null) - { - Enabled.Value = false; - return; - } - - Action = () => osuGame.ShowBeatmap(beatmap.OnlineBeatmapID ?? 0); - Enabled.Value = true; - } - } -} diff --git a/osu.Game/Screens/Multi/Match/MatchSubScreen.cs b/osu.Game/Screens/Multi/Match/MatchSubScreen.cs index 5469d4c8c8..2cbe215a39 100644 --- a/osu.Game/Screens/Multi/Match/MatchSubScreen.cs +++ b/osu.Game/Screens/Multi/Match/MatchSubScreen.cs @@ -2,23 +2,32 @@ // 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.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; +using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.GameTypes; using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Multi.Components; using osu.Game.Screens.Multi.Match.Components; using osu.Game.Screens.Multi.Play; +using osu.Game.Screens.Multi.Ranking; +using osu.Game.Screens.Play; using osu.Game.Screens.Select; -using PlaylistItem = osu.Game.Online.Multiplayer.PlaylistItem; +using osu.Game.Users; +using Footer = osu.Game.Screens.Multi.Match.Components.Footer; namespace osu.Game.Screens.Multi.Match { - public class MatchSubScreen : MultiplayerSubScreen + [Cached(typeof(IPreviewTrackOwner))] + public class MatchSubScreen : MultiplayerSubScreen, IPreviewTrackOwner { public override bool DisallowExternalBeatmapRulesetChanges => true; @@ -29,40 +38,35 @@ namespace osu.Game.Screens.Multi.Match [Resolved(typeof(Room), nameof(Room.RoomID))] private Bindable<int?> roomId { get; set; } - [Resolved(typeof(Room), nameof(Room.Name))] - private Bindable<string> name { get; set; } - [Resolved(typeof(Room), nameof(Room.Type))] private Bindable<GameType> type { get; set; } - [Resolved(typeof(Room))] - protected BindableList<PlaylistItem> Playlist { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable<PlaylistItem> CurrentItem { get; private set; } + [Resolved(typeof(Room), nameof(Room.Playlist))] + private BindableList<PlaylistItem> playlist { get; set; } [Resolved] private BeatmapManager beatmapManager { get; set; } - [Resolved(CanBeNull = true)] - private OsuGame game { get; set; } + [Resolved(canBeNull: true)] + private Multiplayer multiplayer { get; set; } + protected readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>(); + + private MatchSettingsOverlay settingsOverlay; private MatchLeaderboard leaderboard; + private IBindable<WeakReference<BeatmapSetInfo>> managerUpdated; + private OverlinedHeader participantsHeader; + public MatchSubScreen(Room room) { Title = room.RoomID.Value == null ? "New room" : room.Name.Value; + Activity.Value = new UserActivity.InLobby(room); } [BackgroundDependencyLoader] private void load() { - MatchChatDisplay chat; - Components.Header header; - Info info; - GridContainer bottomRow; - MatchSettingsOverlay settings; - InternalChildren = new Drawable[] { new GridContainer @@ -72,154 +76,206 @@ namespace osu.Game.Screens.Multi.Match { new Drawable[] { - header = new Components.Header + new Container { - Depth = -1, - RequestBeatmapSelection = () => + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { - this.Push(new MatchSongSelect + Horizontal = 105, + Vertical = 20 + }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - Selected = item => + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new Drawable[] { new Components.Header() }, + new Drawable[] { - Playlist.Clear(); - Playlist.Add(item); + participantsHeader = new OverlinedHeader("Participants") + { + ShowLine = false + } + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 5 }, + Child = new ParticipantsDisplay(Direction.Horizontal) + { + Details = { BindTarget = participantsHeader.Details } + } + } + }, + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = 5 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Playlist"), }, + new Drawable[] + { + new DrawableRoomPlaylistWithResults + { + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = playlist }, + SelectedItem = { BindTarget = SelectedItem }, + RequestShowResults = item => + { + Debug.Assert(roomId.Value != null); + multiplayer?.Push(new TimeshiftResultsScreen(null, roomId.Value.Value, item, false)); + } + } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + } + } + }, + null, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { new OverlinedHeader("Leaderboard"), }, + new Drawable[] { leaderboard = new MatchLeaderboard { RelativeSizeAxes = Axes.Both }, }, + new Drawable[] { new OverlinedHeader("Chat"), }, + new Drawable[] { new MatchChatDisplay { RelativeSizeAxes = Axes.Both } } + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Relative, size: 0.4f, minSize: 120), + } + }, + null + }, + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 400), + new Dimension(), + new Dimension(GridSizeMode.Relative, size: 0.5f, maxSize: 600), + new Dimension(), + } + } } - }); + }, } } }, - new Drawable[] { info = new Info { OnStart = onStart } }, new Drawable[] { - bottomRow = new GridContainer + new Footer { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - leaderboard = new MatchLeaderboard - { - Padding = new MarginPadding - { - Left = 10 + HORIZONTAL_OVERFLOW_PADDING, - Right = 10, - Vertical = 10, - }, - RelativeSizeAxes = Axes.Both - }, - new Container - { - Padding = new MarginPadding - { - Left = 10, - Right = 10 + HORIZONTAL_OVERFLOW_PADDING, - Vertical = 10, - }, - RelativeSizeAxes = Axes.Both, - Child = chat = new MatchChatDisplay - { - RelativeSizeAxes = Axes.Both - } - }, - }, - }, + OnStart = onStart, + SelectedItem = { BindTarget = SelectedItem } } - }, + } }, RowDimensions = new[] { + new Dimension(), new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Distributed), } }, - new Container + settingsOverlay = new MatchSettingsOverlay { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = Components.Header.HEIGHT }, - Child = settings = new MatchSettingsOverlay { RelativeSizeAxes = Axes.Both }, - }, - }; - - header.Tabs.Current.BindValueChanged(tab => - { - const float fade_duration = 500; - - if (tab.NewValue is SettingsMatchPage) - { - settings.Show(); - info.FadeOut(fade_duration, Easing.OutQuint); - bottomRow.FadeOut(fade_duration, Easing.OutQuint); + EditPlaylist = () => this.Push(new MatchSongSelect()), + State = { Value = roomId.Value == null ? Visibility.Visible : Visibility.Hidden } } - else - { - settings.Hide(); - info.FadeIn(fade_duration, Easing.OutQuint); - bottomRow.FadeIn(fade_duration, Easing.OutQuint); - } - }, true); - - chat.Exit += () => - { - if (this.IsCurrentScreen()) - this.Exit(); }; - - beatmapManager.ItemAdded += beatmapAdded; } + [Resolved] + private IAPIProvider api { get; set; } + protected override void LoadComplete() { base.LoadComplete(); - CurrentItem.BindValueChanged(currentItemChanged, true); + roomId.BindValueChanged(id => + { + if (id.NewValue == null) + settingsOverlay.Show(); + else + { + settingsOverlay.Hide(); + + // Set the first playlist item. + // This is scheduled since updating the room and playlist may happen in an arbitrary order (via Room.CopyFrom()). + Schedule(() => SelectedItem.Value = playlist.FirstOrDefault()); + } + }, true); + + SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(selectedItemChanged)); + SelectedItem.Value = playlist.FirstOrDefault(); + + managerUpdated = beatmapManager.ItemUpdated.GetBoundCopy(); + managerUpdated.BindValueChanged(beatmapUpdated); } public override bool OnExiting(IScreen next) { RoomManager?.PartRoom(); - Mods.Value = Array.Empty<Mod>(); return base.OnExiting(next); } - /// <summary> - /// Handles propagation of the current playlist item's content to game-wide mechanisms. - /// </summary> - private void currentItemChanged(ValueChangedEvent<PlaylistItem> e) + private void selectedItemChanged() { - // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info - var localBeatmap = e.NewValue?.Beatmap == null ? null : beatmapManager.QueryBeatmap(b => b.OnlineBeatmapID == e.NewValue.Beatmap.OnlineBeatmapID); + updateWorkingBeatmap(); - Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); - Mods.Value = e.NewValue?.RequiredMods?.ToArray() ?? Array.Empty<Mod>(); + var item = SelectedItem.Value; - if (e.NewValue?.Ruleset != null) - Ruleset.Value = e.NewValue.Ruleset; + Mods.Value = item?.RequiredMods?.ToArray() ?? Array.Empty<Mod>(); + + if (item?.Ruleset != null) + Ruleset.Value = item.Ruleset.Value; } - /// <summary> - /// Handle the case where a beatmap is imported (and can be used by this match). - /// </summary> - private void beatmapAdded(BeatmapSetInfo model) => Schedule(() => + private void beatmapUpdated(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakSet) => Schedule(updateWorkingBeatmap); + + private void updateWorkingBeatmap() { - if (Beatmap.Value != beatmapManager.DefaultBeatmap) - return; + var beatmap = SelectedItem.Value?.Beatmap.Value; - if (CurrentItem.Value == null) - return; + // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info + var localBeatmap = beatmap == null ? null : beatmapManager.QueryBeatmap(b => b.OnlineBeatmapID == beatmap.OnlineBeatmapID); - // Try to retrieve the corresponding local beatmap - var localBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineBeatmapID == CurrentItem.Value.Beatmap.OnlineBeatmapID); - - if (localBeatmap != null) - Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); - }); - - [Resolved(canBeNull: true)] - private Multiplayer multiplayer { get; set; } + Beatmap.Value = beatmapManager.GetWorkingBeatmap(localBeatmap); + } private void onStart() { @@ -227,20 +283,12 @@ namespace osu.Game.Screens.Multi.Match { default: case GameTypeTimeshift _: - multiplayer?.Start(() => new TimeshiftPlayer(CurrentItem.Value) + multiplayer?.Push(new PlayerLoader(() => new TimeshiftPlayer(SelectedItem.Value) { Exited = () => leaderboard.RefreshScores() - }); + })); break; } } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (beatmapManager != null) - beatmapManager.ItemAdded -= beatmapAdded; - } } } diff --git a/osu.Game/Screens/Multi/Multiplayer.cs b/osu.Game/Screens/Multi/Multiplayer.cs index 9ffd620e55..a323faeea1 100644 --- a/osu.Game/Screens/Multi/Multiplayer.cs +++ b/osu.Game/Screens/Multi/Multiplayer.cs @@ -1,37 +1,41 @@ // 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. -using System; 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.Logging; using osu.Framework.Screens; using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Backgrounds; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; -using osu.Game.Overlays.BeatmapSet.Buttons; +using osu.Game.Overlays; using osu.Game.Screens.Menu; +using osu.Game.Screens.Multi.Components; using osu.Game.Screens.Multi.Lounge; using osu.Game.Screens.Multi.Lounge.Components; using osu.Game.Screens.Multi.Match; -using osu.Game.Screens.Play; +using osu.Game.Screens.Multi.Match.Components; +using osu.Game.Users; using osuTK; namespace osu.Game.Screens.Multi { [Cached] - public class Multiplayer : OsuScreen, IOnlineComponent + public class Multiplayer : OsuScreen { public override bool CursorVisible => (screenStack.CurrentScreen as IMultiplayerSubScreen)?.CursorVisible ?? true; + // this is required due to PlayerLoader eventually being pushed to the main stack + // while leases may be taken out by a subscreen. public override bool DisallowExternalBeatmapRulesetChanges => true; private readonly MultiplayerWaveContainer waves; @@ -43,11 +47,14 @@ namespace osu.Game.Screens.Multi private readonly IBindable<bool> isIdle = new BindableBool(); [Cached] - private readonly Bindable<Room> currentRoom = new Bindable<Room>(); + private readonly Bindable<Room> selectedRoom = new Bindable<Room>(); [Cached] private readonly Bindable<FilterCriteria> currentFilter = new Bindable<FilterCriteria>(new FilterCriteria()); + [Resolved(CanBeNull = true)] + private MusicController music { get; set; } + [Cached(Type = typeof(IRoomManager))] private RoomManager roomManager; @@ -60,6 +67,9 @@ namespace osu.Game.Screens.Multi [Resolved(CanBeNull = true)] private OsuLogo logo { get; set; } + private readonly Drawable header; + private readonly Drawable headerBackground; + public Multiplayer() { Anchor = Anchor.Centre; @@ -67,54 +77,65 @@ namespace osu.Game.Screens.Multi RelativeSizeAxes = Axes.Both; Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; + var backgroundColour = Color4Extensions.FromHex(@"3e3a44"); + InternalChild = waves = new MultiplayerWaveContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - new Container + new Box { RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"3e3a44"), - }, - new Triangles - { - RelativeSizeAxes = Axes.Both, - ColourLight = OsuColour.FromHex(@"3c3842"), - ColourDark = OsuColour.FromHex(@"393540"), - TriangleScale = 5, - }, - }, + Colour = backgroundColour, }, new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = Header.HEIGHT }, - Child = screenStack = new OsuScreenStack(loungeSubScreen = new LoungeSubScreen()) { RelativeSizeAxes = Axes.Both } + Children = new[] + { + header = new Container + { + RelativeSizeAxes = Axes.X, + Height = 400, + Children = new[] + { + headerBackground = new Container + { + RelativeSizeAxes = Axes.Both, + Width = 1.25f, + Masking = true, + Children = new Drawable[] + { + new HeaderBackgroundSprite + { + RelativeSizeAxes = Axes.X, + Height = 400 // Keep a static height so the header doesn't change as it's resized between subscreens + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = -1 }, // 1px padding to avoid a 1px gap due to masking + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(backgroundColour.Opacity(0.5f), backgroundColour) + }, + } + } + }, + screenStack = new MultiplayerSubScreenStack { RelativeSizeAxes = Axes.Both } + } }, new Header(screenStack), - createButton = new HeaderButton + createButton = new CreateRoomButton { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - RelativeSizeAxes = Axes.None, - Size = new Vector2(150, Header.HEIGHT - 20), - Margin = new MarginPadding - { - Top = 10, - Right = 10 + HORIZONTAL_OVERFLOW_PADDING, - }, - Text = "Create room", - Action = () => loungeSubScreen.Open(new Room - { - Name = { Value = $"{api.LocalUser}'s awesome room" } - }), + Action = () => CreateRoom() }, roomManager = new RoomManager() } @@ -122,17 +143,28 @@ namespace osu.Game.Screens.Multi screenStack.ScreenPushed += screenPushed; screenStack.ScreenExited += screenExited; + + screenStack.Push(loungeSubScreen = new LoungeSubScreen()); } + private readonly IBindable<APIState> apiState = new Bindable<APIState>(); + [BackgroundDependencyLoader(true)] private void load(IdleTracker idleTracker) { - api.Register(this); + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); if (idleTracker != null) isIdle.BindTo(idleTracker.IsIdle); } + private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() => + { + if (state.NewValue != APIState.Online) + Schedule(forcefullyExit); + }); + protected override void LoadComplete() { base.LoadComplete(); @@ -142,39 +174,49 @@ namespace osu.Game.Screens.Multi protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new CachedModelDependencyContainer<Room>(base.CreateChildDependencies(parent)); - dependencies.Model.BindTo(currentRoom); + dependencies.Model.BindTo(selectedRoom); return dependencies; } private void updatePollingRate(bool idle) - { - roomManager.TimeBetweenPolls = !this.IsCurrentScreen() || !(screenStack.CurrentScreen is LoungeSubScreen) ? 0 : (idle ? 120000 : 15000); - Logger.Log($"Polling adjusted to {roomManager.TimeBetweenPolls}"); - } - - /// <summary> - /// Push a <see cref="Player"/> to the main screen stack to begin gameplay. - /// Generally called from a <see cref="MatchSubScreen"/> via DI resolution. - /// </summary> - public void Start(Func<Player> player) { if (!this.IsCurrentScreen()) - return; + { + roomManager.TimeBetweenListingPolls = 0; + roomManager.TimeBetweenSelectionPolls = 0; + } + else + { + switch (screenStack.CurrentScreen) + { + case LoungeSubScreen _: + roomManager.TimeBetweenListingPolls = idle ? 120000 : 15000; + roomManager.TimeBetweenSelectionPolls = idle ? 120000 : 15000; + break; - this.Push(new PlayerLoader(player)); - } + case MatchSubScreen _: + roomManager.TimeBetweenListingPolls = 0; + roomManager.TimeBetweenSelectionPolls = idle ? 30000 : 5000; + break; - public void APIStateChanged(IAPIProvider api, APIState state) - { - if (state != APIState.Online) - forcefullyExit(); + default: + roomManager.TimeBetweenListingPolls = 0; + roomManager.TimeBetweenSelectionPolls = 0; + break; + } + } + + Logger.Log($"Polling adjusted (listing: {roomManager.TimeBetweenListingPolls}, selection: {roomManager.TimeBetweenSelectionPolls})"); } private void forcefullyExit() { // This is temporary since we don't currently have a way to force screens to be exited if (this.IsCurrentScreen()) - this.Exit(); + { + while (this.IsCurrentScreen()) + this.Exit(); + } else { this.MakeCurrent(); @@ -198,6 +240,8 @@ namespace osu.Game.Screens.Multi base.OnResuming(last); beginHandlingTrack(); + + updatePollingRate(isIdle.Value); } public override void OnSuspending(IScreen next) @@ -207,16 +251,12 @@ namespace osu.Game.Screens.Multi endHandlingTrack(); - roomManager.TimeBetweenPolls = 0; + updatePollingRate(isIdle.Value); } public override bool OnExiting(IScreen next) { - if (!(screenStack.CurrentScreen is LoungeSubScreen)) - { - screenStack.Exit(); - return true; - } + roomManager.PartRoom(); waves.Hide(); @@ -231,6 +271,20 @@ namespace osu.Game.Screens.Multi return false; } + public override bool OnBackButton() + { + if ((screenStack.CurrentScreen as IMultiplayerSubScreen)?.OnBackButton() == true) + return true; + + if (screenStack.CurrentScreen != null && !(screenStack.CurrentScreen is LoungeSubScreen)) + { + screenStack.Exit(); + return true; + } + + return false; + } + protected override void LogoExiting(OsuLogo logo) { base.LogoExiting(logo); @@ -239,6 +293,12 @@ namespace osu.Game.Screens.Multi logo.Delay(WaveContainer.DISAPPEAR_DURATION / 2).FadeOut(); } + /// <summary> + /// Create a new room. + /// </summary> + /// <param name="room">An optional template to use when creating the room.</param> + public void CreateRoom(Room room = null) => loungeSubScreen.Open(room ?? new Room { Name = { Value = $"{api.LocalUser}'s awesome room" } }); + private void beginHandlingTrack() { Beatmap.BindValueChanged(updateTrack, true); @@ -250,18 +310,40 @@ namespace osu.Game.Screens.Multi Beatmap.ValueChanged -= updateTrack; } - private void screenPushed(IScreen lastScreen, IScreen newScreen) => subScreenChanged(newScreen); + private void screenPushed(IScreen lastScreen, IScreen newScreen) + { + subScreenChanged(lastScreen, newScreen); + } private void screenExited(IScreen lastScreen, IScreen newScreen) { - subScreenChanged(newScreen); + subScreenChanged(lastScreen, newScreen); if (screenStack.CurrentScreen == null && this.IsCurrentScreen()) this.Exit(); } - private void subScreenChanged(IScreen newScreen) + private void subScreenChanged(IScreen lastScreen, IScreen newScreen) { + switch (newScreen) + { + case LoungeSubScreen _: + header.Delay(MultiplayerSubScreen.RESUME_TRANSITION_DELAY).ResizeHeightTo(400, MultiplayerSubScreen.APPEAR_DURATION, Easing.OutQuint); + headerBackground.MoveToX(0, MultiplayerSubScreen.X_MOVE_DURATION, Easing.OutQuint); + break; + + case MatchSubScreen _: + header.ResizeHeightTo(135, MultiplayerSubScreen.APPEAR_DURATION, Easing.OutQuint); + headerBackground.MoveToX(-MultiplayerSubScreen.X_SHIFT, MultiplayerSubScreen.X_MOVE_DURATION, Easing.OutQuint); + break; + } + + if (lastScreen is IOsuScreen lastOsuScreen) + Activity.UnbindFrom(lastOsuScreen.Activity); + + if (newScreen is IOsuScreen newOsuScreen) + ((IBindable<UserActivity>)Activity).BindTo(newOsuScreen.Activity); + updatePollingRate(isIdle.Value); createButton.FadeTo(newScreen is LoungeSubScreen ? 1 : 0, 200); @@ -270,11 +352,7 @@ namespace osu.Game.Screens.Multi private void updateTrack(ValueChangedEvent<WorkingBeatmap> _ = null) { - bool isMatch = screenStack.CurrentScreen is MatchSubScreen; - - Beatmap.Disabled = isMatch; - - if (isMatch) + if (screenStack.CurrentScreen is MatchSubScreen) { var track = Beatmap.Value?.Track; @@ -283,8 +361,7 @@ namespace osu.Game.Screens.Multi track.RestartPoint = Beatmap.Value.Metadata.PreviewTime; track.Looping = true; - if (!track.IsRunning) - track.Restart(); + music?.EnsurePlayingSomething(); } } else @@ -304,22 +381,47 @@ namespace osu.Game.Screens.Multi } } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - api?.Unregister(this); - } - private class MultiplayerWaveContainer : WaveContainer { protected override bool StartHidden => true; public MultiplayerWaveContainer() { - FirstWaveColour = OsuColour.FromHex(@"654d8c"); - SecondWaveColour = OsuColour.FromHex(@"554075"); - ThirdWaveColour = OsuColour.FromHex(@"44325e"); - FourthWaveColour = OsuColour.FromHex(@"392850"); + FirstWaveColour = Color4Extensions.FromHex(@"654d8c"); + SecondWaveColour = Color4Extensions.FromHex(@"554075"); + ThirdWaveColour = Color4Extensions.FromHex(@"44325e"); + FourthWaveColour = Color4Extensions.FromHex(@"392850"); + } + } + + private class HeaderBackgroundSprite : MultiplayerBackgroundSprite + { + protected override UpdateableBeatmapBackgroundSprite CreateBackgroundSprite() => new BackgroundSprite { RelativeSizeAxes = Axes.Both }; + + private class BackgroundSprite : UpdateableBeatmapBackgroundSprite + { + protected override double TransformDuration => 200; + } + } + + public class CreateRoomButton : PurpleTriangleButton + { + public CreateRoomButton() + { + Size = new Vector2(150, Header.HEIGHT - 20); + Margin = new MarginPadding + { + Top = 10, + Right = 10 + HORIZONTAL_OVERFLOW_PADDING, + }; + } + + [BackgroundDependencyLoader] + private void load() + { + Triangles.TriangleScale = 1.5f; + + Text = "Create room"; } } } diff --git a/osu.Game/Screens/Multi/MultiplayerComposite.cs b/osu.Game/Screens/Multi/MultiplayerComposite.cs index 8c09d576ff..e612e77748 100644 --- a/osu.Game/Screens/Multi/MultiplayerComposite.cs +++ b/osu.Game/Screens/Multi/MultiplayerComposite.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; @@ -32,10 +31,7 @@ namespace osu.Game.Screens.Multi protected BindableList<PlaylistItem> Playlist { get; private set; } [Resolved(typeof(Room))] - protected Bindable<PlaylistItem> CurrentItem { get; private set; } - - [Resolved(typeof(Room))] - protected Bindable<IEnumerable<User>> Participants { get; private set; } + protected BindableList<User> RecentParticipants { get; private set; } [Resolved(typeof(Room))] protected Bindable<int> ParticipantCount { get; private set; } diff --git a/osu.Game/Screens/Multi/MultiplayerSubScreen.cs b/osu.Game/Screens/Multi/MultiplayerSubScreen.cs index ff94f63f01..8e46de1a95 100644 --- a/osu.Game/Screens/Multi/MultiplayerSubScreen.cs +++ b/osu.Game/Screens/Multi/MultiplayerSubScreen.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; -using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Multi { @@ -24,31 +23,41 @@ namespace osu.Game.Screens.Multi RelativeSizeAxes = Axes.Both; } + public const float X_SHIFT = 200; + + public const double X_MOVE_DURATION = 800; + + public const double RESUME_TRANSITION_DELAY = DISAPPEAR_DURATION / 2; + + public const double APPEAR_DURATION = 800; + + public const double DISAPPEAR_DURATION = 500; + public override void OnEntering(IScreen last) { - this.FadeInFromZero(WaveContainer.APPEAR_DURATION, Easing.OutQuint); - this.FadeInFromZero(WaveContainer.APPEAR_DURATION, Easing.OutQuint); - this.MoveToX(200).MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); + this.FadeInFromZero(APPEAR_DURATION, Easing.OutQuint); + this.FadeInFromZero(APPEAR_DURATION, Easing.OutQuint); + this.MoveToX(X_SHIFT).MoveToX(0, X_MOVE_DURATION, Easing.OutQuint); } public override bool OnExiting(IScreen next) { - this.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint); - this.MoveToX(200, WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint); + this.FadeOut(DISAPPEAR_DURATION, Easing.OutQuint); + this.MoveToX(X_SHIFT, X_MOVE_DURATION, Easing.OutQuint); return false; } public override void OnResuming(IScreen last) { - this.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint); - this.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint); + this.Delay(RESUME_TRANSITION_DELAY).FadeIn(APPEAR_DURATION, Easing.OutQuint); + this.MoveToX(0, X_MOVE_DURATION, Easing.OutQuint); } public override void OnSuspending(IScreen next) { - this.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint); - this.MoveToX(-200, WaveContainer.DISAPPEAR_DURATION, Easing.OutQuint); + this.FadeOut(DISAPPEAR_DURATION, Easing.OutQuint); + this.MoveToX(-X_SHIFT, X_MOVE_DURATION, Easing.OutQuint); } public override string ToString() => Title; diff --git a/osu.Game/Screens/Multi/MultiplayerSubScreenStack.cs b/osu.Game/Screens/Multi/MultiplayerSubScreenStack.cs new file mode 100644 index 0000000000..3b0ed0dba1 --- /dev/null +++ b/osu.Game/Screens/Multi/MultiplayerSubScreenStack.cs @@ -0,0 +1,24 @@ +// 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. + +using osu.Framework.Screens; + +namespace osu.Game.Screens.Multi +{ + public class MultiplayerSubScreenStack : OsuScreenStack + { + protected override void ScreenChanged(IScreen prev, IScreen next) + { + base.ScreenChanged(prev, next); + + // because this is a screen stack within a screen stack, let's manually handle disabled changes to simplify things. + var osuScreen = ((OsuScreen)next); + + bool disallowChanges = osuScreen.DisallowExternalBeatmapRulesetChanges; + + osuScreen.Beatmap.Disabled = disallowChanges; + osuScreen.Ruleset.Disabled = disallowChanges; + osuScreen.Mods.Disabled = disallowChanges; + } + } +} diff --git a/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs b/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs index 88c6fc5e2e..04da943a10 100644 --- a/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs +++ b/osu.Game/Screens/Multi/Play/TimeshiftPlayer.cs @@ -10,7 +10,6 @@ using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.Multiplayer; using osu.Game.Rulesets; using osu.Game.Scoring; @@ -50,22 +49,22 @@ namespace osu.Game.Screens.Multi.Play bool failed = false; // Sanity checks to ensure that TimeshiftPlayer matches the settings for the current PlaylistItem - if (Beatmap.Value.BeatmapInfo.OnlineBeatmapID != playlistItem.Beatmap.OnlineBeatmapID) + if (Beatmap.Value.BeatmapInfo.OnlineBeatmapID != playlistItem.Beatmap.Value.OnlineBeatmapID) throw new InvalidOperationException("Current Beatmap does not match PlaylistItem's Beatmap"); - if (ruleset.Value.ID != playlistItem.Ruleset.ID) + if (ruleset.Value.ID != playlistItem.Ruleset.Value.ID) throw new InvalidOperationException("Current Ruleset does not match PlaylistItem's Ruleset"); 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); + var req = new CreateRoomScoreRequest(roomId.Value ?? 0, playlistItem.ID, Game.VersionHash); req.Success += r => token = r.ID; req.Failure += e => { failed = true; - Logger.Error(e, "Failed to retrieve a score submission token."); + Logger.Error(e, "Failed to retrieve a score submission token.\n\nThis may happen if you are running an old or non-official release of osu! (ie. you are self-compiling)."); Schedule(() => { @@ -90,23 +89,25 @@ namespace osu.Game.Screens.Multi.Play return false; } - protected override ScoreInfo CreateScore() + protected override ResultsScreen CreateResults(ScoreInfo score) { - submitScore(); - return base.CreateScore(); + Debug.Assert(roomId.Value != null); + return new TimeshiftResultsScreen(score, roomId.Value.Value, playlistItem); } - private void submitScore() + protected override ScoreInfo CreateScore() { var score = base.CreateScore(); - score.TotalScore = (int)Math.Round(ScoreProcessor.GetStandardisedScore()); Debug.Assert(token != null); var request = new SubmitRoomScoreRequest(token.Value, roomId.Value ?? 0, playlistItem.ID, score); + request.Success += s => score.OnlineScoreID = s.ID; request.Failure += e => Logger.Error(e, "Failed to submit score"); api.Queue(request); + + return score; } protected override void Dispose(bool isDisposing) @@ -115,7 +116,5 @@ namespace osu.Game.Screens.Multi.Play Exited = null; } - - protected override Results CreateResults(ScoreInfo score) => new MatchResults(score); } } diff --git a/osu.Game/Screens/Multi/Ranking/MatchResults.cs b/osu.Game/Screens/Multi/Ranking/MatchResults.cs deleted file mode 100644 index fe68d7e849..0000000000 --- a/osu.Game/Screens/Multi/Ranking/MatchResults.cs +++ /dev/null @@ -1,26 +0,0 @@ -// 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. - -using System.Collections.Generic; -using osu.Game.Scoring; -using osu.Game.Screens.Multi.Ranking.Types; -using osu.Game.Screens.Ranking; -using osu.Game.Screens.Ranking.Types; - -namespace osu.Game.Screens.Multi.Ranking -{ - public class MatchResults : Results - { - public MatchResults(ScoreInfo score) - : base(score) - { - } - - protected override IEnumerable<IResultPageInfo> CreateResultPages() => new IResultPageInfo[] - { - new ScoreOverviewPageInfo(Score, Beatmap.Value), - new LocalLeaderboardPageInfo(Score, Beatmap.Value), - new RoomLeaderboardPageInfo(Score, Beatmap.Value), - }; - } -} diff --git a/osu.Game/Screens/Multi/Ranking/Pages/RoomLeaderboardPage.cs b/osu.Game/Screens/Multi/Ranking/Pages/RoomLeaderboardPage.cs deleted file mode 100644 index d20b021fc6..0000000000 --- a/osu.Game/Screens/Multi/Ranking/Pages/RoomLeaderboardPage.cs +++ /dev/null @@ -1,135 +0,0 @@ -// 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. - -using System; -using System.Collections.Generic; -using Microsoft.EntityFrameworkCore.Internal; -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.Lists; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Leaderboards; -using osu.Game.Online.Multiplayer; -using osu.Game.Scoring; -using osu.Game.Screens.Multi.Match.Components; -using osu.Game.Screens.Ranking; - -namespace osu.Game.Screens.Multi.Ranking.Pages -{ - public class RoomLeaderboardPage : ResultsPage - { - private OsuColour colours; - private TextFlowContainer rankText; - - [Resolved(typeof(Room), nameof(Room.Name))] - private Bindable<string> name { get; set; } - - public RoomLeaderboardPage(ScoreInfo score, WorkingBeatmap beatmap) - : base(score, beatmap) - { - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - this.colours = colours; - - MatchLeaderboard leaderboard; - - Children = new Drawable[] - { - new Box - { - Colour = colours.Gray6, - RelativeSizeAxes = Axes.Both, - }, - new BufferedContainer - { - RelativeSizeAxes = Axes.Both, - BackgroundColour = colours.Gray6, - Child = leaderboard = CreateLeaderboard() - }, - rankText = new TextFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Width = 0.5f, - AutoSizeAxes = Axes.Y, - Y = 50, - TextAnchor = Anchor.TopCentre - }, - }; - - leaderboard.Origin = Anchor.Centre; - leaderboard.Anchor = Anchor.Centre; - leaderboard.RelativeSizeAxes = Axes.Both; - leaderboard.Height = 0.8f; - leaderboard.Y = 55; - leaderboard.ScoresLoaded = scoresLoaded; - } - - private void scoresLoaded(IEnumerable<APIRoomScoreInfo> scores) - { - Action<SpriteText> gray = s => s.Colour = colours.GrayC; - Action<SpriteText> white = s => - { - s.Font = s.Font.With(size: s.Font.Size * 1.4f); - s.Colour = colours.GrayF; - }; - - rankText.AddText(name + "\n", white); - rankText.AddText("You are placed ", gray); - - int index = scores.IndexOf(new APIRoomScoreInfo { User = Score.User }, new FuncEqualityComparer<APIRoomScoreInfo>((s1, s2) => s1.User.Id.Equals(s2.User.Id))); - - rankText.AddText($"#{index + 1} ", s => - { - s.Font = s.Font.With(Typeface.Exo, weight: FontWeight.Bold); - s.Colour = colours.YellowDark; - }); - - rankText.AddText("in the room!", gray); - } - - protected virtual MatchLeaderboard CreateLeaderboard() => new ResultsMatchLeaderboard(); - - public class ResultsMatchLeaderboard : MatchLeaderboard - { - protected override bool FadeTop => true; - - protected override LeaderboardScore CreateDrawableScore(APIRoomScoreInfo model, int index) - => new ResultsMatchLeaderboardScore(model, index); - - protected override FillFlowContainer<LeaderboardScore> CreateScoreFlow() - { - var flow = base.CreateScoreFlow(); - flow.Padding = new MarginPadding - { - Top = LeaderboardScore.HEIGHT * 2, - Bottom = LeaderboardScore.HEIGHT * 3, - }; - return flow; - } - - private class ResultsMatchLeaderboardScore : MatchLeaderboardScore - { - public ResultsMatchLeaderboardScore(APIRoomScoreInfo score, int rank) - : base(score, rank) - { - } - - [BackgroundDependencyLoader] - private void load() - { - } - } - } - } -} diff --git a/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs b/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs new file mode 100644 index 0000000000..8da6a530a8 --- /dev/null +++ b/osu.Game/Screens/Multi/Ranking/TimeshiftResultsScreen.cs @@ -0,0 +1,250 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using JetBrains.Annotations; +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.Multiplayer; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Screens.Multi.Ranking +{ + public class TimeshiftResultsScreen : ResultsScreen + { + private readonly int roomId; + private readonly PlaylistItem playlistItem; + + protected LoadingSpinner LeftSpinner { get; private set; } + protected LoadingSpinner CentreSpinner { get; private set; } + protected LoadingSpinner RightSpinner { get; private set; } + + private MultiplayerScores higherScores; + private MultiplayerScores lowerScores; + + [Resolved] + private IAPIProvider api { get; set; } + + public TimeshiftResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) + : base(score, allowRetry) + { + this.roomId = roomId; + this.playlistItem = playlistItem; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = TwoLayerButton.SIZE_EXTENDED.Y }, + Children = new Drawable[] + { + LeftSpinner = new PanelListLoadingSpinner(ScorePanelList) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.Centre, + }, + CentreSpinner = new PanelListLoadingSpinner(ScorePanelList) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { Value = Score == null ? Visibility.Visible : Visibility.Hidden }, + }, + RightSpinner = new PanelListLoadingSpinner(ScorePanelList) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.Centre, + }, + } + }); + } + + protected override APIRequest FetchScores(Action<IEnumerable<ScoreInfo>> scoresCallback) + { + // This performs two requests: + // 1. A request to show the user's score (and scores around). + // 2. If that fails, a request to index the room starting from the highest score. + + var userScoreReq = new ShowPlaylistUserScoreRequest(roomId, playlistItem.ID, api.LocalUser.Value.Id); + + userScoreReq.Success += userScore => + { + var allScores = new List<MultiplayerScore> { userScore }; + + if (userScore.ScoresAround?.Higher != null) + { + allScores.AddRange(userScore.ScoresAround.Higher.Scores); + higherScores = userScore.ScoresAround.Higher; + + Debug.Assert(userScore.Position != null); + setPositions(higherScores, userScore.Position.Value, -1); + } + + if (userScore.ScoresAround?.Lower != null) + { + allScores.AddRange(userScore.ScoresAround.Lower.Scores); + lowerScores = userScore.ScoresAround.Lower; + + Debug.Assert(userScore.Position != null); + setPositions(lowerScores, userScore.Position.Value, 1); + } + + performSuccessCallback(scoresCallback, allScores); + }; + + // On failure, fallback to a normal index. + userScoreReq.Failure += _ => api.Queue(createIndexRequest(scoresCallback)); + + return userScoreReq; + } + + protected override APIRequest FetchNextPage(int direction, Action<IEnumerable<ScoreInfo>> scoresCallback) + { + Debug.Assert(direction == 1 || direction == -1); + + MultiplayerScores pivot = direction == -1 ? higherScores : lowerScores; + + if (pivot?.Cursor == null) + return null; + + if (pivot == higherScores) + LeftSpinner.Show(); + else + RightSpinner.Show(); + + return createIndexRequest(scoresCallback, pivot); + } + + /// <summary> + /// Creates a <see cref="IndexPlaylistScoresRequest"/> with an optional score pivot. + /// </summary> + /// <remarks>Does not queue the request.</remarks> + /// <param name="scoresCallback">The callback to perform with the resulting scores.</param> + /// <param name="pivot">An optional score pivot to retrieve scores around. Can be null to retrieve scores from the highest score.</param> + /// <returns>The indexing <see cref="APIRequest"/>.</returns> + private APIRequest createIndexRequest(Action<IEnumerable<ScoreInfo>> scoresCallback, [CanBeNull] MultiplayerScores pivot = null) + { + var indexReq = pivot != null + ? new IndexPlaylistScoresRequest(roomId, playlistItem.ID, pivot.Cursor, pivot.Params) + : new IndexPlaylistScoresRequest(roomId, playlistItem.ID); + + indexReq.Success += r => + { + if (pivot == lowerScores) + { + lowerScores = r; + setPositions(r, pivot, 1); + } + else + { + higherScores = r; + setPositions(r, pivot, -1); + } + + performSuccessCallback(scoresCallback, r.Scores, r); + }; + + indexReq.Failure += _ => hideLoadingSpinners(pivot); + + return indexReq; + } + + /// <summary> + /// Transforms returned <see cref="MultiplayerScores"/> into <see cref="ScoreInfo"/>s, ensure the <see cref="ScorePanelList"/> is put into a sane state, and invokes a given success callback. + /// </summary> + /// <param name="callback">The callback to invoke with the final <see cref="ScoreInfo"/>s.</param> + /// <param name="scores">The <see cref="MultiplayerScore"/>s that were retrieved from <see cref="APIRequest"/>s.</param> + /// <param name="pivot">An optional pivot around which the scores were retrieved.</param> + private void performSuccessCallback([NotNull] Action<IEnumerable<ScoreInfo>> callback, [NotNull] List<MultiplayerScore> scores, [CanBeNull] MultiplayerScores pivot = null) + { + var scoreInfos = new List<ScoreInfo>(scores.Select(s => s.CreateScoreInfo(playlistItem))); + + // Select a score if we don't already have one selected. + // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). + if (SelectedScore.Value == null) + { + Schedule(() => + { + // Prefer selecting the local user's score, or otherwise default to the first visible score. + SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.Id == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); + }); + } + + // Invoke callback to add the scores. Exclude the user's current score which was added previously. + callback.Invoke(scoreInfos.Where(s => s.OnlineScoreID != Score?.OnlineScoreID)); + + hideLoadingSpinners(pivot); + } + + private void hideLoadingSpinners([CanBeNull] MultiplayerScores pivot = null) + { + CentreSpinner.Hide(); + + if (pivot == lowerScores) + RightSpinner.Hide(); + else if (pivot == higherScores) + LeftSpinner.Hide(); + } + + /// <summary> + /// Applies positions to all <see cref="MultiplayerScore"/>s referenced to a given pivot. + /// </summary> + /// <param name="scores">The <see cref="MultiplayerScores"/> to set positions on.</param> + /// <param name="pivot">The pivot.</param> + /// <param name="increment">The amount to increment the pivot position by for each <see cref="MultiplayerScore"/> in <paramref name="scores"/>.</param> + private void setPositions([NotNull] MultiplayerScores scores, [CanBeNull] MultiplayerScores pivot, int increment) + => setPositions(scores, pivot?.Scores[^1].Position ?? 0, increment); + + /// <summary> + /// Applies positions to all <see cref="MultiplayerScore"/>s referenced to a given pivot. + /// </summary> + /// <param name="scores">The <see cref="MultiplayerScores"/> to set positions on.</param> + /// <param name="pivotPosition">The pivot position.</param> + /// <param name="increment">The amount to increment the pivot position by for each <see cref="MultiplayerScore"/> in <paramref name="scores"/>.</param> + private void setPositions([NotNull] MultiplayerScores scores, int pivotPosition, int increment) + { + foreach (var s in scores.Scores) + { + pivotPosition += increment; + s.Position = pivotPosition; + } + } + + private class PanelListLoadingSpinner : LoadingSpinner + { + private readonly ScorePanelList list; + + /// <summary> + /// Creates a new <see cref="PanelListLoadingSpinner"/>. + /// </summary> + /// <param name="list">The list to track.</param> + /// <param name="withBox">Whether the spinner should have a surrounding black box for visibility.</param> + public PanelListLoadingSpinner(ScorePanelList list, bool withBox = true) + : base(withBox) + { + this.list = list; + } + + protected override void Update() + { + base.Update(); + + float panelOffset = list.DrawWidth / 2 - ScorePanel.EXPANDED_WIDTH; + + if ((Anchor & Anchor.x0) > 0) + X = (float)(panelOffset - list.Current); + else if ((Anchor & Anchor.x2) > 0) + X = (float)(list.ScrollableExtent - list.Current - panelOffset); + } + } + } +} diff --git a/osu.Game/Screens/Multi/Ranking/Types/RoomLeaderboardPageInfo.cs b/osu.Game/Screens/Multi/Ranking/Types/RoomLeaderboardPageInfo.cs deleted file mode 100644 index dcfad8458f..0000000000 --- a/osu.Game/Screens/Multi/Ranking/Types/RoomLeaderboardPageInfo.cs +++ /dev/null @@ -1,29 +0,0 @@ -// 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. - -using osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps; -using osu.Game.Scoring; -using osu.Game.Screens.Multi.Ranking.Pages; -using osu.Game.Screens.Ranking; - -namespace osu.Game.Screens.Multi.Ranking.Types -{ - public class RoomLeaderboardPageInfo : IResultPageInfo - { - private readonly ScoreInfo score; - private readonly WorkingBeatmap beatmap; - - public RoomLeaderboardPageInfo(ScoreInfo score, WorkingBeatmap beatmap) - { - this.score = score; - this.beatmap = beatmap; - } - - public IconUsage Icon => FontAwesome.Solid.Users; - - public string Name => "Room Leaderboard"; - - public virtual ResultsPage CreatePage() => new RoomLeaderboardPage(score, beatmap); - } -} diff --git a/osu.Game/Screens/Multi/RoomManager.cs b/osu.Game/Screens/Multi/RoomManager.cs index 6f473aaafa..2a96fa536d 100644 --- a/osu.Game/Screens/Multi/RoomManager.cs +++ b/osu.Game/Screens/Multi/RoomManager.cs @@ -2,35 +2,45 @@ // 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 osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Online; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.Multiplayer; using osu.Game.Rulesets; using osu.Game.Screens.Multi.Lounge.Components; namespace osu.Game.Screens.Multi { - public class RoomManager : PollingComponent, IRoomManager + public class RoomManager : CompositeDrawable, IRoomManager { public event Action RoomsUpdated; private readonly BindableList<Room> rooms = new BindableList<Room>(); + + public Bindable<bool> InitialRoomsReceived { get; } = new Bindable<bool>(); + public IBindableList<Room> Rooms => rooms; - private Room joinedRoom; + public double TimeBetweenListingPolls + { + get => listingPollingComponent.TimeBetweenPolls; + set => listingPollingComponent.TimeBetweenPolls = value; + } - [Resolved] - private Bindable<FilterCriteria> currentFilter { get; set; } - - [Resolved] - private IAPIProvider api { get; set; } + public double TimeBetweenSelectionPolls + { + get => selectionPollingComponent.TimeBetweenPolls; + set => selectionPollingComponent.TimeBetweenPolls = value; + } [Resolved] private RulesetStore rulesets { get; set; } @@ -38,14 +48,30 @@ namespace osu.Game.Screens.Multi [Resolved] private BeatmapManager beatmaps { get; set; } - [BackgroundDependencyLoader] - private void load() + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private Bindable<Room> selectedRoom { get; set; } + + private readonly ListingPollingComponent listingPollingComponent; + private readonly SelectionPollingComponent selectionPollingComponent; + + private Room joinedRoom; + + public RoomManager() { - currentFilter.BindValueChanged(_ => + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] { - if (IsLoaded) - PollImmediately(); - }); + listingPollingComponent = new ListingPollingComponent + { + InitialRoomsReceived = { BindTarget = InitialRoomsReceived }, + RoomsReceived = onListingReceived + }, + selectionPollingComponent = new SelectionPollingComponent { RoomReceived = onSelectedRoomReceived } + }; } protected override void Dispose(bool isDisposing) @@ -87,9 +113,8 @@ namespace osu.Game.Screens.Multi public void JoinRoom(Room room, Action<Room> onSuccess = null, Action<string> onError = null) { currentJoinRoomRequest?.Cancel(); - currentJoinRoomRequest = null; + currentJoinRoomRequest = new JoinRoomRequest(room); - currentJoinRoomRequest = new JoinRoomRequest(room, api.LocalUser.Value); currentJoinRoomRequest.Success += () => { joinedRoom = room; @@ -98,7 +123,8 @@ namespace osu.Game.Screens.Multi currentJoinRoomRequest.Failure += exception => { - Logger.Log($"Failed to join room: {exception}", level: LogLevel.Important); + if (!(exception is OperationCanceledException)) + Logger.Log($"Failed to join room: {exception}", level: LogLevel.Important); onError?.Invoke(exception.ToString()); }; @@ -107,52 +133,79 @@ namespace osu.Game.Screens.Multi public void PartRoom() { + currentJoinRoomRequest?.Cancel(); + if (joinedRoom == null) return; - api.Queue(new PartRoomRequest(joinedRoom, api.LocalUser.Value)); + api.Queue(new PartRoomRequest(joinedRoom)); joinedRoom = null; } - private GetRoomsRequest pollReq; + private readonly HashSet<int> ignoredRooms = new HashSet<int>(); - protected override Task Poll() + /// <summary> + /// Invoked when the listing of all <see cref="Room"/>s is received from the server. + /// </summary> + /// <param name="listing">The listing.</param> + private void onListingReceived(List<Room> listing) { - if (!api.IsLoggedIn) - return base.Poll(); - - var tcs = new TaskCompletionSource<bool>(); - - pollReq?.Cancel(); - pollReq = new GetRoomsRequest(currentFilter.Value.PrimaryFilter); - - pollReq.Success += result => + // Remove past matches + foreach (var r in rooms.ToList()) { - // Remove past matches - foreach (var r in rooms.ToList()) + if (listing.All(e => e.RoomID.Value != r.RoomID.Value)) + rooms.Remove(r); + } + + for (int i = 0; i < listing.Count; i++) + { + if (selectedRoom.Value?.RoomID?.Value == listing[i].RoomID.Value) { - if (result.All(e => e.RoomID.Value != r.RoomID.Value)) - rooms.Remove(r); + // The listing request contains less data than the selection request, so data from the selection request is always preferred while the room is selected. + continue; } - for (int i = 0; i < result.Count; i++) + var room = listing[i]; + + Debug.Assert(room.RoomID.Value != null); + + if (ignoredRooms.Contains(room.RoomID.Value.Value)) + continue; + + room.Position.Value = i; + + try { - var r = result[i]; - r.Position.Value = i; - - update(r, r); - addRoom(r); + update(room, room); + addRoom(room); } + catch (Exception ex) + { + Logger.Error(ex, $"Failed to update room: {room.Name.Value}."); - RoomsUpdated?.Invoke(); - tcs.SetResult(true); - }; + ignoredRooms.Add(room.RoomID.Value.Value); + rooms.Remove(room); + } + } - pollReq.Failure += _ => tcs.SetResult(false); + RoomsUpdated?.Invoke(); + } - api.Queue(pollReq); - - return tcs.Task; + /// <summary> + /// Invoked when a <see cref="Room"/> is received from the server. + /// </summary> + /// <param name="toUpdate">The received <see cref="Room"/>.</param> + private void onSelectedRoomReceived(Room toUpdate) + { + foreach (var room in rooms) + { + if (room.RoomID.Value == toUpdate.RoomID.Value) + { + toUpdate.Position.Value = room.Position.Value; + update(room, toUpdate); + break; + } + } } /// <summary> @@ -180,5 +233,105 @@ namespace osu.Game.Screens.Multi else existing.CopyFrom(room); } + + private class SelectionPollingComponent : PollingComponent + { + public Action<Room> RoomReceived; + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private Bindable<Room> selectedRoom { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + selectedRoom.BindValueChanged(_ => + { + if (IsLoaded) + PollImmediately(); + }); + } + + private GetRoomRequest pollReq; + + protected override Task Poll() + { + if (!api.IsLoggedIn) + return base.Poll(); + + if (selectedRoom.Value?.RoomID.Value == null) + return base.Poll(); + + var tcs = new TaskCompletionSource<bool>(); + + pollReq?.Cancel(); + pollReq = new GetRoomRequest(selectedRoom.Value.RoomID.Value.Value); + + pollReq.Success += result => + { + RoomReceived?.Invoke(result); + tcs.SetResult(true); + }; + + pollReq.Failure += _ => tcs.SetResult(false); + + api.Queue(pollReq); + + return tcs.Task; + } + } + + private class ListingPollingComponent : PollingComponent + { + public Action<List<Room>> RoomsReceived; + + public readonly Bindable<bool> InitialRoomsReceived = new Bindable<bool>(); + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private Bindable<FilterCriteria> currentFilter { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + currentFilter.BindValueChanged(_ => + { + InitialRoomsReceived.Value = false; + + if (IsLoaded) + PollImmediately(); + }); + } + + private GetRoomsRequest pollReq; + + protected override Task Poll() + { + if (!api.IsLoggedIn) + return base.Poll(); + + var tcs = new TaskCompletionSource<bool>(); + + pollReq?.Cancel(); + pollReq = new GetRoomsRequest(currentFilter.Value.StatusFilter, currentFilter.Value.RoomCategoryFilter); + + pollReq.Success += result => + { + InitialRoomsReceived.Value = true; + RoomsReceived?.Invoke(result); + tcs.SetResult(true); + }; + + pollReq.Failure += _ => tcs.SetResult(false); + + api.Queue(pollReq); + + return tcs.Task; + } + } } } diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 328631ff9c..851aedd84f 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -1,8 +1,8 @@ // 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. +using System; using System.Collections.Generic; -using Microsoft.EntityFrameworkCore.Internal; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -14,7 +14,6 @@ using osu.Game.Rulesets; using osu.Game.Screens.Menu; using osu.Game.Overlays; using osu.Game.Users; -using osu.Game.Online.API; using osu.Game.Rulesets.Mods; namespace osu.Game.Screens @@ -23,14 +22,14 @@ namespace osu.Game.Screens { /// <summary> /// The amount of negative padding that should be applied to game background content which touches both the left and right sides of the screen. - /// This allows for the game content to be pushed byt he options/notification overlays without causing black areas to appear. + /// This allows for the game content to be pushed by the options/notification overlays without causing black areas to appear. /// </summary> public const float HORIZONTAL_OVERFLOW_PADDING = 50; /// <summary> /// A user-facing title for this screen. /// </summary> - public virtual string Title => GetType().ShortDisplayName(); + public virtual string Title => GetType().Name; public string Description => Title; @@ -44,37 +43,31 @@ namespace osu.Game.Screens public virtual bool HideOverlaysOnEnter => false; /// <summary> - /// Whether overlays should be able to be opened once this screen is entered or resumed. + /// The initial overlay activation mode to use when this screen is entered for the first time. /// </summary> - public virtual OverlayActivation InitialOverlayActivationMode => OverlayActivation.All; + protected virtual OverlayActivation InitialOverlayActivationMode => OverlayActivation.All; + + protected readonly Bindable<OverlayActivation> OverlayActivationMode; + + IBindable<OverlayActivation> IOsuScreen.OverlayActivationMode => OverlayActivationMode; public virtual bool CursorVisible => true; protected new OsuGameBase Game => base.Game as OsuGameBase; /// <summary> - /// The <see cref="UserActivity"/> to set the user's activity automatically to when this screen is entered - /// <para>This <see cref="Activity"/> will be automatically set to <see cref="InitialActivity"/> for this screen on entering unless - /// <see cref="Activity"/> is manually set before.</para> + /// The <see cref="UserActivity"/> to set the user's activity automatically to when this screen is entered. + /// <para>This <see cref="Activity"/> will be automatically set to <see cref="InitialActivity"/> for this screen on entering for the first time + /// unless <see cref="Activity"/> is manually set before.</para> /// </summary> protected virtual UserActivity InitialActivity => null; - private UserActivity activity; - /// <summary> /// The current <see cref="UserActivity"/> for this screen. /// </summary> - protected UserActivity Activity - { - get => activity; - set - { - if (value == activity) return; + protected readonly Bindable<UserActivity> Activity = new Bindable<UserActivity>(); - activity = value; - updateActivity(); - } - } + IBindable<UserActivity> IOsuScreen.Activity => Activity; /// <summary> /// Whether to disallow changes to game-wise Beatmap/Ruleset bindables for this screen (and all children). @@ -87,21 +80,38 @@ namespace osu.Game.Screens public virtual float BackgroundParallaxAmount => 1; + public virtual bool AllowRateAdjustments => true; + public Bindable<WorkingBeatmap> Beatmap { get; private set; } public Bindable<RulesetInfo> Ruleset { get; private set; } public Bindable<IReadOnlyList<Mod>> Mods { get; private set; } + private OsuScreenDependencies screenDependencies; + + internal void CreateLeasedDependencies(IReadOnlyDependencyContainer dependencies) => createDependencies(dependencies); + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - var screenDependencies = new OsuScreenDependencies(DisallowExternalBeatmapRulesetChanges, parent); + if (screenDependencies == null) + { + if (DisallowExternalBeatmapRulesetChanges) + throw new InvalidOperationException($"Screens that specify {nameof(DisallowExternalBeatmapRulesetChanges)} must be pushed immediately."); + + createDependencies(parent); + } + + return base.CreateChildDependencies(screenDependencies); + } + + private void createDependencies(IReadOnlyDependencyContainer dependencies) + { + screenDependencies = new OsuScreenDependencies(DisallowExternalBeatmapRulesetChanges, dependencies); Beatmap = screenDependencies.Beatmap; Ruleset = screenDependencies.Ruleset; Mods = screenDependencies.Mods; - - return base.CreateChildDependencies(screenDependencies); } protected BackgroundScreen Background => backgroundStack?.CurrentScreen as BackgroundScreen; @@ -114,19 +124,20 @@ namespace osu.Game.Screens [Resolved(canBeNull: true)] private OsuLogo logo { get; set; } - [Resolved(canBeNull: true)] - private IAPIProvider api { get; set; } - protected OsuScreen() { Anchor = Anchor.Centre; Origin = Anchor.Centre; + + OverlayActivationMode = new Bindable<OverlayActivation>(InitialOverlayActivationMode); } [BackgroundDependencyLoader(true)] private void load(OsuGame osu, AudioManager audio) { sampleExit = audio.Samples.Get(@"UI/screen-back"); + + Activity.Value ??= InitialActivity; } public override void OnResuming(IScreen last) @@ -135,8 +146,6 @@ namespace osu.Game.Screens sampleExit?.Play(); applyArrivingDefaults(true); - updateActivity(); - base.OnResuming(last); } @@ -153,9 +162,6 @@ namespace osu.Game.Screens backgroundStack?.Push(localBackground = CreateBackground()); - if (activity == null) - Activity = InitialActivity; - base.OnEntering(last); } @@ -173,12 +179,6 @@ namespace osu.Game.Screens return false; } - private void updateActivity() - { - if (api != null) - api.Activity.Value = activity; - } - /// <summary> /// Fired when this screen was entered or resumed and the logo state is required to be adjusted. /// </summary> @@ -241,5 +241,7 @@ namespace osu.Game.Screens /// Note that the instance created may not be the used instance if it matches the BackgroundMode equality clause. /// </summary> protected virtual BackgroundScreen CreateBackground() => null; + + public virtual bool OnBackButton() => false; } } diff --git a/osu.Game/Screens/OsuScreenDependencies.cs b/osu.Game/Screens/OsuScreenDependencies.cs index 115f4b7e1a..8d54829d49 100644 --- a/osu.Game/Screens/OsuScreenDependencies.cs +++ b/osu.Game/Screens/OsuScreenDependencies.cs @@ -26,16 +26,26 @@ namespace osu.Game.Screens Beatmap = parent.Get<LeasedBindable<WorkingBeatmap>>()?.GetBoundCopy(); if (Beatmap == null) + { Cache(Beatmap = parent.Get<Bindable<WorkingBeatmap>>().BeginLease(false)); + CacheAs(Beatmap); + } Ruleset = parent.Get<LeasedBindable<RulesetInfo>>()?.GetBoundCopy(); if (Ruleset == null) + { Cache(Ruleset = parent.Get<Bindable<RulesetInfo>>().BeginLease(true)); + CacheAs(Ruleset); + } Mods = parent.Get<LeasedBindable<IReadOnlyList<Mod>>>()?.GetBoundCopy(); + if (Mods == null) + { Cache(Mods = parent.Get<Bindable<IReadOnlyList<Mod>>>().BeginLease(true)); + CacheAs(Mods); + } } else { diff --git a/osu.Game/Screens/OsuScreenStack.cs b/osu.Game/Screens/OsuScreenStack.cs index 0844e32d46..e2a0414df7 100644 --- a/osu.Game/Screens/OsuScreenStack.cs +++ b/osu.Game/Screens/OsuScreenStack.cs @@ -13,22 +13,11 @@ namespace osu.Game.Screens [Cached] private BackgroundScreenStack backgroundScreenStack; - private ParallaxContainer parallaxContainer; + private readonly ParallaxContainer parallaxContainer; protected float ParallaxAmount => parallaxContainer.ParallaxAmount; public OsuScreenStack() - { - initializeStack(); - } - - public OsuScreenStack(IScreen baseScreen) - : base(baseScreen) - { - initializeStack(); - } - - private void initializeStack() { InternalChild = parallaxContainer = new ParallaxContainer { @@ -36,13 +25,32 @@ namespace osu.Game.Screens Child = backgroundScreenStack = new BackgroundScreenStack { RelativeSizeAxes = Axes.Both }, }; - ScreenPushed += onScreenChange; - ScreenExited += onScreenChange; + ScreenPushed += screenPushed; + ScreenExited += ScreenChanged; } - private void onScreenChange(IScreen prev, IScreen next) + private void screenPushed(IScreen prev, IScreen next) { - parallaxContainer.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT * ((IOsuScreen)next)?.BackgroundParallaxAmount ?? 1.0f; + if (LoadState < LoadState.Ready) + { + // dependencies must be present to stay in a sane state. + // this is generally only ever hit by test scenes. + Schedule(() => screenPushed(prev, next)); + return; + } + + // create dependencies synchronously to ensure leases are in a sane state. + ((OsuScreen)next).CreateLeasedDependencies((prev as OsuScreen)?.Dependencies ?? Dependencies); + + ScreenChanged(prev, next); } + + protected virtual void ScreenChanged(IScreen prev, IScreen next) + { + setParallax(next); + } + + private void setParallax(IScreen next) => + parallaxContainer.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT * ((IOsuScreen)next)?.BackgroundParallaxAmount ?? 1.0f; } } diff --git a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs new file mode 100644 index 0000000000..5530b4beac --- /dev/null +++ b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs @@ -0,0 +1,173 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Screens.Play +{ + /// <summary> + /// Displays beatmap metadata inside <see cref="PlayerLoader"/> + /// </summary> + public class BeatmapMetadataDisplay : Container + { + private class MetadataLine : Container + { + public MetadataLine(string left, string right) + { + AutoSizeAxes = Axes.Both; + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Right = 5 }, + Colour = OsuColour.Gray(0.8f), + Text = left, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopLeft, + Margin = new MarginPadding { Left = 5 }, + Text = string.IsNullOrEmpty(right) ? @"-" : right, + } + }; + } + } + + private readonly WorkingBeatmap beatmap; + private readonly Bindable<IReadOnlyList<Mod>> mods; + private readonly Drawable facade; + private LoadingSpinner loading; + private Sprite backgroundSprite; + + public IBindable<IReadOnlyList<Mod>> Mods => mods; + + public bool Loading + { + set + { + if (value) + loading.Show(); + else + loading.Hide(); + } + } + + public BeatmapMetadataDisplay(WorkingBeatmap beatmap, Bindable<IReadOnlyList<Mod>> mods, Drawable facade) + { + this.beatmap = beatmap; + this.facade = facade; + + this.mods = new Bindable<IReadOnlyList<Mod>>(); + this.mods.BindTo(mods); + } + + [BackgroundDependencyLoader] + private void load() + { + var metadata = beatmap.BeatmapInfo?.Metadata ?? new BeatmapMetadata(); + + AutoSizeAxes = Axes.Both; + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Direction = FillDirection.Vertical, + Children = new[] + { + facade.With(d => + { + d.Anchor = Anchor.TopCentre; + d.Origin = Anchor.TopCentre; + }), + new OsuSpriteText + { + Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), + Font = OsuFont.GetFont(size: 36, italics: true), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Margin = new MarginPadding { Top = 15 }, + }, + new OsuSpriteText + { + Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), + Font = OsuFont.GetFont(size: 26, italics: true), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + }, + new Container + { + Size = new Vector2(300, 60), + Margin = new MarginPadding(10), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + CornerRadius = 10, + Masking = true, + Children = new Drawable[] + { + backgroundSprite = new Sprite + { + RelativeSizeAxes = Axes.Both, + Texture = beatmap?.Background, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + FillMode = FillMode.Fill, + }, + loading = new LoadingLayer(backgroundSprite) + } + }, + new OsuSpriteText + { + Text = beatmap?.BeatmapInfo?.Version, + Font = OsuFont.GetFont(size: 26, italics: true), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Margin = new MarginPadding + { + Bottom = 40 + }, + }, + new MetadataLine("Source", metadata.Source) + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + }, + new MetadataLine("Mapper", metadata.AuthorString) + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + }, + new ModDisplay + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = 20 }, + Current = mods + }, + }, + } + }; + + Loading = true; + } + } +} diff --git a/osu.Game/Screens/Play/Break/BreakInfo.cs b/osu.Game/Screens/Play/Break/BreakInfo.cs index a3d64d05a3..6e129b20ea 100644 --- a/osu.Game/Screens/Play/Break/BreakInfo.cs +++ b/osu.Game/Screens/Play/Break/BreakInfo.cs @@ -30,7 +30,7 @@ namespace osu.Game.Screens.Play.Break Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = "current progress".ToUpperInvariant(), - Font = OsuFont.GetFont(weight: FontWeight.Black, size: 15), + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 15), }, new FillFlowContainer { diff --git a/osu.Game/Screens/Play/Break/BreakInfoLine.cs b/osu.Game/Screens/Play/Break/BreakInfoLine.cs index 70e7b8f297..18aab394f8 100644 --- a/osu.Game/Screens/Play/Break/BreakInfoLine.cs +++ b/osu.Game/Screens/Play/Break/BreakInfoLine.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Utils; namespace osu.Game.Screens.Play.Break { @@ -85,6 +86,6 @@ namespace osu.Game.Screens.Play.Break { } - protected override string Format(double count) => $@"{count:P2}"; + protected override string Format(double count) => count.FormatAccuracy(); } } diff --git a/osu.Game/Screens/Play/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index 2b401778a6..36f825b8f6 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -1,8 +1,7 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. using System.Collections.Generic; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -15,7 +14,11 @@ namespace osu.Game.Screens.Play { public class BreakOverlay : Container { - private const double fade_duration = BreakPeriod.MIN_BREAK_DURATION / 2; + /// <summary> + /// The duration of the break overlay fading. + /// </summary> + public const double BREAK_FADE_DURATION = BreakPeriod.MIN_BREAK_DURATION / 2; + private const float remaining_time_container_max_size = 0.3f; private const int vertical_margin = 25; @@ -29,7 +32,9 @@ namespace osu.Game.Screens.Play set { breaks = value; - initializeBreaks(); + + if (IsLoaded) + initializeBreaks(); } } @@ -38,12 +43,14 @@ namespace osu.Game.Screens.Play private readonly Container remainingTimeAdjustmentBox; private readonly Container remainingTimeBox; private readonly RemainingTimeCounter remainingTimeCounter; - private readonly BreakInfo info; private readonly BreakArrows breakArrows; - public BreakOverlay(bool letterboxing, ScoreProcessor scoreProcessor = null) + public BreakOverlay(bool letterboxing, ScoreProcessor scoreProcessor) { RelativeSizeAxes = Axes.Both; + + BreakInfo info; + Child = fadeContainer = new Container { Alpha = 0, @@ -94,13 +101,11 @@ namespace osu.Game.Screens.Play } }; - if (scoreProcessor != null) bindProcessor(scoreProcessor); - } - - [BackgroundDependencyLoader(true)] - private void load(GameplayClock clock) - { - if (clock != null) Clock = clock; + if (scoreProcessor != null) + { + info.AccuracyDisplay.Current.BindTo(scoreProcessor.Accuracy); + info.GradeDisplay.Current.BindTo(scoreProcessor.Rank); + } } protected override void LoadComplete() @@ -111,12 +116,10 @@ namespace osu.Game.Screens.Play private void initializeBreaks() { - if (!IsLoaded) return; // we need a clock. - FinishTransforms(true); Scheduler.CancelDelayedTasks(); - if (breaks == null) return; //we need breaks. + if (breaks == null) return; // we need breaks. foreach (var b in breaks) { @@ -125,34 +128,28 @@ namespace osu.Game.Screens.Play using (BeginAbsoluteSequence(b.StartTime, true)) { - fadeContainer.FadeIn(fade_duration); - breakArrows.Show(fade_duration); + fadeContainer.FadeIn(BREAK_FADE_DURATION); + breakArrows.Show(BREAK_FADE_DURATION); remainingTimeAdjustmentBox - .ResizeWidthTo(remaining_time_container_max_size, fade_duration, Easing.OutQuint) - .Delay(b.Duration - fade_duration) + .ResizeWidthTo(remaining_time_container_max_size, BREAK_FADE_DURATION, Easing.OutQuint) + .Delay(b.Duration - BREAK_FADE_DURATION) .ResizeWidthTo(0); remainingTimeBox - .ResizeWidthTo(0, b.Duration - fade_duration) + .ResizeWidthTo(0, b.Duration - BREAK_FADE_DURATION) .Then() .ResizeWidthTo(1); remainingTimeCounter.CountTo(b.Duration).CountTo(0, b.Duration); - using (BeginDelayedSequence(b.Duration - fade_duration, true)) + using (BeginDelayedSequence(b.Duration - BREAK_FADE_DURATION, true)) { - fadeContainer.FadeOut(fade_duration); - breakArrows.Hide(fade_duration); + fadeContainer.FadeOut(BREAK_FADE_DURATION); + breakArrows.Hide(BREAK_FADE_DURATION); } } } } - - private void bindProcessor(ScoreProcessor processor) - { - info.AccuracyDisplay.Current.BindTo(processor.Accuracy); - info.GradeDisplay.Current.BindTo(processor.Rank); - } } } diff --git a/osu.Game/Screens/Play/BreakTracker.cs b/osu.Game/Screens/Play/BreakTracker.cs new file mode 100644 index 0000000000..51e21656e1 --- /dev/null +++ b/osu.Game/Screens/Play/BreakTracker.cs @@ -0,0 +1,56 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Scoring; +using osu.Game.Utils; + +namespace osu.Game.Screens.Play +{ + public class BreakTracker : Component + { + private readonly ScoreProcessor scoreProcessor; + private readonly double gameplayStartTime; + + private PeriodTracker breaks; + + /// <summary> + /// Whether the gameplay is currently in a break. + /// </summary> + public IBindable<bool> IsBreakTime => isBreakTime; + + private readonly BindableBool isBreakTime = new BindableBool(); + + public IReadOnlyList<BreakPeriod> Breaks + { + set + { + isBreakTime.Value = false; + + breaks = new PeriodTracker(value.Where(b => b.HasEffect) + .Select(b => new Period(b.StartTime, b.EndTime - BreakOverlay.BREAK_FADE_DURATION))); + } + } + + public BreakTracker(double gameplayStartTime = 0, ScoreProcessor scoreProcessor = null) + { + this.gameplayStartTime = gameplayStartTime; + this.scoreProcessor = scoreProcessor; + } + + protected override void Update() + { + base.Update(); + + var time = Clock.CurrentTime; + + isBreakTime.Value = breaks?.IsInAny(time) == true + || time < gameplayStartTime + || scoreProcessor?.HasCompleted.Value == true; + } + } +} diff --git a/osu.Game/Screens/Play/ComboEffects.cs b/osu.Game/Screens/Play/ComboEffects.cs index 1c4ac921f0..831b2f593c 100644 --- a/osu.Game/Screens/Play/ComboEffects.cs +++ b/osu.Game/Screens/Play/ComboEffects.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; @@ -16,27 +17,34 @@ namespace osu.Game.Screens.Play private SkinnableSound comboBreakSample; + private Bindable<bool> alwaysPlay; + private bool firstTime = true; + public ComboEffects(ScoreProcessor processor) { this.processor = processor; } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { - InternalChild = comboBreakSample = new SkinnableSound(new SampleInfo("combobreak")); + InternalChild = comboBreakSample = new SkinnableSound(new SampleInfo("Gameplay/combobreak")); + alwaysPlay = config.GetBindable<bool>(OsuSetting.AlwaysPlayFirstComboBreak); } protected override void LoadComplete() { base.LoadComplete(); - processor.Combo.BindValueChanged(onComboChange, true); + processor.Combo.BindValueChanged(onComboChange); } private void onComboChange(ValueChangedEvent<int> combo) { - if (combo.NewValue == 0 && combo.OldValue > 20) + if (combo.NewValue == 0 && (combo.OldValue > 20 || (alwaysPlay.Value && firstTime))) + { comboBreakSample?.Play(); + firstTime = false; + } } } } diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs index 45dff039b6..58eb95b7c6 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.Graphics.Containers; using osu.Game.Graphics.Containers; using osu.Game.Storyboards; using osu.Game.Storyboards.Drawables; @@ -13,6 +14,8 @@ namespace osu.Game.Screens.Play /// </summary> public class DimmableStoryboard : UserDimContainer { + public Container OverlayLayerContainer { get; private set; } + private readonly Storyboard storyboard; private DrawableStoryboard drawableStoryboard; @@ -24,6 +27,8 @@ namespace osu.Game.Screens.Play [BackgroundDependencyLoader] private void load() { + Add(OverlayLayerContainer = new Container()); + initializeStoryboard(false); } @@ -33,23 +38,28 @@ namespace osu.Game.Screens.Play base.LoadComplete(); } - protected override bool ShowDimContent => ShowStoryboard.Value && UserDimLevel.Value < 1; + protected override bool ShowDimContent => IgnoreUserSettings.Value || (ShowStoryboard.Value && DimLevel < 1); private void initializeStoryboard(bool async) { if (drawableStoryboard != null) return; - if (!ShowStoryboard.Value) + if (!ShowStoryboard.Value && !IgnoreUserSettings.Value) return; drawableStoryboard = storyboard.CreateDrawable(); - drawableStoryboard.Masking = true; if (async) - LoadComponentAsync(drawableStoryboard, Add); + LoadComponentAsync(drawableStoryboard, onStoryboardCreated); else - Add(drawableStoryboard); + onStoryboardCreated(drawableStoryboard); + } + + private void onStoryboardCreated(DrawableStoryboard storyboard) + { + Add(storyboard); + OverlayLayerContainer.Add(storyboard.OverlayLayer.CreateProxy()); } } } diff --git a/osu.Game/Screens/Play/EpilepsyWarning.cs b/osu.Game/Screens/Play/EpilepsyWarning.cs new file mode 100644 index 0000000000..dc42427fbf --- /dev/null +++ b/osu.Game/Screens/Play/EpilepsyWarning.cs @@ -0,0 +1,85 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Screens.Backgrounds; +using osuTK; + +namespace osu.Game.Screens.Play +{ + public class EpilepsyWarning : VisibilityContainer + { + public const double FADE_DURATION = 250; + + public EpilepsyWarning() + { + RelativeSizeAxes = Axes.Both; + Alpha = 0f; + } + + public BackgroundScreenBeatmap DimmableBackground { get; set; } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, IBindable<WorkingBeatmap> beatmap) + { + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new SpriteIcon + { + Colour = colours.Yellow, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.ExclamationTriangle, + Size = new Vector2(50), + }, + new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 25)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.Centre, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }.With(tfc => + { + tfc.AddText("This beatmap contains scenes with "); + tfc.AddText("rapidly flashing colours", s => + { + s.Font = s.Font.With(weight: FontWeight.Bold); + s.Colour = colours.Yellow; + }); + tfc.AddText("."); + + tfc.NewParagraph(); + tfc.AddText("Please take caution if you are affected by epilepsy."); + }), + } + } + }; + } + + protected override void PopIn() + { + DimmableBackground?.FadeColour(OsuColour.Gray(0.5f), FADE_DURATION, Easing.OutQuint); + + this.FadeIn(FADE_DURATION, Easing.OutQuint); + } + + protected override void PopOut() => this.FadeOut(FADE_DURATION); + } +} diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index a3caffb620..608f20affd 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -10,7 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Graphics; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects.Drawables; using osuTK; @@ -89,6 +89,8 @@ namespace osu.Game.Screens.Play private void applyToPlayfield(Playfield playfield) { + double failTime = playfield.Time.Current; + foreach (var nested in playfield.NestedPlayfields) applyToPlayfield(nested); @@ -97,13 +99,29 @@ namespace osu.Game.Screens.Play if (appliedObjects.Contains(obj)) continue; - obj.RotateTo(RNG.NextSingle(-90, 90), duration); - obj.ScaleTo(obj.Scale * 0.5f, duration); - obj.MoveToOffset(new Vector2(0, 400), duration); + float rotation = RNG.NextSingle(-90, 90); + Vector2 originalPosition = obj.Position; + Vector2 originalScale = obj.Scale; + + dropOffScreen(obj, failTime, rotation, originalScale, originalPosition); + + // need to reapply the fail drop after judgement state changes + obj.ApplyCustomUpdateState += (o, _) => dropOffScreen(obj, failTime, rotation, originalScale, originalPosition); + appliedObjects.Add(obj); } } + private void dropOffScreen(DrawableHitObject obj, double failTime, float randomRotation, Vector2 originalScale, Vector2 originalPosition) + { + using (obj.BeginAbsoluteSequence(failTime)) + { + obj.RotateTo(randomRotation, duration); + obj.ScaleTo(originalScale * 0.5f, duration); + obj.MoveTo(originalPosition + new Vector2(0, 400), duration); + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Play/GameplayBeatmap.cs b/osu.Game/Screens/Play/GameplayBeatmap.cs new file mode 100644 index 0000000000..64894544f4 --- /dev/null +++ b/osu.Game/Screens/Play/GameplayBeatmap.cs @@ -0,0 +1,50 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Screens.Play +{ + public class GameplayBeatmap : Component, IBeatmap + { + public readonly IBeatmap PlayableBeatmap; + + public GameplayBeatmap(IBeatmap playableBeatmap) + { + PlayableBeatmap = playableBeatmap; + } + + public BeatmapInfo BeatmapInfo + { + get => PlayableBeatmap.BeatmapInfo; + set => PlayableBeatmap.BeatmapInfo = value; + } + + public BeatmapMetadata Metadata => PlayableBeatmap.Metadata; + + public ControlPointInfo ControlPointInfo => PlayableBeatmap.ControlPointInfo; + + public List<BreakPeriod> Breaks => PlayableBeatmap.Breaks; + + public double TotalBreakTime => PlayableBeatmap.TotalBreakTime; + + public IReadOnlyList<HitObject> HitObjects => PlayableBeatmap.HitObjects; + + public IEnumerable<BeatmapStatistic> GetStatistics() => PlayableBeatmap.GetStatistics(); + + public IBeatmap Clone() => PlayableBeatmap.Clone(); + + private readonly Bindable<JudgementResult> lastJudgementResult = new Bindable<JudgementResult>(); + + public IBindable<JudgementResult> LastJudgementResult => lastJudgementResult; + + public void ApplyResult(JudgementResult result) => lastJudgementResult.Value = result; + } +} diff --git a/osu.Game/Screens/Play/GameplayClock.cs b/osu.Game/Screens/Play/GameplayClock.cs index b1948d02d5..db4b5d300b 100644 --- a/osu.Game/Screens/Play/GameplayClock.cs +++ b/osu.Game/Screens/Play/GameplayClock.cs @@ -1,8 +1,11 @@ // 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. +using System.Collections.Generic; +using System.Linq; using osu.Framework.Bindables; using osu.Framework.Timing; +using osu.Framework.Utils; namespace osu.Game.Screens.Play { @@ -20,6 +23,11 @@ namespace osu.Game.Screens.Play public readonly BindableBool IsPaused = new BindableBool(); + /// <summary> + /// All adjustments applied to this clock which don't come from gameplay or mods. + /// </summary> + public virtual IEnumerable<Bindable<double>> NonGameplayAdjustments => Enumerable.Empty<Bindable<double>>(); + public GameplayClock(IFrameBasedClock underlyingClock) { this.underlyingClock = underlyingClock; @@ -29,12 +37,33 @@ namespace osu.Game.Screens.Play public double Rate => underlyingClock.Rate; + /// <summary> + /// The rate of gameplay when playback is at 100%. + /// This excludes any seeking / user adjustments. + /// </summary> + public double TrueGameplayRate + { + get + { + double baseRate = Rate; + + foreach (var adjustment in NonGameplayAdjustments) + { + if (Precision.AlmostEquals(adjustment.Value, 0)) + return 0; + + baseRate /= adjustment.Value; + } + + return baseRate; + } + } + public bool IsRunning => underlyingClock.IsRunning; public void ProcessFrame() { - // we do not want to process the underlying clock. - // this is handled by PauseContainer. + // intentionally not updating the underlying clock (handled externally). } public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; @@ -42,5 +71,7 @@ namespace osu.Game.Screens.Play public double FramesPerSecond => underlyingClock.FramesPerSecond; public FrameTimeInfo TimeInfo => underlyingClock.TimeInfo; + + public IClock Source => underlyingClock; } } diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 6a03271b86..2c83161614 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -3,18 +3,19 @@ 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; -using osu.Game.Rulesets.Mods; namespace osu.Game.Screens.Play { @@ -24,12 +25,9 @@ namespace osu.Game.Screens.Play public class GameplayClockContainer : Container { private readonly WorkingBeatmap beatmap; - private readonly IReadOnlyList<Mod> mods; - /// <summary> - /// The original source (usually a <see cref="WorkingBeatmap"/>'s track). - /// </summary> - private readonly IAdjustableClock sourceClock; + [NotNull] + private ITrack track; public readonly BindableBool IsPaused = new BindableBool(); @@ -39,8 +37,11 @@ namespace osu.Game.Screens.Play private readonly DecoupleableInterpolatingFramedClock adjustableClock; private readonly double gameplayStartTime; + private readonly bool startAtGameplayStart; - public readonly Bindable<double> UserPlaybackRate = new BindableDouble(1) + private readonly double firstHitObjectTime; + + public readonly BindableNumber<double> UserPlaybackRate = new BindableDouble(1) { Default = 1, MinValue = 0.5, @@ -51,8 +52,10 @@ namespace osu.Game.Screens.Play /// <summary> /// The final clock which is exposed to underlying components. /// </summary> - [Cached] - public readonly GameplayClock GameplayClock; + public GameplayClock GameplayClock => localGameplayClock; + + [Cached(typeof(GameplayClock))] + private readonly LocalGameplayClock localGameplayClock; private Bindable<double> userAudioOffset; @@ -60,34 +63,47 @@ namespace osu.Game.Screens.Play private readonly FramedOffsetClock platformOffsetClock; - public GameplayClockContainer(WorkingBeatmap beatmap, IReadOnlyList<Mod> mods, double gameplayStartTime) + /// <summary> + /// Creates a new <see cref="GameplayClockContainer"/>. + /// </summary> + /// <param name="beatmap">The beatmap being played.</param> + /// <param name="gameplayStartTime">The suggested time to start gameplay at.</param> + /// <param name="startAtGameplayStart"> + /// Whether <paramref name="gameplayStartTime"/> should be used regardless of when storyboard events and hitobjects are supposed to start. + /// </param> + public GameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime, bool startAtGameplayStart = false) { this.beatmap = beatmap; - this.mods = mods; this.gameplayStartTime = gameplayStartTime; + this.startAtGameplayStart = startAtGameplayStart; + track = beatmap.Track; + + firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; RelativeSizeAxes = Axes.Both; - sourceClock = (IAdjustableClock)beatmap.Track ?? new StopwatchClock(); - (sourceClock as IAdjustableAudioComponent)?.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - adjustableClock = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; // 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 FramedOffsetClock(adjustableClock) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 22 : 0 }; + platformOffsetClock = new HardwareCorrectionOffsetClock(adjustableClock) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; // the final usable gameplay clock with user-set offsets applied. - userOffsetClock = new FramedOffsetClock(platformOffsetClock); + userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock); // the clock to be exposed via DI to children. - GameplayClock = new GameplayClock(userOffsetClock); + localGameplayClock = new LocalGameplayClock(userOffsetClock); GameplayClock.IsPaused.BindTo(IsPaused); } private double totalOffset => userOffsetClock.Offset + platformOffsetClock.Offset; + /// <summary> + /// Duration before gameplay start time required before skip button displays. + /// </summary> + public const double MINIMUM_SKIP_TIME = 1000; + private readonly BindableDouble pauseFreqAdjust = new BindableDouble(1); [BackgroundDependencyLoader] @@ -96,20 +112,38 @@ namespace osu.Game.Screens.Play userAudioOffset = config.GetBindable<double>(OsuSetting.AudioOffset); userAudioOffset.BindValueChanged(offset => userOffsetClock.Offset = offset.NewValue, true); - UserPlaybackRate.ValueChanged += _ => updateRate(); + // sane default provided by ruleset. + double startTime = gameplayStartTime; - Seek(Math.Min(-beatmap.BeatmapInfo.AudioLeadIn, 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. + startTime = Math.Min(startTime, beatmap.Storyboard.FirstEventTime); + + // 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(() => { - sourceClock.Reset(); + track.Seek(0); + track.Stop(); Schedule(() => { - adjustableClock.ChangeSource(sourceClock); + adjustableClock.ChangeSource(track); updateRate(); if (!IsPaused.Value) @@ -129,6 +163,23 @@ namespace osu.Game.Screens.Play this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In); } + /// <summary> + /// Skip forward to the next valid skip point. + /// </summary> + 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); + } + /// <summary> /// Seek to a specific time in gameplay. /// <remarks> @@ -153,39 +204,84 @@ namespace osu.Game.Screens.Play IsPaused.Value = true; } - public void ResetLocalAdjustments() + /// <summary> + /// Changes the backing clock to avoid using the originally provided track. + /// </summary> + public void StopUsingBeatmapClock() { - // In the case of replays, we may have changed the playback rate. - UserPlaybackRate.Value = 1; + removeSourceClockAdjustments(); + + track = new TrackVirtual(track.Length); + adjustableClock.ChangeSource(track); } protected override void Update() { if (!IsPaused.Value) + { userOffsetClock.ProcessFrame(); + } base.Update(); } + private bool speedAdjustmentsApplied; + private void updateRate() { - if (sourceClock == null) return; + if (speedAdjustmentsApplied) + return; - sourceClock.ResetSpeedAdjustments(); + track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); + track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); - if (sourceClock is IHasTempoAdjust tempo) - tempo.TempoAdjust = UserPlaybackRate.Value; - else - sourceClock.Rate = UserPlaybackRate.Value; + localGameplayClock.MutableNonGameplayAdjustments.Add(pauseFreqAdjust); + localGameplayClock.MutableNonGameplayAdjustments.Add(UserPlaybackRate); - foreach (var mod in mods.OfType<IApplicableToClock>()) - mod.ApplyToClock(sourceClock); + speedAdjustmentsApplied = true; } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - (sourceClock as IAdjustableAudioComponent)?.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); + removeSourceClockAdjustments(); + } + + private void removeSourceClockAdjustments() + { + 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 + { + public readonly List<Bindable<double>> MutableNonGameplayAdjustments = new List<Bindable<double>>(); + + public override IEnumerable<Bindable<double>> NonGameplayAdjustments => MutableNonGameplayAdjustments; + + public LocalGameplayClock(FramedOffsetClock underlyingClock) + : base(underlyingClock) + { + } + } + + 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) + { + } } } } diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index c7e762714c..f938839be3 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -12,7 +12,6 @@ using osu.Game.Graphics; using osu.Framework.Allocation; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Shapes; -using osuTK.Input; using System.Collections.Generic; using System.Linq; using osu.Framework.Input.Bindings; @@ -25,7 +24,8 @@ namespace osu.Game.Screens.Play { public abstract class GameplayMenuOverlay : OverlayContainer, IKeyBindingHandler<GlobalAction> { - private const int transition_duration = 200; + protected const int TRANSITION_DURATION = 200; + private const int button_height = 70; private const float background_alpha = 0.75f; @@ -50,7 +50,7 @@ namespace osu.Game.Screens.Play public abstract string Description { get; } - protected internal FillFlowContainer<DialogButton> InternalButtons; + protected ButtonContainer InternalButtons; public IReadOnlyList<DialogButton> Buttons => InternalButtons; private FillFlowContainer retryCounterContainer; @@ -59,7 +59,7 @@ namespace osu.Game.Screens.Play { RelativeSizeAxes = Axes.Both; - State.ValueChanged += s => selectionIndex = -1; + State.ValueChanged += s => InternalButtons.Deselect(); } [BackgroundDependencyLoader] @@ -114,7 +114,7 @@ namespace osu.Game.Screens.Play } } }, - InternalButtons = new FillFlowContainer<DialogButton> + InternalButtons = new ButtonContainer { Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, @@ -157,14 +157,12 @@ namespace osu.Game.Screens.Play } } - protected override void PopIn() => this.FadeIn(transition_duration, Easing.In); - protected override void PopOut() => this.FadeOut(transition_duration, Easing.In); + protected override void PopIn() => this.FadeIn(TRANSITION_DURATION, Easing.In); + protected override void PopOut() => this.FadeOut(TRANSITION_DURATION, Easing.In); // Don't let mouse down events through the overlay or people can click circles while paused. protected override bool OnMouseDown(MouseDownEvent e) => true; - protected override bool OnMouseUp(MouseUpEvent e) => true; - protected override bool OnMouseMove(MouseMoveEvent e) => true; protected void AddButton(string text, Color4 colour, Action action) @@ -188,57 +186,18 @@ namespace osu.Game.Screens.Play InternalButtons.Add(button); } - private int _selectionIndex = -1; - - private int selectionIndex - { - get => _selectionIndex; - set - { - if (_selectionIndex == value) - return; - - // Deselect the previously-selected button - if (_selectionIndex != -1) - InternalButtons[_selectionIndex].Selected.Value = false; - - _selectionIndex = value; - - // Select the newly-selected button - if (_selectionIndex != -1) - InternalButtons[_selectionIndex].Selected.Value = true; - } - } - - protected override bool OnKeyDown(KeyDownEvent e) - { - if (!e.Repeat) - { - switch (e.Key) - { - case Key.Up: - if (selectionIndex == -1 || selectionIndex == 0) - selectionIndex = InternalButtons.Count - 1; - else - selectionIndex--; - return true; - - case Key.Down: - if (selectionIndex == -1 || selectionIndex == InternalButtons.Count - 1) - selectionIndex = 0; - else - selectionIndex++; - return true; - } - } - - return base.OnKeyDown(e); - } - public bool OnPressed(GlobalAction action) { switch (action) { + case GlobalAction.SelectPrevious: + InternalButtons.SelectPrevious(); + return true; + + case GlobalAction.SelectNext: + InternalButtons.SelectNext(); + return true; + case GlobalAction.Back: BackAction.Invoke(); return true; @@ -251,24 +210,16 @@ namespace osu.Game.Screens.Play return false; } - public bool OnReleased(GlobalAction action) + public void OnReleased(GlobalAction action) { - switch (action) - { - case GlobalAction.Back: - case GlobalAction.Select: - return true; - } - - return false; } private void buttonSelectionChanged(DialogButton button, bool isSelected) { if (!isSelected) - selectionIndex = -1; + InternalButtons.Deselect(); else - selectionIndex = InternalButtons.IndexOf(button); + InternalButtons.Select(button); } private void updateRetryCount() @@ -302,8 +253,49 @@ namespace osu.Game.Screens.Play }; } + protected class ButtonContainer : FillFlowContainer<DialogButton> + { + private int selectedIndex = -1; + + private void setSelected(int value) + { + if (selectedIndex == value) + return; + + // Deselect the previously-selected button + if (selectedIndex != -1) + this[selectedIndex].Selected.Value = false; + + selectedIndex = value; + + // Select the newly-selected button + if (selectedIndex != -1) + this[selectedIndex].Selected.Value = true; + } + + public void SelectNext() + { + if (selectedIndex == -1 || selectedIndex == Count - 1) + setSelected(0); + else + setSelected(selectedIndex + 1); + } + + public void SelectPrevious() + { + if (selectedIndex == -1 || selectedIndex == 0) + setSelected(Count - 1); + else + setSelected(selectedIndex - 1); + } + + public void Deselect() => setSelected(-1); + public void Select(DialogButton button) => setSelected(IndexOf(button)); + } + private class Button : DialogButton { + // required to ensure keyboard navigation always starts from an extremity (unless the cursor is moved) protected override bool OnHover(HoverEvent e) => true; protected override bool OnMouseMove(MouseMoveEvent e) @@ -312,5 +304,22 @@ namespace osu.Game.Screens.Play return base.OnMouseMove(e); } } + + [Resolved] + private GlobalActionContainer globalAction { get; set; } + + protected override bool Handle(UIEvent e) + { + switch (e) + { + case ScrollEvent _: + if (ReceivePositionalInputAt(e.ScreenSpaceMousePosition)) + return globalAction.TriggerEvent(e); + + break; + } + + return base.Handle(e); + } } } diff --git a/osu.Game/Screens/Play/HUD/ComboCounter.cs b/osu.Game/Screens/Play/HUD/ComboCounter.cs deleted file mode 100644 index 5ac3dac5f7..0000000000 --- a/osu.Game/Screens/Play/HUD/ComboCounter.cs +++ /dev/null @@ -1,200 +0,0 @@ -// 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. - -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics.Sprites; - -namespace osu.Game.Screens.Play.HUD -{ - public abstract class ComboCounter : Container - { - public BindableInt Current = new BindableInt - { - MinValue = 0, - }; - - public bool IsRolling { get; protected set; } - - protected SpriteText PopOutCount; - - protected virtual double PopOutDuration => 150; - protected virtual float PopOutScale => 2.0f; - protected virtual Easing PopOutEasing => Easing.None; - protected virtual float PopOutInitialAlpha => 0.75f; - - protected virtual double FadeOutDuration => 100; - - /// <summary> - /// Duration in milliseconds for the counter roll-up animation for each element. - /// </summary> - protected virtual double RollingDuration => 20; - - /// <summary> - /// Easing for the counter rollover animation. - /// </summary> - protected Easing RollingEasing => Easing.None; - - protected SpriteText DisplayedCountSpriteText; - - private int previousValue; - - /// <summary> - /// Base of all combo counters. - /// </summary> - protected ComboCounter() - { - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] - { - DisplayedCountSpriteText = new OsuSpriteText - { - Alpha = 0, - }, - PopOutCount = new OsuSpriteText - { - Alpha = 0, - Margin = new MarginPadding(0.05f), - } - }; - - TextSize = 80; - - Current.ValueChanged += combo => updateCount(combo.NewValue == 0); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - DisplayedCountSpriteText.Text = FormatCount(Current.Value); - DisplayedCountSpriteText.Anchor = Anchor; - DisplayedCountSpriteText.Origin = Origin; - - StopRolling(); - } - - private int displayedCount; - - /// <summary> - /// Value shown at the current moment. - /// </summary> - public virtual int DisplayedCount - { - get => displayedCount; - protected set - { - if (displayedCount.Equals(value)) - return; - - updateDisplayedCount(displayedCount, value, IsRolling); - } - } - - private float textSize; - - public float TextSize - { - get => textSize; - set - { - textSize = value; - - DisplayedCountSpriteText.Font = DisplayedCountSpriteText.Font.With(size: TextSize); - PopOutCount.Font = PopOutCount.Font.With(size: TextSize); - } - } - - /// <summary> - /// Increments the combo by an amount. - /// </summary> - /// <param name="amount"></param> - public void Increment(int amount = 1) - { - Current.Value = Current.Value + amount; - } - - /// <summary> - /// Stops rollover animation, forcing the displayed count to be the actual count. - /// </summary> - public void StopRolling() - { - updateCount(false); - } - - protected virtual string FormatCount(int count) - { - return count.ToString(); - } - - protected virtual void OnCountRolling(int currentValue, int newValue) - { - transformRoll(currentValue, newValue); - } - - protected virtual void OnCountIncrement(int currentValue, int newValue) - { - DisplayedCount = newValue; - } - - protected virtual void OnCountChange(int currentValue, int newValue) - { - DisplayedCount = newValue; - } - - private double getProportionalDuration(int currentValue, int newValue) - { - double difference = currentValue > newValue ? currentValue - newValue : newValue - currentValue; - return difference * RollingDuration; - } - - private void updateDisplayedCount(int currentValue, int newValue, bool rolling) - { - displayedCount = newValue; - if (rolling) - OnDisplayedCountRolling(currentValue, newValue); - else if (currentValue + 1 == newValue) - OnDisplayedCountIncrement(newValue); - else - OnDisplayedCountChange(newValue); - } - - private void updateCount(bool rolling) - { - int prev = previousValue; - previousValue = Current.Value; - - if (!IsLoaded) - return; - - if (!rolling) - { - FinishTransforms(false, nameof(DisplayedCount)); - IsRolling = false; - DisplayedCount = prev; - - if (prev + 1 == Current.Value) - OnCountIncrement(prev, Current.Value); - else - OnCountChange(prev, Current.Value); - } - else - { - OnCountRolling(displayedCount, Current.Value); - IsRolling = true; - } - } - - private void transformRoll(int currentValue, int newValue) - { - this.TransformTo(nameof(DisplayedCount), newValue, getProportionalDuration(currentValue, newValue), RollingEasing); - } - - protected abstract void OnDisplayedCountRolling(int currentValue, int newValue); - protected abstract void OnDisplayedCountIncrement(int newValue); - protected abstract void OnDisplayedCountChange(int newValue); - } -} diff --git a/osu.Game/Screens/Play/HUD/ComboResultCounter.cs b/osu.Game/Screens/Play/HUD/ComboResultCounter.cs deleted file mode 100644 index 3f6b1e29e6..0000000000 --- a/osu.Game/Screens/Play/HUD/ComboResultCounter.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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. - -using osu.Framework.Graphics; -using osu.Game.Graphics.UserInterface; - -namespace osu.Game.Screens.Play.HUD -{ - /// <summary> - /// Used to display combo with a roll-up animation in results screen. - /// </summary> - public class ComboResultCounter : RollingCounter<long> - { - protected override double RollingDuration => 500; - protected override Easing RollingEasing => Easing.Out; - - protected override double GetProportionalDuration(long currentValue, long newValue) - { - return currentValue > newValue ? currentValue - newValue : newValue - currentValue; - } - - protected override string FormatCount(long count) - { - return $@"{count}x"; - } - - public override void Increment(long amount) - { - Current.Value = Current.Value + amount; - } - } -} diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs new file mode 100644 index 0000000000..d5d8ec570a --- /dev/null +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs @@ -0,0 +1,43 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public class DefaultAccuracyCounter : PercentageCounter, IAccuracyCounter + { + private readonly Vector2 offset = new Vector2(-20, 5); + + public DefaultAccuracyCounter() + { + Origin = Anchor.TopRight; + Anchor = Anchor.TopRight; + } + + [Resolved(canBeNull: true)] + private HUDOverlay hud { get; set; } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + 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 new file mode 100644 index 0000000000..63e7a88550 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -0,0 +1,53 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public class DefaultComboCounter : RollingCounter<int>, IComboCounter + { + private readonly Vector2 offset = new Vector2(20, 5); + + [Resolved(canBeNull: true)] + private HUDOverlay hud { get; set; } + + public DefaultComboCounter() + { + Current.Value = DisplayedCount = 0; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) => 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. + Position = Parent.ToLocalSpace(score.ScreenSpaceDrawQuad.TopRight) + offset; + } + } + + protected override string FormatCount(int count) + { + return $@"{count}x"; + } + + protected override double GetProportionalDuration(int currentValue, int newValue) + { + return Math.Abs(currentValue - newValue) * RollingDuration * 100.0f; + } + + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(size: 20f)); + } +} diff --git a/osu.Game/Screens/Play/HUD/StandardHealthDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs similarity index 83% rename from osu.Game/Screens/Play/HUD/StandardHealthDisplay.cs rename to osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs index 315bc27a79..b550b469e9 100644 --- a/osu.Game/Screens/Play/HUD/StandardHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs @@ -12,11 +12,11 @@ using osu.Game.Rulesets.Judgements; using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Game.Rulesets.Scoring; +using osu.Framework.Utils; namespace osu.Game.Screens.Play.HUD { - public class StandardHealthDisplay : HealthDisplay, IHasAccentColour + public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour { /// <summary> /// The base opacity of the glow. @@ -71,8 +71,12 @@ namespace osu.Game.Screens.Play.HUD } } - public StandardHealthDisplay() + public DefaultHealthDisplay() { + Size = new Vector2(1, 5); + RelativeSizeAxes = Axes.X; + Margin = new MarginPadding { Top = 20 }; + Children = new Drawable[] { new Box @@ -103,16 +107,22 @@ namespace osu.Game.Screens.Play.HUD GlowColour = colours.BlueDarker; } - public void Flash(JudgementResult result) - { - if (result.Type == HitResult.Miss) - return; + public override void Flash(JudgementResult result) => Scheduler.AddOnce(flash); + private void flash() + { fill.FadeEdgeEffectTo(Math.Min(1, fill.EdgeEffect.Colour.Linear.A + (1f - base_glow_opacity) / glow_max_hits), 50, Easing.OutQuint) .Delay(glow_fade_delay) .FadeEdgeEffectTo(base_glow_opacity, glow_fade_time, Easing.OutQuint); } - protected override void SetHealth(float value) => fill.ResizeTo(new Vector2(value, 1), 200, Easing.OutQuint); + protected override void Update() + { + base.Update(); + + fill.Width = Interpolation.ValueAt( + Math.Clamp(Clock.ElapsedFrameTime, 0, 200), + fill.Width, (float)Current.Value, 0, 200, Easing.OutQuint); + } } } diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs new file mode 100644 index 0000000000..1dcfe2e067 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -0,0 +1,35 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Play.HUD +{ + public class DefaultScoreCounter : ScoreCounter + { + public DefaultScoreCounter() + : base(6) + { + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + } + + [Resolved(canBeNull: true)] + private HUDOverlay hud { get; set; } + + [BackgroundDependencyLoader] + 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 new file mode 100644 index 0000000000..847b8a53cf --- /dev/null +++ b/osu.Game/Screens/Play/HUD/FailingLayer.cs @@ -0,0 +1,115 @@ +// 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. + +using System; +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.Utils; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Rulesets.Scoring; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + /// <summary> + /// An overlay layer on top of the playfield which fades to red when the current player health falls below a certain threshold defined by <see cref="low_health_threshold"/>. + /// </summary> + public class FailingLayer : HealthDisplay + { + /// <summary> + /// Whether the current player health should be shown on screen. + /// </summary> + public readonly Bindable<bool> ShowHealth = new Bindable<bool>(); + + private const float max_alpha = 0.4f; + private const int fade_time = 400; + private const float gradient_size = 0.2f; + + /// <summary> + /// The threshold under which the current player life should be considered low and the layer should start fading in. + /// </summary> + private const double low_health_threshold = 0.20f; + + private readonly Container boxes; + + private Bindable<bool> fadePlayfieldWhenHealthLow; + private HealthProcessor healthProcessor; + + public FailingLayer() + { + RelativeSizeAxes = Axes.Both; + Children = new Drawable[] + { + boxes = new Container + { + Alpha = 0, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(Color4.White, Color4.White.Opacity(0)), + Width = gradient_size, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Width = gradient_size, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0), Color4.White), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + } + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour color, OsuConfigManager config) + { + boxes.Colour = color.Red; + + fadePlayfieldWhenHealthLow = config.GetBindable<bool>(OsuSetting.FadePlayfieldWhenHealthLow); + fadePlayfieldWhenHealthLow.BindValueChanged(_ => updateState()); + ShowHealth.BindValueChanged(_ => updateState()); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + 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; + this.FadeTo(showLayer ? 1 : 0, fade_time, Easing.OutQuint); + } + + protected override void Update() + { + double target = Math.Clamp(max_alpha * (1 - Current.Value / low_health_threshold), 0, max_alpha); + + boxes.Alpha = (float)Interpolation.Lerp(boxes.Alpha, target, Clock.ElapsedFrameTime * 0.01f); + + base.Update(); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index acd8656fb2..5c43e00192 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -3,22 +3,34 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; namespace osu.Game.Screens.Play.HUD { - public abstract class HealthDisplay : Container + /// <summary> + /// A container for components displaying the current player health. + /// Gets bound automatically to the <see cref="HealthProcessor"/> when inserted to <see cref="DrawableRuleset.Overlays"/> hierarchy. + /// </summary> + public abstract class HealthDisplay : Container, IHealthDisplay { - public readonly BindableDouble Current = new BindableDouble + public Bindable<double> Current { get; } = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; - protected HealthDisplay() + public virtual void Flash(JudgementResult result) { - Current.ValueChanged += health => SetHealth((float)health.NewValue); } - protected abstract void SetHealth(float value); + /// <summary> + /// Bind the tracked fields of <see cref="HealthProcessor"/> to this health display. + /// </summary> + public virtual void BindHealthProcessor(HealthProcessor processor) + { + Current.BindTo(processor.Health); + } } } diff --git a/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs b/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs new file mode 100644 index 0000000000..37d10a5320 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs @@ -0,0 +1,151 @@ +// 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. + +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<HitErrorMeter> + { + private const int fade_duration = 200; + private const int margin = 10; + + private readonly Bindable<ScoreMeterType> type = new Bindable<ScoreMeterType>(); + + 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<ScoreMeterType> 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 new file mode 100644 index 0000000000..89f135de7f --- /dev/null +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -0,0 +1,283 @@ +// 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. + +using System; +using System.Linq; +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.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +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; + + private const int bar_height = 200; + + private const int bar_width = 2; + + private const int spacing = 2; + + private const float chevron_size = 8; + + private SpriteIcon arrow; + + private Container colourBarsEarly; + private Container colourBarsLate; + + private Container judgementsContainer; + + private double maxHitWindow; + + public BarHitErrorMeter(HitWindows hitWindows, bool rightAligned = false) + : base(hitWindows) + { + alignment = rightAligned ? Anchor.x0 : Anchor.x2; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.X, + Height = bar_height, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(spacing, 0), + Margin = new MarginPadding(2), + Children = new Drawable[] + { + judgementsContainer = new Container + { + Anchor = Anchor.y1 | alignment, + Origin = Anchor.y1 | alignment, + Width = judgement_line_width, + RelativeSizeAxes = Axes.Y, + }, + colourBars = new Container + { + Width = bar_width, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.y1 | alignment, + Origin = Anchor.y1 | alignment, + Children = new Drawable[] + { + colourBarsEarly = new Container + { + Anchor = Anchor.y1 | alignment, + Origin = alignment, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + Scale = new Vector2(1, -1), + }, + colourBarsLate = new Container + { + Anchor = Anchor.y1 | alignment, + Origin = alignment, + RelativeSizeAxes = Axes.Both, + Height = 0.5f, + }, + new SpriteIcon + { + Y = -10, + Size = new Vector2(10), + Icon = FontAwesome.Solid.ShippingFast, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + // undo any layout rotation to display the icon the correct orientation + Rotation = -Rotation, + }, + new SpriteIcon + { + Y = 10, + Size = new Vector2(10), + Icon = FontAwesome.Solid.Bicycle, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + // undo any layout rotation to display the icon the correct orientation + Rotation = -Rotation, + } + } + }, + new Container + { + Anchor = Anchor.y1 | alignment, + Origin = Anchor.y1 | alignment, + Width = chevron_size, + 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), + } + }, + } + }; + + createColourBars(colours); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + colourBars.Height = 0; + colourBars.ResizeHeightTo(1, 800, Easing.OutQuint); + + arrow.Alpha = 0; + arrow.Delay(200).FadeInFromZero(600); + } + + private void createColourBars(OsuColour colours) + { + var windows = HitWindows.GetAllAvailableWindows().ToArray(); + + maxHitWindow = 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)); + } + + // 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.Width = 2.5f; + colourBars.Add(centre); + + Drawable createColourBar(HitResult result, float height, bool first = false) + { + var colour = GetColourForHitResult(result); + + if (first) + { + // the first bar needs gradient rendering. + const float gradient_start = 0.8f; + + return new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colour, + Height = height * gradient_start + }, + new Box + { + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(colour, colour.Opacity(0)), + Y = gradient_start, + Height = height * (1 - gradient_start) + }, + } + }; + } + + return new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colour, + Height = height + }; + } + } + + private double floatingAverage; + private Container colourBars; + + private const int max_concurrent_judgements = 50; + + public override void OnNewJudgement(JudgementResult judgement) + { + if (!judgement.IsHit) + return; + + if (judgementsContainer.Count > max_concurrent_judgements) + { + const double quick_fade_time = 100; + + // check with a bit of lenience to avoid precision error in comparison. + var old = judgementsContainer.FirstOrDefault(j => j.LifetimeEnd > Clock.CurrentTime + quick_fade_time * 1.1); + + if (old != null) + { + old.ClearTransforms(); + old.FadeOut(quick_fade_time).Expire(); + } + } + + 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), + }); + + arrow.MoveToY( + getRelativeJudgementPosition(floatingAverage = floatingAverage * 0.9 + judgement.TimeOffset * 0.1) + , arrow_move_duration, Easing.Out); + } + + private float getRelativeJudgementPosition(double value) => Math.Clamp((float)((value / maxHitWindow) + 1) / 2, 0, 1); + + private class JudgementLine : CompositeDrawable + { + private const int judgement_fade_duration = 5000; + + public JudgementLine() + { + RelativeSizeAxes = Axes.X; + RelativePositionAxes = Axes.Y; + Height = 3; + + InternalChild = new CircularContainer + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Width = 0; + + this.ResizeWidthTo(1, 200, Easing.OutElasticHalf); + this.FadeTo(0.8f, 150).Then().FadeOut(judgement_fade_duration).Expire(); + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs new file mode 100644 index 0000000000..657235bfd4 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs @@ -0,0 +1,92 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +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; + +namespace osu.Game.Screens.Play.HUD.HitErrorMeters +{ + public class ColourHitErrorMeter : HitErrorMeter + { + private const int animation_duration = 200; + + private readonly JudgementFlow judgementsFlow; + + public ColourHitErrorMeter(HitWindows hitWindows) + : base(hitWindows) + { + AutoSizeAxes = Axes.Both; + InternalChild = judgementsFlow = new JudgementFlow(); + } + + public override void OnNewJudgement(JudgementResult judgement) => judgementsFlow.Push(GetColourForHitResult(HitWindows.ResultFor(judgement.TimeOffset))); + + private class JudgementFlow : FillFlowContainer<HitErrorCircle> + { + private const int max_available_judgements = 20; + private const int drawable_judgement_size = 8; + private const int spacing = 2; + + public override IEnumerable<Drawable> FlowingChildren => base.FlowingChildren.Reverse(); + + public JudgementFlow() + { + AutoSizeAxes = Axes.X; + Height = max_available_judgements * (drawable_judgement_size + spacing) - spacing; + Spacing = new Vector2(0, spacing); + Direction = FillDirection.Vertical; + LayoutDuration = animation_duration; + LayoutEasing = Easing.OutQuint; + } + + public void Push(Color4 colour) + { + Add(new HitErrorCircle(colour, drawable_judgement_size)); + + if (Children.Count > max_available_judgements) + Children.FirstOrDefault(c => !c.IsRemoved)?.Remove(); + } + } + + private class HitErrorCircle : Container + { + public bool IsRemoved { get; private set; } + + private readonly Circle circle; + + public HitErrorCircle(Color4 colour, int size) + { + Size = new Vector2(size); + Child = circle = new Circle + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = colour + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + circle.FadeInFromZero(animation_duration, Easing.OutQuint); + circle.MoveToY(-DrawSize.Y); + circle.MoveToY(0, animation_duration, Easing.OutQuint); + } + + public void Remove() + { + IsRemoved = true; + + this.FadeOut(animation_duration, Easing.OutQuint).Expire(); + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs new file mode 100644 index 0000000000..b3edfdedec --- /dev/null +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs @@ -0,0 +1,51 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play.HUD.HitErrorMeters +{ + public abstract class HitErrorMeter : CompositeDrawable + { + protected readonly HitWindows HitWindows; + + [Resolved] + private OsuColour colours { get; set; } + + protected HitErrorMeter(HitWindows hitWindows) + { + HitWindows = hitWindows; + } + + public abstract void OnNewJudgement(JudgementResult judgement); + + protected Color4 GetColourForHitResult(HitResult result) + { + switch (result) + { + case HitResult.Miss: + return colours.Red; + + case HitResult.Meh: + return colours.Yellow; + + case HitResult.Ok: + return colours.Green; + + case HitResult.Good: + return colours.GreenLight; + + case HitResult.Great: + return colours.Blue; + + default: + return colours.BlueLight; + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 91c14591b1..387c0e587b 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -12,7 +12,8 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; +using osu.Framework.Utils; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -45,7 +46,6 @@ namespace osu.Game.Screens.Play.HUD { text = new OsuSpriteText { - Text = "hold for menu", Font = OsuFont.GetFont(weight: FontWeight.Bold), Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft @@ -60,9 +60,23 @@ namespace osu.Game.Screens.Play.HUD AutoSizeAxes = Axes.Both; } + [Resolved] + private OsuConfigManager config { get; set; } + + private Bindable<float> activationDelay; + protected override void LoadComplete() { + activationDelay = config.GetBindable<float>(OsuSetting.UIHoldActivationDelay); + activationDelay.BindValueChanged(v => + { + text.Text = v.NewValue > 0 + ? "hold for menu" + : "press for menu"; + }, true); + text.FadeInFromZero(500, Easing.OutQuint).Delay(1500).FadeOut(500, Easing.OutQuint); + base.LoadComplete(); } @@ -86,9 +100,11 @@ namespace osu.Game.Screens.Play.HUD if (text.Alpha > 0 || button.Progress.Value > 0 || button.IsHovered) Alpha = 1; else + { Alpha = Interpolation.ValueAt( - MathHelper.Clamp(Clock.ElapsedFrameTime, 0, 200), - Alpha, MathHelper.Clamp(1 - positionalAdjust, 0.04f, 1), 0, 200, Easing.OutQuint); + Math.Clamp(Clock.ElapsedFrameTime, 0, 200), + Alpha, Math.Clamp(1 - positionalAdjust, 0.04f, 1), 0, 200, Easing.OutQuint); + } } private class Button : HoldToConfirmContainer, IKeyBindingHandler<GlobalAction> @@ -235,6 +251,7 @@ namespace osu.Game.Screens.Play.HUD switch (action) { case GlobalAction.Back: + case GlobalAction.PauseGameplay: // in the future this behaviour will differ for replays etc. if (!pendingAnimation) BeginConfirm(); return true; @@ -243,16 +260,15 @@ namespace osu.Game.Screens.Play.HUD return false; } - public bool OnReleased(GlobalAction action) + public void OnReleased(GlobalAction action) { switch (action) { case GlobalAction.Back: + case GlobalAction.PauseGameplay: AbortConfirm(); - return true; + break; } - - return false; } protected override bool OnMouseDown(MouseDownEvent e) @@ -262,11 +278,10 @@ namespace osu.Game.Screens.Play.HUD return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { if (!e.HasAnyButtonPressed) AbortConfirm(); - return true; } } } diff --git a/osu.Game/Screens/Play/HUD/IAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/IAccuracyCounter.cs new file mode 100644 index 0000000000..0199250a08 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/IAccuracyCounter.cs @@ -0,0 +1,19 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + /// <summary> + /// An interface providing a set of methods to update a accuracy counter. + /// </summary> + public interface IAccuracyCounter : IDrawable + { + /// <summary> + /// The current accuracy to be displayed. + /// </summary> + Bindable<double> Current { get; } + } +} diff --git a/osu.Game/Screens/Play/HUD/IComboCounter.cs b/osu.Game/Screens/Play/HUD/IComboCounter.cs new file mode 100644 index 0000000000..ff235bf04e --- /dev/null +++ b/osu.Game/Screens/Play/HUD/IComboCounter.cs @@ -0,0 +1,19 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + /// <summary> + /// An interface providing a set of methods to update a combo counter. + /// </summary> + public interface IComboCounter : IDrawable + { + /// <summary> + /// The current combo to be displayed. + /// </summary> + Bindable<int> Current { get; } + } +} diff --git a/osu.Game/Screens/Play/HUD/IHealthDisplay.cs b/osu.Game/Screens/Play/HUD/IHealthDisplay.cs new file mode 100644 index 0000000000..b1a64bd844 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/IHealthDisplay.cs @@ -0,0 +1,26 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Judgements; + +namespace osu.Game.Screens.Play.HUD +{ + /// <summary> + /// An interface providing a set of methods to update a health display. + /// </summary> + public interface IHealthDisplay : IDrawable + { + /// <summary> + /// The current health to be displayed. + /// </summary> + Bindable<double> Current { get; } + + /// <summary> + /// Flash the display for a specified result type. + /// </summary> + /// <param name="result">The result type.</param> + void Flash(JudgementResult result); + } +} diff --git a/osu.Game/Screens/Play/HUD/IScoreCounter.cs b/osu.Game/Screens/Play/HUD/IScoreCounter.cs new file mode 100644 index 0000000000..7f5e81d5ef --- /dev/null +++ b/osu.Game/Screens/Play/HUD/IScoreCounter.cs @@ -0,0 +1,25 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + /// <summary> + /// An interface providing a set of methods to update a score counter. + /// </summary> + public interface IScoreCounter : IDrawable + { + /// <summary> + /// The current score to be displayed. + /// </summary> + Bindable<double> Current { get; } + + /// <summary> + /// 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. + /// </summary> + Bindable<int> RequiredDisplayDigits { get; } + } +} diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs new file mode 100644 index 0000000000..4784bca7dd --- /dev/null +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -0,0 +1,252 @@ +// 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. + +using osu.Framework.Allocation; +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.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + /// <summary> + /// Uses the 'x' symbol and has a pop-out effect while rolling over. + /// </summary> + public class LegacyComboCounter : CompositeDrawable, IComboCounter + { + public Bindable<int> Current { get; } = new BindableInt { MinValue = 0, }; + + private uint scheduledPopOutCurrentId; + + private const double pop_out_duration = 150; + + private const Easing pop_out_easing = Easing.None; + + private const double fade_out_duration = 100; + + /// <summary> + /// Duration in milliseconds for the counter roll-up animation for each element. + /// </summary> + private const double rolling_duration = 20; + + private Drawable popOutCount; + + private Drawable displayedCountSpriteText; + + private int previousValue; + + private int displayedCount; + + private bool isRolling; + + [Resolved] + private ISkinSource skin { get; set; } + + public LegacyComboCounter() + { + AutoSizeAxes = Axes.Both; + + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + + Margin = new MarginPadding(10); + + Scale = new Vector2(1.2f); + } + + /// <summary> + /// Value shown at the current moment. + /// </summary> + public virtual int DisplayedCount + { + get => displayedCount; + private set + { + if (displayedCount.Equals(value)) + return; + + if (isRolling) + onDisplayedCountRolling(displayedCount, value); + else if (displayedCount + 1 == value) + onDisplayedCountIncrement(value); + else + onDisplayedCountChange(value); + + displayedCount = value; + } + } + + [BackgroundDependencyLoader] + private void load() + { + 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); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ((IHasText)displayedCountSpriteText).Text = formatCount(Current.Value); + + displayedCountSpriteText.Anchor = Anchor; + displayedCountSpriteText.Origin = Origin; + popOutCount.Origin = Origin; + popOutCount.Anchor = Anchor; + + updateCount(false); + } + + private void updateCount(bool rolling) + { + int prev = previousValue; + previousValue = Current.Value; + + if (!IsLoaded) + return; + + if (!rolling) + { + FinishTransforms(false, nameof(DisplayedCount)); + isRolling = false; + DisplayedCount = prev; + + if (prev + 1 == Current.Value) + onCountIncrement(prev, Current.Value); + else + onCountChange(prev, Current.Value); + } + else + { + onCountRolling(displayedCount, Current.Value); + isRolling = true; + } + } + + private void transformPopOut(int newValue) + { + ((IHasText)popOutCount).Text = formatCount(newValue); + + popOutCount.ScaleTo(1.6f); + popOutCount.FadeTo(0.75f); + popOutCount.MoveTo(Vector2.Zero); + + popOutCount.ScaleTo(1, pop_out_duration, pop_out_easing); + popOutCount.FadeOut(pop_out_duration, pop_out_easing); + popOutCount.MoveTo(displayedCountSpriteText.Position, pop_out_duration, pop_out_easing); + } + + private void transformNoPopOut(int newValue) + { + ((IHasText)displayedCountSpriteText).Text = formatCount(newValue); + + displayedCountSpriteText.ScaleTo(1); + } + + private void transformPopOutSmall(int newValue) + { + ((IHasText)displayedCountSpriteText).Text = formatCount(newValue); + displayedCountSpriteText.ScaleTo(1.1f); + displayedCountSpriteText.ScaleTo(1, pop_out_duration, pop_out_easing); + } + + private void scheduledPopOutSmall(uint id) + { + // Too late; scheduled task invalidated + if (id != scheduledPopOutCurrentId) + return; + + DisplayedCount++; + } + + private void onCountIncrement(int currentValue, int newValue) + { + scheduledPopOutCurrentId++; + + if (DisplayedCount < currentValue) + DisplayedCount++; + + displayedCountSpriteText.Show(); + + transformPopOut(newValue); + + uint newTaskId = scheduledPopOutCurrentId; + + Scheduler.AddDelayed(delegate + { + scheduledPopOutSmall(newTaskId); + }, pop_out_duration); + } + + private void onCountRolling(int currentValue, int newValue) + { + scheduledPopOutCurrentId++; + + // Hides displayed count if was increasing from 0 to 1 but didn't finish + if (currentValue == 0 && newValue == 0) + displayedCountSpriteText.FadeOut(fade_out_duration); + + transformRoll(currentValue, newValue); + } + + private void onCountChange(int currentValue, int newValue) + { + scheduledPopOutCurrentId++; + + if (newValue == 0) + displayedCountSpriteText.FadeOut(); + + DisplayedCount = newValue; + } + + private void onDisplayedCountRolling(int currentValue, int newValue) + { + if (newValue == 0) + displayedCountSpriteText.FadeOut(fade_out_duration); + else + displayedCountSpriteText.Show(); + + transformNoPopOut(newValue); + } + + private void onDisplayedCountChange(int newValue) + { + displayedCountSpriteText.FadeTo(newValue == 0 ? 0 : 1); + transformNoPopOut(newValue); + } + + private void onDisplayedCountIncrement(int newValue) + { + displayedCountSpriteText.Show(); + transformPopOutSmall(newValue); + } + + private void transformRoll(int currentValue, int newValue) => + this.TransformTo(nameof(DisplayedCount), newValue, getProportionalDuration(currentValue, newValue), Easing.None); + + private string formatCount(int count) => $@"{count}x"; + + private double getProportionalDuration(int currentValue, int newValue) + { + 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/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index 0f3c92a962..68d019bf71 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -24,7 +24,7 @@ namespace osu.Game.Screens.Play.HUD public bool DisplayUnrankedText = true; - public bool AllowExpand = true; + public ExpansionMode ExpansionMode = ExpansionMode.ExpandOnHover; private readonly Bindable<IReadOnlyList<Mod>> current = new Bindable<IReadOnlyList<Mod>>(); @@ -48,23 +48,29 @@ namespace osu.Game.Screens.Play.HUD { AutoSizeAxes = Axes.Both; - Children = new Drawable[] + Child = new FillFlowContainer { - iconsContainer = new ReverseChildIDFillFlowContainer<ModIcon> + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Margin = new MarginPadding { Left = 10, Right = 10 }, + iconsContainer = new ReverseChildIDFillFlowContainer<ModIcon> + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }, + unrankedText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = @"/ UNRANKED /", + Font = OsuFont.Numeric.With(size: 12) + } }, - unrankedText = new OsuSpriteText - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.TopCentre, - Text = @"/ UNRANKED /", - Font = OsuFont.Numeric.With(size: 12) - } }; Current.ValueChanged += mods => @@ -110,11 +116,15 @@ namespace osu.Game.Screens.Play.HUD private void expand() { - if (AllowExpand) + if (ExpansionMode != ExpansionMode.AlwaysContracted) iconsContainer.TransformSpacingTo(new Vector2(5, 0), 500, Easing.OutQuint); } - private void contract() => iconsContainer.TransformSpacingTo(new Vector2(-25, 0), 500, Easing.OutQuint); + private void contract() + { + if (ExpansionMode != ExpansionMode.AlwaysExpanded) + iconsContainer.TransformSpacingTo(new Vector2(-25, 0), 500, Easing.OutQuint); + } protected override bool OnHover(HoverEvent e) { @@ -128,4 +138,22 @@ namespace osu.Game.Screens.Play.HUD base.OnHoverLost(e); } } + + public enum ExpansionMode + { + /// <summary> + /// The <see cref="ModDisplay"/> will expand only when hovered. + /// </summary> + ExpandOnHover, + + /// <summary> + /// The <see cref="ModDisplay"/> will always be expanded. + /// </summary> + AlwaysExpanded, + + /// <summary> + /// The <see cref="ModDisplay"/> will always be contracted. + /// </summary> + AlwaysContracted + } } diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index b2c3952f38..ffcbb06fb3 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -20,14 +20,13 @@ namespace osu.Game.Screens.Play.HUD public readonly VisualSettings VisualSettings; - //public readonly CollectionSettings CollectionSettings; - - //public readonly DiscussionSettings DiscussionSettings; - public PlayerSettingsOverlay() { AlwaysPresent = true; - RelativeSizeAxes = Axes.Both; + + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + AutoSizeAxes = Axes.Both; Child = new FillFlowContainer<PlayerSettingsGroup> { @@ -36,7 +35,6 @@ namespace osu.Game.Screens.Play.HUD AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 20), - Margin = new MarginPadding { Top = 100, Right = 10 }, Children = new PlayerSettingsGroup[] { //CollectionSettings = new CollectionSettings(), @@ -45,14 +43,12 @@ namespace osu.Game.Screens.Play.HUD VisualSettings = new VisualSettings { Expanded = false } } }; - - Show(); } protected override void PopIn() => this.FadeIn(fade_duration); protected override void PopOut() => this.FadeOut(fade_duration); - //We want to handle keyboard inputs all the time in order to trigger ToggleVisibility() when not visible + // We want to handle keyboard inputs all the time in order to trigger ToggleVisibility() when not visible public override bool PropagateNonPositionalInputSubTree => true; protected override bool OnKeyDown(KeyDownEvent e) diff --git a/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs new file mode 100644 index 0000000000..76c9c30813 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs @@ -0,0 +1,29 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public class SkinnableAccuracyCounter : SkinnableDrawable, IAccuracyCounter + { + public Bindable<double> Current { get; } = new Bindable<double>(); + + 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 new file mode 100644 index 0000000000..c04c50141a --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SkinnableComboCounter.cs @@ -0,0 +1,29 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public class SkinnableComboCounter : SkinnableDrawable, IComboCounter + { + public Bindable<int> Current { get; } = new Bindable<int>(); + + 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/SkinnableScoreCounter.cs b/osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs new file mode 100644 index 0000000000..b46f5684b1 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs @@ -0,0 +1,61 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Configuration; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public class SkinnableScoreCounter : SkinnableDrawable, IScoreCounter + { + public Bindable<double> Current { get; } = new Bindable<double>(); + + private Bindable<ScoringMode> scoreDisplayMode; + + public Bindable<int> RequiredDisplayDigits { get; } = new Bindable<int>(); + + public SkinnableScoreCounter() + : base(new HUDSkinComponent(HUDSkinComponents.ScoreCounter), _ => new DefaultScoreCounter()) + { + CentreComponent = false; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + scoreDisplayMode = config.GetBindable<ScoringMode>(OsuSetting.ScoreDisplayMode); + scoreDisplayMode.BindValueChanged(scoreMode => + { + switch (scoreMode.NewValue) + { + case ScoringMode.Standardised: + RequiredDisplayDigits.Value = 6; + break; + + case ScoringMode.Classic: + RequiredDisplayDigits.Value = 8; + break; + + default: + 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); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/StandardComboCounter.cs b/osu.Game/Screens/Play/HUD/StandardComboCounter.cs deleted file mode 100644 index 7301300b8d..0000000000 --- a/osu.Game/Screens/Play/HUD/StandardComboCounter.cs +++ /dev/null @@ -1,141 +0,0 @@ -// 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. - -using osuTK; -using osu.Framework.Graphics; - -namespace osu.Game.Screens.Play.HUD -{ - /// <summary> - /// Uses the 'x' symbol and has a pop-out effect while rolling over. - /// </summary> - public class StandardComboCounter : ComboCounter - { - protected uint ScheduledPopOutCurrentId; - - protected virtual float PopOutSmallScale => 1.1f; - protected virtual bool CanPopOutWhileRolling => false; - - public new Vector2 PopOutScale = new Vector2(1.6f); - - protected override void LoadComplete() - { - base.LoadComplete(); - - PopOutCount.Origin = Origin; - PopOutCount.Anchor = Anchor; - } - - protected override string FormatCount(int count) - { - return $@"{count}x"; - } - - protected virtual void TransformPopOut(int newValue) - { - PopOutCount.Text = FormatCount(newValue); - - PopOutCount.ScaleTo(PopOutScale); - PopOutCount.FadeTo(PopOutInitialAlpha); - PopOutCount.MoveTo(Vector2.Zero); - - PopOutCount.ScaleTo(1, PopOutDuration, PopOutEasing); - PopOutCount.FadeOut(PopOutDuration, PopOutEasing); - PopOutCount.MoveTo(DisplayedCountSpriteText.Position, PopOutDuration, PopOutEasing); - } - - protected virtual void TransformPopOutRolling(int newValue) - { - TransformPopOut(newValue); - TransformPopOutSmall(newValue); - } - - protected virtual void TransformNoPopOut(int newValue) - { - DisplayedCountSpriteText.Text = FormatCount(newValue); - DisplayedCountSpriteText.ScaleTo(1); - } - - protected virtual void TransformPopOutSmall(int newValue) - { - DisplayedCountSpriteText.Text = FormatCount(newValue); - DisplayedCountSpriteText.ScaleTo(PopOutSmallScale); - DisplayedCountSpriteText.ScaleTo(1, PopOutDuration, PopOutEasing); - } - - protected virtual void ScheduledPopOutSmall(uint id) - { - // Too late; scheduled task invalidated - if (id != ScheduledPopOutCurrentId) - return; - - DisplayedCount++; - } - - protected override void OnCountRolling(int currentValue, int newValue) - { - ScheduledPopOutCurrentId++; - - // Hides displayed count if was increasing from 0 to 1 but didn't finish - if (currentValue == 0 && newValue == 0) - DisplayedCountSpriteText.FadeOut(FadeOutDuration); - - base.OnCountRolling(currentValue, newValue); - } - - protected override void OnCountIncrement(int currentValue, int newValue) - { - ScheduledPopOutCurrentId++; - - if (DisplayedCount < currentValue) - DisplayedCount++; - - DisplayedCountSpriteText.Show(); - - TransformPopOut(newValue); - - uint newTaskId = ScheduledPopOutCurrentId; - Scheduler.AddDelayed(delegate - { - ScheduledPopOutSmall(newTaskId); - }, PopOutDuration); - } - - protected override void OnCountChange(int currentValue, int newValue) - { - ScheduledPopOutCurrentId++; - - if (newValue == 0) - DisplayedCountSpriteText.FadeOut(); - - base.OnCountChange(currentValue, newValue); - } - - protected override void OnDisplayedCountRolling(int currentValue, int newValue) - { - if (newValue == 0) - DisplayedCountSpriteText.FadeOut(FadeOutDuration); - else - DisplayedCountSpriteText.Show(); - - if (CanPopOutWhileRolling) - TransformPopOutRolling(newValue); - else - TransformNoPopOut(newValue); - } - - protected override void OnDisplayedCountChange(int newValue) - { - DisplayedCountSpriteText.FadeTo(newValue == 0 ? 0 : 1); - - TransformNoPopOut(newValue); - } - - protected override void OnDisplayedCountIncrement(int newValue) - { - DisplayedCountSpriteText.Show(); - - TransformPopOutSmall(newValue); - } - } -} diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 43b9491750..e83dded075 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -5,11 +5,13 @@ using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Mods; @@ -21,40 +23,60 @@ using osuTK.Input; namespace osu.Game.Screens.Play { - public class HUDOverlay : Container + [Cached] + public class HUDOverlay : Container, IKeyBindingHandler<GlobalAction> { - private const int duration = 250; - private const Easing easing = Easing.OutQuint; + public const float FADE_DURATION = 400; + + public const Easing FADE_EASING = Easing.Out; public readonly KeyCounterDisplay KeyCounter; - public readonly RollingCounter<int> ComboCounter; - public readonly ScoreCounter ScoreCounter; - public readonly RollingCounter<double> AccuracyCounter; - public readonly HealthDisplay HealthDisplay; + 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<bool> ShowHealthbar = new Bindable<bool>(true); private readonly ScoreProcessor scoreProcessor; + private readonly HealthProcessor healthProcessor; private readonly DrawableRuleset drawableRuleset; private readonly IReadOnlyList<Mod> mods; - private Bindable<bool> showHud; + /// <summary> + /// Whether the elements that can optionally be hidden should be visible. + /// </summary> + public Bindable<bool> ShowHud { get; } = new BindableBool(); + + private Bindable<HUDVisibilityMode> configVisibilityMode; + private readonly Container visibilityContainer; + private readonly BindableBool replayLoaded = new BindableBool(); private static bool hasShownNotificationOnce; public Action<double> RequestSeek; - private readonly Container topScoreContainer; + private readonly FillFlowContainer bottomRightElements; + private readonly FillFlowContainer topRightElements; - public HUDOverlay(ScoreProcessor scoreProcessor, DrawableRuleset drawableRuleset, IReadOnlyList<Mod> mods) + internal readonly IBindable<bool> IsBreakTime = new Bindable<bool>(); + + private bool holdingForHUD; + + private IEnumerable<Drawable> hideTargets => new Drawable[] { visibilityContainer, KeyCounter }; + + public HUDOverlay(ScoreProcessor scoreProcessor, HealthProcessor healthProcessor, DrawableRuleset drawableRuleset, IReadOnlyList<Mod> mods) { this.scoreProcessor = scoreProcessor; + this.healthProcessor = healthProcessor; this.drawableRuleset = drawableRuleset; this.mods = mods; @@ -62,37 +84,65 @@ namespace osu.Game.Screens.Play Children = new Drawable[] { + FailingLayer = CreateFailingLayer(), visibilityContainer = new Container { RelativeSizeAxes = Axes.Both, + Child = new GridContainer + { + 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 + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding(10), + Spacing = new Vector2(10), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, Children = new Drawable[] { - topScoreContainer = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - AutoSizeDuration = 200, - AutoSizeEasing = Easing.Out, - Children = new Drawable[] - { - AccuracyCounter = CreateAccuracyCounter(), - ScoreCounter = CreateScoreCounter(), - ComboCounter = CreateComboCounter(), - }, - }, - HealthDisplay = CreateHealthDisplay(), - Progress = CreateProgress(), ModDisplay = CreateModsContainer(), + PlayerSettingsOverlay = CreatePlayerSettingsOverlay(), } }, - PlayerSettingsOverlay = CreatePlayerSettingsOverlay(), - new FillFlowContainer + bottomRightElements = new FillFlowContainer { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Position = -new Vector2(5, TwoLayerButton.SIZE_RETRACTED.Y), + Margin = new MarginPadding(10), + Spacing = new Vector2(10), AutoSizeAxes = Axes.Both, + LayoutDuration = FADE_DURATION / 2, + LayoutEasing = FADE_EASING, Direction = FillDirection.Vertical, Children = new Drawable[] { @@ -106,34 +156,26 @@ namespace osu.Game.Screens.Play [BackgroundDependencyLoader(true)] private void load(OsuConfigManager config, NotificationOverlay notificationOverlay) { - BindProcessor(scoreProcessor); - BindDrawableRuleset(drawableRuleset); + if (scoreProcessor != null) + BindScoreProcessor(scoreProcessor); - Progress.Objects = drawableRuleset.Objects; - Progress.AllowSeeking = drawableRuleset.HasReplayLoaded.Value; - Progress.RequestSeek = time => RequestSeek(time); - Progress.ReferenceClock = drawableRuleset.FrameStableClock; + 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; - showHud = config.GetBindable<bool>(OsuSetting.ShowInterface); - showHud.BindValueChanged(visible => visibilityContainer.FadeTo(visible.NewValue ? 1 : 0, duration, easing), true); + configVisibilityMode = config.GetBindable<HUDVisibilityMode>(OsuSetting.HUDVisibilityMode); - ShowHealthbar.BindValueChanged(healthBar => - { - if (healthBar.NewValue) - { - HealthDisplay.FadeIn(duration, easing); - topScoreContainer.MoveToY(30, duration, easing); - } - else - { - HealthDisplay.FadeOut(duration, easing); - topScoreContainer.MoveToY(0, duration, easing); - } - }, true); - - if (!showHud.Value && !hasShownNotificationOnce) + if (configVisibilityMode.Value == HUDVisibilityMode.Never && !hasShownNotificationOnce) { hasShownNotificationOnce = true; @@ -142,15 +184,66 @@ namespace osu.Game.Screens.Play Text = @"The score overlay is currently disabled. You can toggle this by pressing Shift+Tab." }); } + + // start all elements hidden + hideTargets.ForEach(d => d.Hide()); } + public override void Hide() => throw new InvalidOperationException($"{nameof(HUDOverlay)} should not be hidden as it will remove the ability of a user to quit. Use {nameof(ShowHud)} instead."); + protected override void LoadComplete() { 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()); + configVisibilityMode.BindValueChanged(_ => updateVisibility(), true); + replayLoaded.BindValueChanged(replayLoadedValueChanged, true); } + protected override void Update() + { + 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 = ToLocalSpace(AccuracyCounter.Drawable.ScreenSpaceDrawQuad.BottomRight).Y; + + bottomRightElements.Y = -Progress.Height; + } + + private void updateVisibility() + { + if (ShowHud.Disabled) + return; + + if (holdingForHUD) + { + ShowHud.Value = true; + return; + } + + switch (configVisibilityMode.Value) + { + case HUDVisibilityMode.Never: + ShowHud.Value = false; + break; + + case HUDVisibilityMode.HideDuringGameplay: + // always show during replay as we want the seek bar to be visible. + ShowHud.Value = replayLoaded.Value || IsBreakTime.Value; + break; + + case HUDVisibilityMode.Always: + ShowHud.Value = true; + break; + } + } + private void replayLoadedValueChanged(ValueChangedEvent<bool> e) { PlayerSettingsOverlay.ReplayLoaded = e.NewValue; @@ -167,6 +260,8 @@ namespace osu.Game.Screens.Play ModDisplay.Delay(2000).FadeOut(200); KeyCounter.Margin = new MarginPadding(10); } + + updateVisibility(); } protected virtual void BindDrawableRuleset(DrawableRuleset drawableRuleset) @@ -187,7 +282,21 @@ namespace osu.Game.Screens.Play switch (e.Key) { case Key.Tab: - showHud.Value = !showHud.Value; + switch (configVisibilityMode.Value) + { + case HUDVisibilityMode.Never: + configVisibilityMode.Value = HUDVisibilityMode.HideDuringGameplay; + break; + + case HUDVisibilityMode.HideDuringGameplay: + configVisibilityMode.Value = HUDVisibilityMode.Always; + break; + + case HUDVisibilityMode.Always: + configVisibilityMode.Value = HUDVisibilityMode.Never; + break; + } + return true; } } @@ -195,44 +304,23 @@ namespace osu.Game.Screens.Play return base.OnKeyDown(e); } - protected virtual RollingCounter<double> CreateAccuracyCounter() => new PercentageCounter - { - TextSize = 20, - BypassAutoSizeAxes = Axes.X, - Anchor = Anchor.TopLeft, - Origin = Anchor.TopRight, - Margin = new MarginPadding { Top = 5, Right = 20 }, - }; + protected virtual SkinnableAccuracyCounter CreateAccuracyCounter() => new SkinnableAccuracyCounter(); - protected virtual ScoreCounter CreateScoreCounter() => new ScoreCounter(6) - { - TextSize = 40, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }; + protected virtual SkinnableScoreCounter CreateScoreCounter() => new SkinnableScoreCounter(); - protected virtual RollingCounter<int> CreateComboCounter() => new SimpleComboCounter - { - TextSize = 20, - BypassAutoSizeAxes = Axes.X, - Anchor = Anchor.TopRight, - Origin = Anchor.TopLeft, - Margin = new MarginPadding { Top = 5, Left = 20 }, - }; + protected virtual SkinnableComboCounter CreateComboCounter() => new SkinnableComboCounter(); - protected virtual HealthDisplay CreateHealthDisplay() => new StandardHealthDisplay + protected virtual SkinnableHealthDisplay CreateHealthDisplay() => new SkinnableHealthDisplay(); + + protected virtual FailingLayer CreateFailingLayer() => new FailingLayer { - Size = new Vector2(1, 5), - RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Top = 20 } + ShowHealth = { BindTarget = ShowHealthbar } }; protected virtual KeyCounterDisplay CreateKeyCounter() => new KeyCounterDisplay { - FadeTime = 50, Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Margin = new MarginPadding(10), }; protected virtual SongProgress CreateProgress() => new SongProgress @@ -253,20 +341,56 @@ namespace osu.Game.Screens.Play Anchor = Anchor.TopRight, Origin = Anchor.TopRight, AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 20, Right = 10 }, }; + protected virtual HitErrorDisplay CreateHitErrorDisplayOverlay() => new HitErrorDisplay(scoreProcessor, drawableRuleset?.FirstAvailableHitWindows); + protected virtual PlayerSettingsOverlay CreatePlayerSettingsOverlay() => new PlayerSettingsOverlay(); - protected virtual void BindProcessor(ScoreProcessor processor) + protected virtual void BindScoreProcessor(ScoreProcessor processor) { ScoreCounter?.Current.BindTo(processor.TotalScore); AccuracyCounter?.Current.BindTo(processor.Accuracy); ComboCounter?.Current.BindTo(processor.Combo); - HealthDisplay?.Current.BindTo(processor.Health); - if (HealthDisplay is StandardHealthDisplay shd) - processor.NewJudgement += shd.Flash; + 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); + } + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.HoldForHUD: + holdingForHUD = true; + updateVisibility(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + switch (action) + { + case GlobalAction.HoldForHUD: + holdingForHUD = false; + updateVisibility(); + break; + } } } } diff --git a/osu.Game/Screens/Play/HotkeyExitOverlay.cs b/osu.Game/Screens/Play/HotkeyExitOverlay.cs index c18aecda55..8d7e2481bf 100644 --- a/osu.Game/Screens/Play/HotkeyExitOverlay.cs +++ b/osu.Game/Screens/Play/HotkeyExitOverlay.cs @@ -17,12 +17,11 @@ namespace osu.Game.Screens.Play return true; } - public bool OnReleased(GlobalAction action) + public void OnReleased(GlobalAction action) { - if (action != GlobalAction.QuickExit) return false; + if (action != GlobalAction.QuickExit) return; AbortConfirm(); - return true; } } } diff --git a/osu.Game/Screens/Play/HotkeyRetryOverlay.cs b/osu.Game/Screens/Play/HotkeyRetryOverlay.cs index f1b851f2d5..58fd941f36 100644 --- a/osu.Game/Screens/Play/HotkeyRetryOverlay.cs +++ b/osu.Game/Screens/Play/HotkeyRetryOverlay.cs @@ -17,12 +17,11 @@ namespace osu.Game.Screens.Play return true; } - public bool OnReleased(GlobalAction action) + public void OnReleased(GlobalAction action) { - if (action != GlobalAction.QuickRetry) return false; + if (action != GlobalAction.QuickRetry) return; AbortConfirm(); - return true; } } } diff --git a/osu.Game/Screens/Play/ISamplePlaybackDisabler.cs b/osu.Game/Screens/Play/ISamplePlaybackDisabler.cs new file mode 100644 index 0000000000..6b37021fe6 --- /dev/null +++ b/osu.Game/Screens/Play/ISamplePlaybackDisabler.cs @@ -0,0 +1,20 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play +{ + /// <summary> + /// Allows a component to disable sample playback dynamically as required. + /// Handled by <see cref="PausableSkinnableSound"/>. + /// </summary> + public interface ISamplePlaybackDisabler + { + /// <summary> + /// Whether sample playback should be disabled (or paused for looping samples). + /// </summary> + IBindable<bool> SamplePlaybackDisabled { get; } + } +} diff --git a/osu.Game/Screens/Play/KeyCounter.cs b/osu.Game/Screens/Play/KeyCounter.cs index 88a62ac8d4..98df73a5e6 100644 --- a/osu.Game/Screens/Play/KeyCounter.cs +++ b/osu.Game/Screens/Play/KeyCounter.cs @@ -1,8 +1,6 @@ // 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. -using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -22,9 +20,6 @@ namespace osu.Game.Screens.Play private Container textLayer; private SpriteText countSpriteText; - private readonly List<KeyCounterState> states = new List<KeyCounterState>(); - private KeyCounterState currentState; - public bool IsCounting { get; set; } = true; private int countPresses; @@ -52,20 +47,30 @@ namespace osu.Game.Screens.Play { isLit = value; updateGlowSprite(value); - - if (value && IsCounting) - { - CountPresses++; - saveState(); - } } } } + public void Increment() + { + if (!IsCounting) + return; + + CountPresses++; + } + + public void Decrement() + { + if (!IsCounting) + return; + + CountPresses--; + } + //further: change default values here and in KeyCounterCollection if needed, instead of passing them in every constructor public Color4 KeyDownTextColor { get; set; } = Color4.DarkGray; public Color4 KeyUpTextColor { get; set; } = Color4.White; - public int FadeTime { get; set; } + public double FadeTime { get; set; } protected KeyCounter(string name) { @@ -73,11 +78,8 @@ namespace osu.Game.Screens.Play } [BackgroundDependencyLoader(true)] - private void load(TextureStore textures, GameplayClock clock) + private void load(TextureStore textures) { - if (clock != null) - Clock = clock; - Children = new Drawable[] { buttonSprite = new Sprite @@ -122,8 +124,8 @@ namespace osu.Game.Screens.Play } } }; - //Set this manually because an element with Alpha=0 won't take it size to AutoSizeContainer, - //so the size can be changing between buttonSprite and glowSprite. + // Set this manually because an element with Alpha=0 won't take it size to AutoSizeContainer, + // so the size can be changing between buttonSprite and glowSprite. Height = buttonSprite.DrawHeight; Width = buttonSprite.DrawWidth; } @@ -132,42 +134,16 @@ namespace osu.Game.Screens.Play { if (show) { - glowSprite.FadeIn(FadeTime); - textLayer.FadeColour(KeyDownTextColor, FadeTime); + double remainingFadeTime = FadeTime * (1 - glowSprite.Alpha); + glowSprite.FadeIn(remainingFadeTime, Easing.OutQuint); + textLayer.FadeColour(KeyDownTextColor, remainingFadeTime, Easing.OutQuint); } else { - glowSprite.FadeOut(FadeTime); - textLayer.FadeColour(KeyUpTextColor, FadeTime); + double remainingFadeTime = 8 * FadeTime * glowSprite.Alpha; + glowSprite.FadeOut(remainingFadeTime, Easing.OutQuint); + textLayer.FadeColour(KeyUpTextColor, remainingFadeTime, Easing.OutQuint); } } - - public void ResetCount() - { - CountPresses = 0; - states.Clear(); - } - - protected override void Update() - { - base.Update(); - - if (currentState?.Time > Clock.CurrentTime) - restoreStateTo(Clock.CurrentTime); - } - - private void saveState() - { - if (currentState == null || currentState.Time < Clock.CurrentTime) - states.Add(currentState = new KeyCounterState(Clock.CurrentTime, CountPresses)); - } - - private void restoreStateTo(double time) - { - states.RemoveAll(state => state.Time > time); - - currentState = states.LastOrDefault(); - CountPresses = currentState?.Count ?? 0; - } } } diff --git a/osu.Game/Screens/Play/KeyCounterAction.cs b/osu.Game/Screens/Play/KeyCounterAction.cs index 8deac653ad..00eddcc776 100644 --- a/osu.Game/Screens/Play/KeyCounterAction.cs +++ b/osu.Game/Screens/Play/KeyCounterAction.cs @@ -1,11 +1,11 @@ // 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. -using osu.Framework.Input.Bindings; +using System.Collections.Generic; namespace osu.Game.Screens.Play { - public class KeyCounterAction<T> : KeyCounter, IKeyBindingHandler<T> + public class KeyCounterAction<T> : KeyCounter where T : struct { public T Action { get; } @@ -16,16 +16,25 @@ namespace osu.Game.Screens.Play Action = action; } - public bool OnPressed(T action) + public bool OnPressed(T action, bool forwards) { - if (action.Equals(Action)) IsLit = true; + if (!EqualityComparer<T>.Default.Equals(action, Action)) + return false; + + IsLit = true; + if (forwards) + Increment(); return false; } - public bool OnReleased(T action) + public void OnReleased(T action, bool forwards) { - if (action.Equals(Action)) IsLit = false; - return false; + if (!EqualityComparer<T>.Default.Equals(action, Action)) + return; + + IsLit = false; + if (!forwards) + Decrement(); } } } diff --git a/osu.Game/Screens/Play/KeyCounterDisplay.cs b/osu.Game/Screens/Play/KeyCounterDisplay.cs index d5967f5899..2ed4afafd3 100644 --- a/osu.Game/Screens/Play/KeyCounterDisplay.cs +++ b/osu.Game/Screens/Play/KeyCounterDisplay.cs @@ -14,17 +14,32 @@ using osuTK.Graphics; namespace osu.Game.Screens.Play { - public class KeyCounterDisplay : FillFlowContainer<KeyCounter> + public class KeyCounterDisplay : Container<KeyCounter> { private const int duration = 100; + private const double key_fade_time = 80; - public readonly Bindable<bool> Visible = new Bindable<bool>(true); private readonly Bindable<bool> configVisibility = new Bindable<bool>(); + protected readonly FillFlowContainer<KeyCounter> KeyFlow; + + protected override Container<KeyCounter> Content => KeyFlow; + + /// <summary> + /// Whether the key counter should be visible regardless of the configuration value. + /// This is true by default, but can be changed. + /// </summary> + public readonly Bindable<bool> AlwaysVisible = new Bindable<bool>(true); + public KeyCounterDisplay() { - Direction = FillDirection.Horizontal; AutoSizeAxes = Axes.Both; + + InternalChild = KeyFlow = new FillFlowContainer<KeyCounter> + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + }; } public override void Add(KeyCounter key) @@ -33,23 +48,22 @@ namespace osu.Game.Screens.Play base.Add(key); key.IsCounting = IsCounting; - key.FadeTime = FadeTime; + key.FadeTime = key_fade_time; key.KeyDownTextColor = KeyDownTextColor; key.KeyUpTextColor = KeyUpTextColor; } - public void ResetCount() - { - foreach (var counter in Children) - counter.ResetCount(); - } - [BackgroundDependencyLoader] private void load(OsuConfigManager config) { config.BindWith(OsuSetting.KeyOverlay, configVisibility); + } - Visible.BindValueChanged(_ => updateVisibility()); + protected override void LoadComplete() + { + base.LoadComplete(); + + AlwaysVisible.BindValueChanged(_ => updateVisibility()); configVisibility.BindValueChanged(_ => updateVisibility(), true); } @@ -68,22 +82,6 @@ namespace osu.Game.Screens.Play } } - private int fadeTime; - - public int FadeTime - { - get => fadeTime; - set - { - if (value != fadeTime) - { - fadeTime = value; - foreach (var child in Children) - child.FadeTime = value; - } - } - } - private Color4 keyDownTextColor = Color4.DarkGray; public Color4 KeyDownTextColor @@ -116,18 +114,15 @@ namespace osu.Game.Screens.Play } } - private void updateVisibility() => this.FadeTo(Visible.Value || configVisibility.Value ? 1 : 0, duration); + private void updateVisibility() => + // Isolate changing visibility of the key counters from fading this component. + KeyFlow.FadeTo(AlwaysVisible.Value || configVisibility.Value ? 1 : 0, duration); public override bool HandleNonPositionalInput => receptor == null; public override bool HandlePositionalInput => receptor == null; private Receptor receptor; - public Receptor GetReceptor() - { - return receptor ?? (receptor = new Receptor(this)); - } - public void SetReceptor(Receptor receptor) { if (this.receptor != null) diff --git a/osu.Game/Screens/Play/KeyCounterKeyboard.cs b/osu.Game/Screens/Play/KeyCounterKeyboard.cs index d9b6dca79d..187dcc1264 100644 --- a/osu.Game/Screens/Play/KeyCounterKeyboard.cs +++ b/osu.Game/Screens/Play/KeyCounterKeyboard.cs @@ -18,14 +18,19 @@ namespace osu.Game.Screens.Play protected override bool OnKeyDown(KeyDownEvent e) { - if (e.Key == Key) IsLit = true; + if (e.Key == Key) + { + IsLit = true; + Increment(); + } + return base.OnKeyDown(e); } - protected override bool OnKeyUp(KeyUpEvent e) + protected override void OnKeyUp(KeyUpEvent e) { if (e.Key == Key) IsLit = false; - return base.OnKeyUp(e); + base.OnKeyUp(e); } } } diff --git a/osu.Game/Screens/Play/KeyCounterMouse.cs b/osu.Game/Screens/Play/KeyCounterMouse.cs index 95fa58e5c0..e55525c5e8 100644 --- a/osu.Game/Screens/Play/KeyCounterMouse.cs +++ b/osu.Game/Screens/Play/KeyCounterMouse.cs @@ -36,14 +36,19 @@ namespace osu.Game.Screens.Play protected override bool OnMouseDown(MouseDownEvent e) { - if (e.Button == Button) IsLit = true; + if (e.Button == Button) + { + IsLit = true; + Increment(); + } + return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { if (e.Button == Button) IsLit = false; - return base.OnMouseUp(e); + base.OnMouseUp(e); } } } diff --git a/osu.Game/Screens/Play/PauseOverlay.cs b/osu.Game/Screens/Play/PauseOverlay.cs index 6cc6027a03..8778cff535 100644 --- a/osu.Game/Screens/Play/PauseOverlay.cs +++ b/osu.Game/Screens/Play/PauseOverlay.cs @@ -4,7 +4,11 @@ using System; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Game.Audio; using osu.Game.Graphics; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Screens.Play @@ -13,9 +17,13 @@ namespace osu.Game.Screens.Play { public Action OnResume; + public override bool IsPresent => base.IsPresent || pauseLoop.IsPlaying; + public override string Header => "paused"; public override string Description => "you're not going to do what i think you're going to do, are ya?"; + private SkinnableSound pauseLoop; + protected override Action BackAction => () => InternalButtons.Children.First().Click(); [BackgroundDependencyLoader] @@ -24,6 +32,27 @@ namespace osu.Game.Screens.Play AddButton("Continue", colours.Green, () => OnResume?.Invoke()); AddButton("Retry", colours.YellowDark, () => OnRetry?.Invoke()); AddButton("Quit", new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); + + AddInternal(pauseLoop = new SkinnableSound(new SampleInfo("Gameplay/pause-loop")) + { + Looping = true, + Volume = { Value = 0 } + }); + } + + protected override void PopIn() + { + base.PopIn(); + + pauseLoop.VolumeTo(1.0f, TRANSITION_DURATION, Easing.InQuint); + pauseLoop.Play(); + } + + protected override void PopOut() + { + base.PopOut(); + + pauseLoop.VolumeTo(0, TRANSITION_DURATION, Easing.OutQuad).Finally(_ => pauseLoop.Stop()); } } } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 8dc16af575..f9af1818d0 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -17,21 +18,31 @@ using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Containers; +using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Overlays; +using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; using osu.Game.Screens.Ranking; using osu.Game.Skinning; using osu.Game.Users; namespace osu.Game.Screens.Play { - public class Player : ScreenWithBeatmapBackground + [Cached] + [Cached(typeof(ISamplePlaybackDisabler))] + public class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler { + /// <summary> + /// The delay upon completion of the beatmap before displaying the results screen. + /// </summary> + public const double RESULTS_DISPLAY_DELAY = 1000.0; + public override bool AllowBackButton => false; // handled by HoldForMenuButton protected override UserActivity InitialActivity => new UserActivity.SoloGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); @@ -40,7 +51,12 @@ namespace osu.Game.Screens.Play public override bool HideOverlaysOnEnter => true; - public override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; + + // We are managing our own adjustments (see OnEntering/OnExiting). + public override bool AllowRateAdjustments => false; + + private readonly Bindable<bool> samplePlaybackDisabled = new Bindable<bool>(); /// <summary> /// Whether gameplay should pause when the game window focus is lost. @@ -55,18 +71,40 @@ namespace osu.Game.Screens.Play private readonly Bindable<bool> storyboardReplacesBackground = new Bindable<bool>(); + protected readonly Bindable<bool> LocalUserPlaying = new Bindable<bool>(); + public int RestartCount; [Resolved] private ScoreManager scoreManager { get; set; } - private RulesetInfo ruleset; + private RulesetInfo rulesetInfo; - private IAPIProvider api; + private Ruleset ruleset; + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private MusicController musicController { get; set; } private SampleChannel sampleRestart; + public BreakOverlay BreakOverlay; + + /// <summary> + /// Whether the gameplay is currently in a break. + /// </summary> + public readonly IBindable<bool> IsBreakTime = new BindableBool(); + + private BreakTracker breakTracker; + + private SkipOverlay skipOverlay; + protected ScoreProcessor ScoreProcessor { get; private set; } + + protected HealthProcessor HealthProcessor { get; private set; } + protected DrawableRuleset DrawableRuleset { get; private set; } protected HUDOverlay HUDOverlay { get; private set; } @@ -75,12 +113,18 @@ namespace osu.Game.Screens.Play protected GameplayClockContainer GameplayClockContainer { get; private set; } - protected DimmableStoryboard DimmableStoryboard { get; private set; } + public DimmableStoryboard DimmableStoryboard { get; private set; } [Cached] [Cached(Type = typeof(IBindable<IReadOnlyList<Mod>>))] protected new readonly Bindable<IReadOnlyList<Mod>> Mods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); + /// <summary> + /// Whether failing should be allowed. + /// By default, this checks whether all selected mods allow failing. + /// </summary> + protected virtual bool CheckModsAllowFailure() => Mods.Value.OfType<IApplicableFailOverride>().All(m => m.PerformFail()); + private readonly bool allowPause; private readonly bool showResults; @@ -95,54 +139,194 @@ namespace osu.Game.Screens.Play this.showResults = showResults; } - [BackgroundDependencyLoader] - private void load(AudioManager audio, IAPIProvider api, OsuConfigManager config) - { - this.api = api; + private GameplayBeatmap gameplayBeatmap; + private ScreenSuspensionHandler screenSuspension; + + private DependencyContainer dependencies; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + protected override void LoadComplete() + { + base.LoadComplete(); + + // replays should never be recorded or played back when autoplay is enabled + if (!Mods.Value.Any(m => m is ModAutoplay)) + PrepareReplay(); + } + + private Replay recordingReplay; + + /// <summary> + /// Run any recording / playback setup for replays. + /// </summary> + protected virtual void PrepareReplay() + { + DrawableRuleset.SetRecordTarget(recordingReplay = new Replay()); + } + + [BackgroundDependencyLoader(true)] + private void load(AudioManager audio, OsuConfigManager config, OsuGame game) + { Mods.Value = base.Mods.Value.Select(m => m.CreateCopy()).ToArray(); - WorkingBeatmap working = loadBeatmap(); + if (Beatmap.Value is DummyWorkingBeatmap) + return; - if (working == null) + IBeatmap playableBeatmap = loadPlayableBeatmap(); + + if (playableBeatmap == null) return; sampleRestart = audio.Samples.Get(@"Gameplay/restart"); mouseWheelDisabled = config.GetBindable<bool>(OsuSetting.MouseDisableWheel); - ScoreProcessor = DrawableRuleset.CreateScoreProcessor(); + if (game != null) + LocalUserPlaying.BindTo(game.LocalUserPlaying); + + DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, Mods.Value); + + ScoreProcessor = ruleset.CreateScoreProcessor(); + ScoreProcessor.ApplyBeatmap(playableBeatmap); ScoreProcessor.Mods.BindTo(Mods); + HealthProcessor = ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); + HealthProcessor.ApplyBeatmap(playableBeatmap); + if (!ScoreProcessor.Mode.Disabled) config.BindWith(OsuSetting.ScoreDisplayMode, ScoreProcessor.Mode); - InternalChild = GameplayClockContainer = new GameplayClockContainer(working, Mods.Value, DrawableRuleset.GameplayStartTime); + InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime); - GameplayClockContainer.Children = new[] + AddInternal(gameplayBeatmap = new GameplayBeatmap(playableBeatmap)); + AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer)); + + dependencies.CacheAs(gameplayBeatmap); + + var beatmapSkinProvider = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin); + + // the beatmapSkinProvider is used as the fallback source here to allow the ruleset-specific skin implementation + // full access to all skin sources. + var rulesetSkinProvider = new SkinProvidingContainer(ruleset.CreateLegacySkinProvider(beatmapSkinProvider, playableBeatmap)); + + // load the skinning hierarchy first. + // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. + GameplayClockContainer.Add(beatmapSkinProvider.WithChild(rulesetSkinProvider)); + + rulesetSkinProvider.AddRange(new[] { - DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard) { RelativeSizeAxes = Axes.Both }, - new ScalingContainer(ScalingMode.Gameplay) - { - Child = new LocalSkinOverrideContainer(working.Skin) + // underlay and gameplay should have access the to skinning sources. + createUnderlayComponents(), + createGameplayComponents(Beatmap.Value, playableBeatmap) + }); + + // also give the HUD a ruleset container to allow rulesets to potentially override HUD elements (used to disable combo counters etc.) + // we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there. + var hudRulesetContainer = new SkinProvidingContainer(ruleset.CreateLegacySkinProvider(beatmapSkinProvider, playableBeatmap)); + + // add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components. + GameplayClockContainer.Add(hudRulesetContainer.WithChild(createOverlayComponents(Beatmap.Value))); + + if (!DrawableRuleset.AllowGameplayOverlays) + { + HUDOverlay.ShowHud.Value = false; + HUDOverlay.ShowHud.Disabled = true; + BreakOverlay.Hide(); + skipOverlay.Hide(); + } + + DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting => + { + if (waiting.NewValue) + GameplayClockContainer.Stop(); + else + GameplayClockContainer.Start(); + }); + + DrawableRuleset.IsPaused.BindValueChanged(paused => + { + updateGameplayState(); + updateSampleDisabledState(); + }); + + DrawableRuleset.FrameStableClock.IsCatchingUp.BindValueChanged(_ => updateSampleDisabledState()); + + DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updateGameplayState()); + + DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updatePauseOnFocusLostState(), true); + + // bind clock into components that require it + DrawableRuleset.IsPaused.BindTo(GameplayClockContainer.IsPaused); + + DrawableRuleset.OnNewResult += r => + { + HealthProcessor.ApplyResult(r); + ScoreProcessor.ApplyResult(r); + gameplayBeatmap.ApplyResult(r); + }; + + DrawableRuleset.OnRevertResult += r => + { + HealthProcessor.RevertResult(r); + ScoreProcessor.RevertResult(r); + }; + + // Bind the judgement processors to ourselves + ScoreProcessor.HasCompleted.ValueChanged += updateCompletionState; + HealthProcessor.Failed += onFail; + + foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>()) + mod.ApplyToScoreProcessor(ScoreProcessor); + + foreach (var mod in Mods.Value.OfType<IApplicableToHealthProcessor>()) + mod.ApplyToHealthProcessor(HealthProcessor); + + IsBreakTime.BindTo(breakTracker.IsBreakTime); + IsBreakTime.BindValueChanged(onBreakTimeChanged, true); + } + + protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new GameplayClockContainer(beatmap, gameplayStart); + + private Drawable createUnderlayComponents() => + DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard) { RelativeSizeAxes = Axes.Both }; + + private Drawable createGameplayComponents(WorkingBeatmap working, IBeatmap playableBeatmap) => new ScalingContainer(ScalingMode.Gameplay) + { + Children = new Drawable[] + { + DrawableRuleset.With(r => + r.FrameStableComponents.Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + ScoreProcessor, + HealthProcessor, + breakTracker = new BreakTracker(DrawableRuleset.GameplayStartTime, ScoreProcessor) { - DrawableRuleset, - new ComboEffects(ScoreProcessor) + Breaks = working.Beatmap.Breaks } - } - }, - new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, ScoreProcessor) + }), + new ComboEffects(ScoreProcessor) + } + }; + + private Drawable createOverlayComponents(WorkingBeatmap working) => new Container + { + RelativeSizeAxes = Axes.Both, + Children = new[] + { + DimmableStoryboard.OverlayLayerContainer.CreateProxy(), + BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, ScoreProcessor) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Clock = DrawableRuleset.FrameStableClock, + ProcessCustomClock = false, Breaks = working.Beatmap.Breaks }, // display the cursor above some HUD elements. DrawableRuleset.Cursor?.CreateProxy() ?? new Container(), - HUDOverlay = new HUDOverlay(ScoreProcessor, DrawableRuleset, Mods.Value) + DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(), + HUDOverlay = new HUDOverlay(ScoreProcessor, HealthProcessor, DrawableRuleset, Mods.Value) { HoldToQuit = { @@ -150,14 +334,18 @@ namespace osu.Game.Screens.Play IsPaused = { BindTarget = GameplayClockContainer.IsPaused } }, PlayerSettingsOverlay = { PlaybackSettings = { UserPlaybackRate = { BindTarget = GameplayClockContainer.UserPlaybackRate } } }, - KeyCounter = { Visible = { BindTarget = DrawableRuleset.HasReplayLoaded } }, + KeyCounter = + { + AlwaysVisible = { BindTarget = DrawableRuleset.HasReplayLoaded }, + IsCounting = false + }, RequestSeek = GameplayClockContainer.Seek, Anchor = Anchor.Centre, Origin = Anchor.Centre }, - new SkipOverlay(DrawableRuleset.GameplayStartTime) + skipOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime) { - RequestSeek = GameplayClockContainer.Seek + RequestSkip = GameplayClockContainer.Skip }, FailOverlay = new FailOverlay { @@ -191,52 +379,60 @@ namespace osu.Game.Screens.Play performImmediateExit(); }, }, - failAnimation = new FailAnimation(DrawableRuleset) { OnComplete = onFailComplete, } - }; + failAnimation = new FailAnimation(DrawableRuleset) { OnComplete = onFailComplete, }, + } + }; - DrawableRuleset.HasReplayLoaded.BindValueChanged(e => HUDOverlay.HoldToQuit.PauseOnFocusLost = !e.NewValue && PauseOnFocusLost, true); - - // bind clock into components that require it - DrawableRuleset.IsPaused.BindTo(GameplayClockContainer.IsPaused); - - // Bind ScoreProcessor to ourselves - ScoreProcessor.AllJudged += onCompletion; - ScoreProcessor.Failed += onFail; - - foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>()) - mod.ApplyToScoreProcessor(ScoreProcessor); + private void onBreakTimeChanged(ValueChangedEvent<bool> isBreakTime) + { + updateGameplayState(); + updatePauseOnFocusLostState(); + HUDOverlay.KeyCounter.IsCounting = !isBreakTime.NewValue; } - private WorkingBeatmap loadBeatmap() + private void updateGameplayState() { - WorkingBeatmap working = Beatmap.Value; - if (working is DummyWorkingBeatmap) - return null; + bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !DrawableRuleset.IsPaused.Value && !breakTracker.IsBreakTime.Value; + OverlayActivationMode.Value = inGameplay ? OverlayActivation.Disabled : OverlayActivation.UserTriggered; + LocalUserPlaying.Value = inGameplay; + } + + private void updateSampleDisabledState() + { + samplePlaybackDisabled.Value = DrawableRuleset.FrameStableClock.IsCatchingUp.Value || GameplayClockContainer.GameplayClock.IsPaused.Value; + } + + private void updatePauseOnFocusLostState() => + HUDOverlay.HoldToQuit.PauseOnFocusLost = PauseOnFocusLost + && !DrawableRuleset.HasReplayLoaded.Value + && !breakTracker.IsBreakTime.Value; + + private IBeatmap loadPlayableBeatmap() + { + IBeatmap playable; try { - var beatmap = working.Beatmap; - - if (beatmap == null) + if (Beatmap.Value.Beatmap == null) throw new InvalidOperationException("Beatmap was not loaded"); - ruleset = Ruleset.Value ?? beatmap.BeatmapInfo.Ruleset; - var rulesetInstance = ruleset.CreateInstance(); + rulesetInfo = Ruleset.Value ?? Beatmap.Value.BeatmapInfo.Ruleset; + ruleset = rulesetInfo.CreateInstance(); try { - DrawableRuleset = rulesetInstance.CreateDrawableRulesetWith(working, Mods.Value); + playable = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, Mods.Value); } catch (BeatmapInvalidForRulesetException) { - // we may fail to create a DrawableRuleset if the beatmap cannot be loaded with the user's preferred ruleset - // let's try again forcing the beatmap's ruleset. - ruleset = beatmap.BeatmapInfo.Ruleset; - rulesetInstance = ruleset.CreateInstance(); - DrawableRuleset = rulesetInstance.CreateDrawableRulesetWith(Beatmap.Value, Mods.Value); + // A playable beatmap may not be creatable with the user's preferred ruleset, so try using the beatmap's default ruleset + rulesetInfo = Beatmap.Value.BeatmapInfo.Ruleset; + ruleset = rulesetInfo.CreateInstance(); + + playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo, Mods.Value); } - if (!DrawableRuleset.Objects.Any()) + if (playable.HitObjects.Count == 0) { Logger.Log("Beatmap contains no hit objects!", level: LogLevel.Error); return null; @@ -244,18 +440,18 @@ namespace osu.Game.Screens.Play } catch (Exception e) { - Logger.Error(e, "Could not load beatmap sucessfully!"); + Logger.Error(e, "Could not load beatmap successfully!"); //couldn't load, hard abort! return null; } - return working; + return playable; } private void performImmediateExit() { // if a restart has been requested, cancel any pending completion (user has shown intent to restart). - onCompletionEvent = null; + completionProgressDelegate?.Cancel(); ValidForResume = false; @@ -266,62 +462,82 @@ namespace osu.Game.Screens.Play { if (!this.IsCurrentScreen()) return; - this.Exit(); + if (ValidForResume && HasFailed && !FailOverlay.IsPresent) + { + failAnimation.FinishTransforms(true); + return; + } + + if (canPause) + Pause(); + else + this.Exit(); } + /// <summary> + /// Restart gameplay via a parent <see cref="PlayerLoader"/>. + /// <remarks>This can be called from a child screen in order to trigger the restart process.</remarks> + /// </summary> public void Restart() { - if (!this.IsCurrentScreen()) return; + // at the point of restarting the track should either already be paused or the volume should be zero. + // stopping here is to ensure music doesn't become audible after exiting back to PlayerLoader. + musicController.Stop(); sampleRestart?.Play(); - - // if a restart has been requested, cancel any pending completion (user has shown intent to restart). - onCompletionEvent = null; - - ValidForResume = false; RestartRequested?.Invoke(); - this.Exit(); + + if (this.IsCurrentScreen()) + performImmediateExit(); + else + this.MakeCurrent(); } - private ScheduledDelegate onCompletionEvent; + private ScheduledDelegate completionProgressDelegate; - private void onCompletion() + private void updateCompletionState(ValueChangedEvent<bool> completionState) { + // screen may be in the exiting transition phase. + if (!this.IsCurrentScreen()) + return; + + if (!completionState.NewValue) + { + completionProgressDelegate?.Cancel(); + completionProgressDelegate = null; + ValidForResume = true; + return; + } + + if (completionProgressDelegate != null) + throw new InvalidOperationException($"{nameof(updateCompletionState)} was fired more than once"); + // Only show the completion screen if the player hasn't failed - if (ScoreProcessor.HasFailed || onCompletionEvent != null) + if (HealthProcessor.HasFailed) return; ValidForResume = false; if (!showResults) return; - using (BeginDelayedSequence(1000)) - { - onCompletionEvent = Schedule(delegate - { - if (!this.IsCurrentScreen()) return; - - var score = CreateScore(); - if (DrawableRuleset.ReplayScore == null) - scoreManager.Import(score).Wait(); - - this.Push(CreateResults(score)); - - onCompletionEvent = null; - }); - } + using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY)) + completionProgressDelegate = Schedule(GotoRanking); } protected virtual ScoreInfo CreateScore() { - var score = DrawableRuleset.ReplayScore?.ScoreInfo ?? new ScoreInfo + var score = new ScoreInfo { Beatmap = Beatmap.Value.BeatmapInfo, - Ruleset = ruleset, + Ruleset = rulesetInfo, Mods = Mods.Value.ToArray(), - User = api.LocalUser.Value, }; + if (DrawableRuleset.ReplayScore != null) + score.User = DrawableRuleset.ReplayScore.ScoreInfo?.User ?? new GuestUser(); + else + score.User = api.LocalUser.Value; + ScoreProcessor.PopulateScore(score); return score; @@ -329,7 +545,7 @@ namespace osu.Game.Screens.Play protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value; - protected virtual Results CreateResults(ScoreInfo score) => new SoloResults(score); + protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score); #region Fail Logic @@ -339,7 +555,7 @@ namespace osu.Game.Screens.Play private bool onFail() { - if (Mods.Value.OfType<IApplicableFailOverride>().Any(m => !m.AllowFail)) + if (!CheckModsAllowFailure()) return false; HasFailed = true; @@ -351,6 +567,10 @@ namespace osu.Game.Screens.Play PauseOverlay.Hide(); failAnimation.Start(); + + if (Mods.Value.OfType<IApplicableFailOverride>().Any(m => m.RestartOnFail)) + Restart(); + return true; } @@ -403,7 +623,12 @@ namespace osu.Game.Screens.Play { if (!canPause) return; - IsResuming = false; + if (IsResuming) + { + DrawableRuleset.CancelResume(); + IsResuming = false; + } + GameplayClockContainer.Stop(); PauseOverlay.Show(); lastPauseActionTime = GameplayClockContainer.GameplayClock.CurrentTime; @@ -417,8 +642,7 @@ namespace osu.Game.Screens.Play PauseOverlay.Hide(); // breaks and time-based conditions may allow instant resume. - double time = GameplayClockContainer.GameplayClock.CurrentTime; - if (Beatmap.Value.Beatmap.Breaks.Any(b => b.Contains(time)) || time < Beatmap.Value.Beatmap.HitObjects.First().StartTime) + if (breakTracker.IsBreakTime.Value) completeResume(); else DrawableRuleset.RequestResume(completeResume); @@ -451,6 +675,11 @@ namespace osu.Game.Screens.Play Background.EnableUserDim.Value = true; Background.BlurAmount.Value = 0; + // bind component bindables. + Background.IsBreakTime.BindTo(breakTracker.IsBreakTime); + HUDOverlay.IsBreakTime.BindTo(breakTracker.IsBreakTime); + DimmableStoryboard.IsBreakTime.BindTo(breakTracker.IsBreakTime); + Background.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); DimmableStoryboard.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); @@ -459,48 +688,92 @@ namespace osu.Game.Screens.Play GameplayClockContainer.Restart(); GameplayClockContainer.FadeInFromZero(750, Easing.OutQuint); + foreach (var mod in Mods.Value.OfType<IApplicableToPlayer>()) + mod.ApplyToPlayer(this); + foreach (var mod in Mods.Value.OfType<IApplicableToHUD>()) mod.ApplyToHUD(HUDOverlay); + + // Our mods are local copies of the global mods so they need to be re-applied to the track. + // This is done through the music controller (for now), because resetting speed adjustments on the beatmap track also removes adjustments provided by DrawableTrack. + // Todo: In the future, player will receive in a track and will probably not have to worry about this... + musicController.ResetTrackAdjustments(); + foreach (var mod in Mods.Value.OfType<IApplicableToTrack>()) + mod.ApplyToTrack(musicController.CurrentTrack); + + updateGameplayState(); } public override void OnSuspending(IScreen next) { + screenSuspension?.Expire(); + fadeOut(); base.OnSuspending(next); } public override bool OnExiting(IScreen next) { - if (onCompletionEvent != null) + screenSuspension?.Expire(); + + if (completionProgressDelegate != null && !completionProgressDelegate.Cancelled && !completionProgressDelegate.Completed) { - // Proceed to result screen if beatmap already finished playing - onCompletionEvent.RunTask(); + // proceed to result screen if beatmap already finished playing + completionProgressDelegate.RunTask(); return true; } - if (canPause) + // ValidForResume is false when restarting + if (ValidForResume) { - Pause(); - return true; + if (pauseCooldownActive && !GameplayClockContainer.IsPaused.Value) + // still want to block if we are within the cooldown period and not already paused. + return true; } - if (pauseCooldownActive && !GameplayClockContainer.IsPaused.Value) - // still want to block if we are within the cooldown period and not already paused. - return true; + // 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(); - if (HasFailed && ValidForResume && !FailOverlay.IsPresent) - // ValidForResume is false when restarting - { - failAnimation.FinishTransforms(true); - return true; - } - - GameplayClockContainer.ResetLocalAdjustments(); + musicController.ResetTrackAdjustments(); fadeOut(); return base.OnExiting(next); } + protected virtual void GotoRanking() + { + if (DrawableRuleset.ReplayScore != null) + { + // if a replay is present, we likely don't want to import into the local database. + this.Push(CreateResults(CreateScore())); + return; + } + + LegacyByteArrayReader replayReader = null; + + var score = new Score { ScoreInfo = CreateScore() }; + + if (recordingReplay?.Frames.Count > 0) + { + score.Replay = recordingReplay; + + using (var stream = new MemoryStream()) + { + new LegacyScoreEncoder(score, gameplayBeatmap.PlayableBeatmap).Encode(stream); + replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr"); + } + } + + scoreManager.Import(score.ScoreInfo, replayReader) + .ContinueWith(imported => Schedule(() => + { + // screen may be in the exiting transition phase. + if (this.IsCurrentScreen()) + this.Push(CreateResults(imported.Result)); + })); + } + private void fadeOut(bool instant = false) { float fadeOutDuration = instant ? 0 : 250; @@ -511,5 +784,7 @@ namespace osu.Game.Screens.Play } #endregion + + IBindable<bool> ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; } } diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 5396321160..42074ac241 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -2,26 +2,26 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +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.Graphics.Sprites; +using osu.Framework.Graphics.Transforms; using osu.Framework.Input; -using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Framework.Threading; -using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using osu.Game.Input; -using osu.Game.Rulesets.Mods; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; using osu.Game.Screens.Menu; -using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Users; using osuTK; @@ -33,43 +33,89 @@ namespace osu.Game.Screens.Play { protected const float BACKGROUND_BLUR = 15; + public override bool HideOverlaysOnEnter => hideOverlays; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + // Here because IsHovered will not update unless we do so. + public override bool HandlePositionalInput => true; + + // We show the previous screen status + protected override UserActivity InitialActivity => null; + + protected override bool PlayResumeSound => false; + + protected BeatmapMetadataDisplay MetadataInfo; + + protected VisualSettings VisualSettings; + + protected Task LoadTask { get; private set; } + + protected Task DisposalTask { get; private set; } + + private bool backgroundBrightnessReduction; + + private readonly BindableDouble volumeAdjustment = new BindableDouble(1); + + protected bool BackgroundBrightnessReduction + { + set + { + if (value == backgroundBrightnessReduction) + return; + + backgroundBrightnessReduction = value; + + Background.FadeColour(OsuColour.Gray(backgroundBrightnessReduction ? 0.8f : 1), 200); + } + } + + private bool readyForPush => + // don't push unless the player is completely loaded + player?.LoadState == LoadState.Ready + // don't push if the user is hovering one of the panes, unless they are idle. + && (IsHovered || idleTracker.IsIdle.Value) + // don't push if the user is dragging a slider or otherwise. + && inputManager?.DraggedDrawable == null + // don't push if a focused overlay is visible, like settings. + && inputManager?.FocusedDrawable == null; + private readonly Func<Player> createPlayer; private Player player; private LogoTrackingContainer content; - private BeatmapMetadataDisplay info; - private bool hideOverlays; - public override bool HideOverlaysOnEnter => hideOverlays; - - protected override UserActivity InitialActivity => null; //shows the previous screen status - - public override bool DisallowExternalBeatmapRulesetChanges => true; - - protected override bool PlayResumeSound => false; - - private Task loadTask; private InputManager inputManager; private IdleTracker idleTracker; + private ScheduledDelegate scheduledPushPlayer; + + [CanBeNull] + private EpilepsyWarning epilepsyWarning; + + [Resolved(CanBeNull = true)] + private NotificationOverlay notificationOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private VolumeOverlay volumeOverlay { get; set; } + + [Resolved] + private AudioManager audioManager { get; set; } + public PlayerLoader(Func<Player> createPlayer) { this.createPlayer = createPlayer; } - private void restartRequested() - { - hideOverlays = true; - ValidForResume = true; - } - [BackgroundDependencyLoader] - private void load() + private void load(SessionStatics sessionStatics) { + muteWarningShownOnce = sessionStatics.GetBindable<bool>(Static.MutedAudioNotificationShownOnce); + InternalChild = (content = new LogoTrackingContainer { Anchor = Anchor.Centre, @@ -77,7 +123,7 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both, }).WithChildren(new Drawable[] { - info = new BeatmapMetadataDisplay(Beatmap.Value, Mods.Value, content.LogoFacade) + MetadataInfo = new BeatmapMetadataDisplay(Beatmap.Value, Mods, content.LogoFacade) { Alpha = 0, Anchor = Anchor.Centre, @@ -100,10 +146,43 @@ namespace osu.Game.Screens.Play idleTracker = new IdleTracker(750) }); - loadNewPlayer(); + if (Beatmap.Value.BeatmapInfo.EpilepsyWarning) + { + AddInternal(epilepsyWarning = new EpilepsyWarning + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + } } - private void playerLoaded(Player player) => info.Loading = false; + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } + + #region Screen handling + + public override void OnEntering(IScreen last) + { + base.OnEntering(last); + + if (epilepsyWarning != null) + epilepsyWarning.DimmableBackground = Background; + Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); + + content.ScaleTo(0.7f); + Background?.FadeColour(Color4.White, 800, Easing.OutQuint); + + contentIn(); + + MetadataInfo.Delay(750).FadeIn(500); + this.Delay(1800).Schedule(pushWhenLoaded); + + showMuteWarningIfNeeded(); + } public override void OnResuming(IScreen last) { @@ -111,51 +190,35 @@ namespace osu.Game.Screens.Play contentIn(); - info.Loading = true; - - //we will only be resumed if the player has requested a re-run (see ValidForResume setting above) - loadNewPlayer(); - this.Delay(400).Schedule(pushWhenLoaded); } - private void loadNewPlayer() + public override void OnSuspending(IScreen next) { - var restartCount = player?.RestartCount + 1 ?? 0; + base.OnSuspending(next); - player = createPlayer(); - player.RestartCount = restartCount; - player.RestartRequested = restartRequested; + cancelLoad(); - loadTask = LoadComponentAsync(player, playerLoaded); + BackgroundBrightnessReduction = false; + + // we're moving to player, so a period of silence is upcoming. + // stop the track before removing adjustment to avoid a volume spike. + Beatmap.Value.Track.Stop(); + Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); } - private void contentIn() + public override bool OnExiting(IScreen next) { - content.ScaleTo(1, 650, Easing.OutQuint); - content.FadeInFromZero(400); - } + cancelLoad(); - private void contentOut() - { - // Ensure the logo is no longer tracking before we scale the content - content.StopTracking(); + content.ScaleTo(0.7f, 150, Easing.InQuint); + this.FadeOut(150); - content.ScaleTo(0.7f, 300, Easing.InQuint); - content.FadeOut(250); - } + Background.EnableUserDim.Value = false; + BackgroundBrightnessReduction = false; + Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); - public override void OnEntering(IScreen last) - { - base.OnEntering(last); - - content.ScaleTo(0.7f); - Background?.FadeColour(Color4.White, 800, Easing.OutQuint); - - contentIn(); - - info.Delay(750).FadeIn(500); - this.Delay(1800).Schedule(pushWhenLoaded); + return base.OnExiting(next); } protected override void LogoArriving(OsuLogo logo, bool resuming) @@ -164,10 +227,7 @@ namespace osu.Game.Screens.Play const double duration = 300; - if (!resuming) - { - logo.MoveTo(new Vector2(0.5f), duration, Easing.In); - } + if (!resuming) logo.MoveTo(new Vector2(0.5f), duration, Easing.In); logo.ScaleTo(new Vector2(0.15f), duration, Easing.In); logo.FadeIn(350); @@ -185,115 +245,7 @@ namespace osu.Game.Screens.Play content.StopTracking(); } - protected override void LoadComplete() - { - inputManager = GetContainingInputManager(); - base.LoadComplete(); - } - - private ScheduledDelegate pushDebounce; - protected VisualSettings VisualSettings; - - // Here because IsHovered will not update unless we do so. - public override bool HandlePositionalInput => true; - - private bool readyForPush => player.LoadState == LoadState.Ready && (IsHovered || idleTracker.IsIdle.Value) && inputManager?.DraggedDrawable == null; - - private void pushWhenLoaded() - { - if (!this.IsCurrentScreen()) return; - - try - { - if (!readyForPush) - { - // as the pushDebounce below has a delay, we need to keep checking and cancel a future debounce - // if we become unready for push during the delay. - cancelLoad(); - return; - } - - if (pushDebounce != null) - return; - - pushDebounce = Scheduler.AddDelayed(() => - { - contentOut(); - - this.Delay(250).Schedule(() => - { - if (!this.IsCurrentScreen()) return; - - loadTask = null; - - //By default, we want to load the player and never be returned to. - //Note that this may change if the player we load requested a re-run. - ValidForResume = false; - - if (player.LoadedBeatmapSuccessfully) - this.Push(player); - else - this.Exit(); - }); - }, 500); - } - finally - { - Schedule(pushWhenLoaded); - } - } - - private void cancelLoad() - { - pushDebounce?.Cancel(); - pushDebounce = null; - } - - public override void OnSuspending(IScreen next) - { - BackgroundBrightnessReduction = false; - base.OnSuspending(next); - cancelLoad(); - } - - public override bool OnExiting(IScreen next) - { - content.ScaleTo(0.7f, 150, Easing.InQuint); - this.FadeOut(150); - cancelLoad(); - - Background.EnableUserDim.Value = false; - BackgroundBrightnessReduction = false; - - return base.OnExiting(next); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (isDisposing) - { - // if the player never got pushed, we should explicitly dispose it. - loadTask?.ContinueWith(_ => player.Dispose()); - } - } - - private bool backgroundBrightnessReduction; - - protected bool BackgroundBrightnessReduction - { - get => backgroundBrightnessReduction; - set - { - if (value == backgroundBrightnessReduction) - return; - - backgroundBrightnessReduction = value; - - Background.FadeColour(OsuColour.Gray(backgroundBrightnessReduction ? 0.8f : 1), 200); - } - } + #endregion protected override void Update() { @@ -322,156 +274,173 @@ namespace osu.Game.Screens.Play } } - private class BeatmapMetadataDisplay : Container + private void prepareNewPlayer() { - private class MetadataLine : Container + if (!this.IsCurrentScreen()) + return; + + var restartCount = player?.RestartCount + 1 ?? 0; + + player = createPlayer(); + player.RestartCount = restartCount; + player.RestartRequested = restartRequested; + + LoadTask = LoadComponentAsync(player, _ => MetadataInfo.Loading = false); + } + + private void restartRequested() + { + hideOverlays = true; + ValidForResume = true; + } + + private void contentIn() + { + MetadataInfo.Loading = true; + + content.FadeInFromZero(400); + content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); + } + + private void contentOut() + { + // Ensure the logo is no longer tracking before we scale the content + content.StopTracking(); + + content.ScaleTo(0.7f, 300, Easing.InQuint); + content.FadeOut(250); + } + + private void pushWhenLoaded() + { + if (!this.IsCurrentScreen()) return; + + try { - public MetadataLine(string left, string right) + if (!readyForPush) { - AutoSizeAxes = Axes.Both; - Children = new Drawable[] + // as the pushDebounce below has a delay, we need to keep checking and cancel a future debounce + // if we become unready for push during the delay. + cancelLoad(); + return; + } + + if (scheduledPushPlayer != null) + return; + + scheduledPushPlayer = Scheduler.AddDelayed(() => + { + contentOut(); + + TransformSequence<PlayerLoader> pushSequence = this.Delay(250); + + // only show if the warning was created (i.e. the beatmap needs it) + // and this is not a restart of the map (the warning expires after first load). + if (epilepsyWarning?.IsAlive == true) { - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopRight, - Margin = new MarginPadding { Right = 5 }, - Colour = OsuColour.Gray(0.8f), - Text = left, - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopLeft, - Margin = new MarginPadding { Left = 5 }, - Text = string.IsNullOrEmpty(right) ? @"-" : right, - } - }; + const double epilepsy_display_length = 3000; + + pushSequence + .Schedule(() => epilepsyWarning.State.Value = Visibility.Visible) + .TransformBindableTo(volumeAdjustment, 0.25, EpilepsyWarning.FADE_DURATION, Easing.OutQuint) + .Delay(epilepsy_display_length) + .Schedule(() => + { + epilepsyWarning.Hide(); + epilepsyWarning.Expire(); + }) + .Delay(EpilepsyWarning.FADE_DURATION); + } + + pushSequence.Schedule(() => + { + if (!this.IsCurrentScreen()) return; + + LoadTask = null; + + // By default, we want to load the player and never be returned to. + // Note that this may change if the player we load requested a re-run. + ValidForResume = false; + + if (player.LoadedBeatmapSuccessfully) + this.Push(player); + else + this.Exit(); + }); + }, 500); + } + finally + { + Schedule(pushWhenLoaded); + } + } + + private void cancelLoad() + { + scheduledPushPlayer?.Cancel(); + scheduledPushPlayer = null; + } + + #region Disposal + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (isDisposing) + { + // if the player never got pushed, we should explicitly dispose it. + DisposalTask = LoadTask?.ContinueWith(_ => player.Dispose()); + } + } + + #endregion + + #region Mute warning + + private Bindable<bool> muteWarningShownOnce; + + private void showMuteWarningIfNeeded() + { + if (!muteWarningShownOnce.Value) + { + // Checks if the notification has not been shown yet and also if master volume is muted, track/music volume is muted or if the whole game is muted. + if (volumeOverlay?.IsMuted.Value == true || audioManager.Volume.Value <= audioManager.Volume.MinValue || audioManager.VolumeTrack.Value <= audioManager.VolumeTrack.MinValue) + { + notificationOverlay?.Post(new MutedNotification()); + muteWarningShownOnce.Value = true; } } + } - private readonly WorkingBeatmap beatmap; - private readonly IReadOnlyList<Mod> mods; - private readonly Drawable facade; - private LoadingAnimation loading; - private Sprite backgroundSprite; + private class MutedNotification : SimpleNotification + { + public override bool IsImportant => true; - public bool Loading + public MutedNotification() { - set - { - if (value) - { - loading.Show(); - backgroundSprite.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint); - } - else - { - loading.Hide(); - backgroundSprite.FadeColour(Color4.White, 400, Easing.OutQuint); - } - } - } - - public BeatmapMetadataDisplay(WorkingBeatmap beatmap, IReadOnlyList<Mod> mods, Drawable facade) - { - this.beatmap = beatmap; - this.mods = mods; - this.facade = facade; + Text = "Your music volume is set to 0%! Click here to restore it."; } [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours, AudioManager audioManager, NotificationOverlay notificationOverlay, VolumeOverlay volumeOverlay) { - var metadata = beatmap.BeatmapInfo?.Metadata ?? new BeatmapMetadata(); + Icon = FontAwesome.Solid.VolumeMute; + IconBackgound.Colour = colours.RedDark; - AutoSizeAxes = Axes.Both; - Children = new Drawable[] + Activated = delegate { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Direction = FillDirection.Vertical, - Children = new[] - { - facade.With(d => - { - d.Anchor = Anchor.TopCentre; - d.Origin = Anchor.TopCentre; - }), - new OsuSpriteText - { - Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), - Font = OsuFont.GetFont(size: 36, italics: true), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Margin = new MarginPadding { Top = 15 }, - }, - new OsuSpriteText - { - Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), - Font = OsuFont.GetFont(size: 26, italics: true), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - }, - new Container - { - Size = new Vector2(300, 60), - Margin = new MarginPadding(10), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - CornerRadius = 10, - Masking = true, - Children = new Drawable[] - { - backgroundSprite = new Sprite - { - RelativeSizeAxes = Axes.Both, - Texture = beatmap?.Background, - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - FillMode = FillMode.Fill, - }, - loading = new LoadingAnimation { Scale = new Vector2(1.3f) } - } - }, - new OsuSpriteText - { - Text = beatmap?.BeatmapInfo?.Version, - Font = OsuFont.GetFont(size: 26, italics: true), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Margin = new MarginPadding - { - Bottom = 40 - }, - }, - new MetadataLine("Source", metadata.Source) - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - }, - new MetadataLine("Mapper", metadata.AuthorString) - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - }, - new ModDisplay - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 20 }, - Current = { Value = mods } - } - }, - } - }; + notificationOverlay.Hide(); - Loading = true; + volumeOverlay.IsMuted.Value = false; + audioManager.Volume.SetDefault(); + audioManager.VolumeTrack.SetDefault(); + + return true; + }; } } + + #endregion } } diff --git a/osu.Game/Screens/Play/PlayerSettings/CollectionSettings.cs b/osu.Game/Screens/Play/PlayerSettings/CollectionSettings.cs deleted file mode 100644 index d3570a8d2d..0000000000 --- a/osu.Game/Screens/Play/PlayerSettings/CollectionSettings.cs +++ /dev/null @@ -1,32 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Overlays.Music; - -namespace osu.Game.Screens.Play.PlayerSettings -{ - public class CollectionSettings : PlayerSettingsGroup - { - protected override string Title => @"collections"; - - [BackgroundDependencyLoader] - private void load() - { - Children = new Drawable[] - { - new OsuSpriteText - { - Text = @"Add current song to", - }, - new CollectionsDropdown<PlaylistCollection> - { - RelativeSizeAxes = Axes.X, - Items = new[] { PlaylistCollection.All }, - }, - }; - } - } -} diff --git a/osu.Game/Screens/Play/PlayerSettings/DiscussionSettings.cs b/osu.Game/Screens/Play/PlayerSettings/DiscussionSettings.cs index bb4eea47ca..ac040774ee 100644 --- a/osu.Game/Screens/Play/PlayerSettings/DiscussionSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/DiscussionSettings.cs @@ -10,7 +10,10 @@ namespace osu.Game.Screens.Play.PlayerSettings { public class DiscussionSettings : PlayerSettingsGroup { - protected override string Title => @"discussions"; + public DiscussionSettings() + : base("discussions") + { + } [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs b/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs index 7a8696e27c..725a6e86bf 100644 --- a/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/InputSettings.cs @@ -9,11 +9,10 @@ namespace osu.Game.Screens.Play.PlayerSettings { public class InputSettings : PlayerSettingsGroup { - protected override string Title => "Input settings"; - private readonly PlayerCheckbox mouseButtonsCheckbox; public InputSettings() + : base("Input Settings") { Children = new Drawable[] { diff --git a/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs b/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs index c691d161ed..16e29ac3c8 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlaybackSettings.cs @@ -13,8 +13,6 @@ namespace osu.Game.Screens.Play.PlayerSettings { private const int padding = 10; - protected override string Title => @"playback"; - public readonly Bindable<double> UserPlaybackRate = new BindableDouble(1) { Default = 1, @@ -28,6 +26,7 @@ namespace osu.Game.Screens.Play.PlayerSettings private readonly OsuSpriteText multiplierText; public PlaybackSettings() + : base("playback") { Children = new Drawable[] { @@ -52,14 +51,14 @@ namespace osu.Game.Screens.Play.PlayerSettings } }, }, - rateSlider = new PlayerSliderBar<double> { Bindable = UserPlaybackRate } + rateSlider = new PlayerSliderBar<double> { Current = UserPlaybackRate } }; } protected override void LoadComplete() { base.LoadComplete(); - rateSlider.Bindable.BindValueChanged(multiplier => multiplierText.Text = $"{multiplier.NewValue:0.0}x", true); + rateSlider.Current.BindValueChanged(multiplier => multiplierText.Text = $"{multiplier.NewValue:0.0}x", true); } } } diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs index 90424ec007..7928d41e3b 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSettingsGroup.cs @@ -17,11 +17,6 @@ namespace osu.Game.Screens.Play.PlayerSettings { public abstract class PlayerSettingsGroup : Container { - /// <summary> - /// The title to be displayed in the header of this group. - /// </summary> - protected abstract string Title { get; } - private const float transition_duration = 250; private const int container_width = 270; private const int border_thickness = 2; @@ -58,7 +53,11 @@ namespace osu.Game.Screens.Play.PlayerSettings private Color4 expandedColour; - protected PlayerSettingsGroup() + /// <summary> + /// Create a new instance. + /// </summary> + /// <param name="title">The title to be displayed in the header of this group.</param> + protected PlayerSettingsGroup(string title) { AutoSizeAxes = Axes.Y; Width = container_width; @@ -95,7 +94,7 @@ namespace osu.Game.Screens.Play.PlayerSettings { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - Text = Title.ToUpperInvariant(), + Text = title.ToUpperInvariant(), Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 17), Margin = new MarginPadding { Left = 10 }, }, diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs index 28fe1f35ca..c8e281195a 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs @@ -11,7 +11,7 @@ using osu.Game.Overlays.Settings; namespace osu.Game.Screens.Play.PlayerSettings { public class PlayerSliderBar<T> : SettingsSlider<T> - where T : struct, IEquatable<T>, IComparable, IConvertible + where T : struct, IEquatable<T>, IComparable<T>, IConvertible { public OsuSliderBar<T> Bar => (OsuSliderBar<T>)Control; diff --git a/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs b/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs index 1c8628f704..8f29fe7893 100644 --- a/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs @@ -10,8 +10,6 @@ namespace osu.Game.Screens.Play.PlayerSettings { public class VisualSettings : PlayerSettingsGroup { - protected override string Title => "Visual settings"; - private readonly PlayerSliderBar<double> dimSliderBar; private readonly PlayerSliderBar<double> blurSliderBar; private readonly PlayerCheckbox showStoryboardToggle; @@ -19,6 +17,7 @@ namespace osu.Game.Screens.Play.PlayerSettings private readonly PlayerCheckbox beatmapHitsoundsToggle; public VisualSettings() + : base("Visual Settings") { Children = new Drawable[] { @@ -26,17 +25,23 @@ namespace osu.Game.Screens.Play.PlayerSettings { Text = "Background dim:" }, - dimSliderBar = new PlayerSliderBar<double>(), + dimSliderBar = new PlayerSliderBar<double> + { + DisplayAsPercentage = true + }, new OsuSpriteText { Text = "Background blur:" }, - blurSliderBar = new PlayerSliderBar<double>(), + blurSliderBar = new PlayerSliderBar<double> + { + DisplayAsPercentage = true + }, new OsuSpriteText { Text = "Toggles:" }, - showStoryboardToggle = new PlayerCheckbox { LabelText = "Storyboards" }, + showStoryboardToggle = new PlayerCheckbox { LabelText = "Storyboard / Video" }, beatmapSkinsToggle = new PlayerCheckbox { LabelText = "Beatmap skins" }, beatmapHitsoundsToggle = new PlayerCheckbox { LabelText = "Beatmap hitsounds" } }; @@ -45,8 +50,8 @@ namespace osu.Game.Screens.Play.PlayerSettings [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - dimSliderBar.Bindable = config.GetBindable<double>(OsuSetting.DimLevel); - blurSliderBar.Bindable = config.GetBindable<double>(OsuSetting.BlurLevel); + dimSliderBar.Current = config.GetBindable<double>(OsuSetting.DimLevel); + blurSliderBar.Current = config.GetBindable<double>(OsuSetting.BlurLevel); showStoryboardToggle.Current = config.GetBindable<bool>(OsuSetting.ShowStoryboard); beatmapSkinsToggle.Current = config.GetBindable<bool>(OsuSetting.BeatmapSkins); beatmapHitsoundsToggle.Current = config.GetBindable<bool>(OsuSetting.BeatmapHitsounds); diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index a9c0ee3a15..3a4298f22d 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -2,25 +2,38 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Scoring; +using osu.Game.Screens.Ranking; namespace osu.Game.Screens.Play { public class ReplayPlayer : Player { - private readonly Score score; + protected readonly Score Score; + + // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) + protected override bool CheckModsAllowFailure() => false; public ReplayPlayer(Score score, bool allowPause = true, bool showResults = true) : base(allowPause, showResults) { - this.score = score; + Score = score; } - protected override void LoadComplete() + protected override void PrepareReplay() { - base.LoadComplete(); - DrawableRuleset?.SetReplayScore(score); + DrawableRuleset?.SetReplayScore(Score); } - protected override ScoreInfo CreateScore() => score.ScoreInfo; + protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false); + + protected override ScoreInfo CreateScore() + { + var baseScore = base.CreateScore(); + + // Since the replay score doesn't contain statistics, we'll pass them through here. + Score.ScoreInfo.HitEvents = baseScore.HitEvents; + + return Score.ScoreInfo; + } } } diff --git a/osu.Game/Screens/Play/ReplayPlayerLoader.cs b/osu.Game/Screens/Play/ReplayPlayerLoader.cs index 86179ef067..9eff4cb8fc 100644 --- a/osu.Game/Screens/Play/ReplayPlayerLoader.cs +++ b/osu.Game/Screens/Play/ReplayPlayerLoader.cs @@ -2,33 +2,31 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Allocation; +using osu.Framework.Screens; using osu.Game.Scoring; namespace osu.Game.Screens.Play { public class ReplayPlayerLoader : PlayerLoader { - private readonly ScoreInfo scoreInfo; + public readonly ScoreInfo Score; public ReplayPlayerLoader(Score score) : base(() => new ReplayPlayer(score)) { if (score.Replay == null) - throw new ArgumentNullException(nameof(score.Replay), $"{nameof(score)} must have a non-null {nameof(score.Replay)}."); + throw new ArgumentException($"{nameof(score)} must have a non-null {nameof(score.Replay)}.", nameof(score)); - scoreInfo = score.ScoreInfo; + Score = score.ScoreInfo; } - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + public override void OnEntering(IScreen last) { - var dependencies = base.CreateChildDependencies(parent); - // these will be reverted thanks to PlayerLoader's lease. - Mods.Value = scoreInfo.Mods; - Ruleset.Value = scoreInfo.Ruleset; + Mods.Value = Score.Mods; + Ruleset.Value = Score.Ruleset; - return dependencies; + base.OnEntering(last); } } } diff --git a/osu.Game/Screens/Play/ResumeOverlay.cs b/osu.Game/Screens/Play/ResumeOverlay.cs index 2ef76069c2..641d5358ba 100644 --- a/osu.Game/Screens/Play/ResumeOverlay.cs +++ b/osu.Game/Screens/Play/ResumeOverlay.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.Play /// <summary> /// An overlay which can be used to require further user actions before gameplay is resumed. /// </summary> - public abstract class ResumeOverlay : OverlayContainer + public abstract class ResumeOverlay : VisibilityContainer { public CursorContainer GameplayCursor { get; set; } @@ -29,8 +29,6 @@ namespace osu.Game.Screens.Play protected const float TRANSITION_TIME = 500; - protected override bool BlockPositionalInput => false; - protected abstract string Message { get; } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; diff --git a/osu.Game/Screens/Play/ScreenSuspensionHandler.cs b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs new file mode 100644 index 0000000000..8585a5c309 --- /dev/null +++ b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs @@ -0,0 +1,52 @@ +// 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. + +using System; +using System.Diagnostics; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Platform; + +namespace osu.Game.Screens.Play +{ + /// <summary> + /// Ensures screen is not suspended / dimmed while gameplay is active. + /// </summary> + public class ScreenSuspensionHandler : Component + { + private readonly GameplayClockContainer gameplayClockContainer; + private Bindable<bool> isPaused; + + [Resolved] + private GameHost host { get; set; } + + public ScreenSuspensionHandler([NotNull] GameplayClockContainer gameplayClockContainer) + { + this.gameplayClockContainer = gameplayClockContainer ?? throw new ArgumentNullException(nameof(gameplayClockContainer)); + } + + protected override void LoadComplete() + { + 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); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + isPaused?.UnbindAll(); + + if (host != null) + host.AllowScreenSuspension.Value = true; + } + } +} diff --git a/osu.Game/Screens/Play/ScreenWithBeatmapBackground.cs b/osu.Game/Screens/Play/ScreenWithBeatmapBackground.cs index d7d2c97598..8eb253608b 100644 --- a/osu.Game/Screens/Play/ScreenWithBeatmapBackground.cs +++ b/osu.Game/Screens/Play/ScreenWithBeatmapBackground.cs @@ -9,6 +9,6 @@ namespace osu.Game.Screens.Play { protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(Beatmap.Value); - protected new BackgroundScreenBeatmap Background => (BackgroundScreenBeatmap)base.Background; + public new BackgroundScreenBeatmap Background => (BackgroundScreenBeatmap)base.Background; } } diff --git a/osu.Game/Screens/Play/SkinnableHealthDisplay.cs b/osu.Game/Screens/Play/SkinnableHealthDisplay.cs new file mode 100644 index 0000000000..d35d15d665 --- /dev/null +++ b/osu.Game/Screens/Play/SkinnableHealthDisplay.cs @@ -0,0 +1,51 @@ +// 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. + +using System; +using osu.Framework.Bindables; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play +{ + public class SkinnableHealthDisplay : SkinnableDrawable, IHealthDisplay + { + public Bindable<double> 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/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index d6c2b59d98..b123757ded 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -19,24 +19,28 @@ 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 { - public class SkipOverlay : OverlayContainer, IKeyBindingHandler<GlobalAction> + public class SkipOverlay : CompositeDrawable, IKeyBindingHandler<GlobalAction> { private readonly double startTime; - public Action<double> RequestSeek; + public Action RequestSkip; private Button button; + private ButtonContainer buttonContainer; private Box remainingTimeBox; private FadeContainer fadeContainer; private double displayTime; + [Resolved] + private GameplayClock gameplayClock { get; set; } + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - protected override bool BlockPositionalInput => false; /// <summary> /// Displays a skip overlay, giving the user the ability to skip forward. @@ -46,8 +50,6 @@ namespace osu.Game.Screens.Play { this.startTime = startTime; - Show(); - RelativePositionAxes = Axes.Both; RelativeSizeAxes = Axes.X; @@ -58,23 +60,18 @@ namespace osu.Game.Screens.Play } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, GameplayClock clock) + private void load(OsuColour colours) { - var baseClock = Clock; - - if (clock != null) - Clock = clock; - - Children = new Drawable[] + InternalChild = buttonContainer = new ButtonContainer { - fadeContainer = new FadeContainer + RelativeSizeAxes = Axes.Both, + Child = fadeContainer = new FadeContainer { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { button = new Button { - Clock = baseClock, Anchor = Anchor.Centre, Origin = Anchor.Centre, }, @@ -91,52 +88,43 @@ namespace osu.Game.Screens.Play }; } - /// <summary> - /// Duration before gameplay start time required before skip button displays. - /// </summary> - private const double skip_buffer = 1000; - private const double fade_time = 300; - private double beginFadeTime => startTime - fade_time; + private double fadeOutBeginTime => startTime - GameplayClockContainer.MINIMUM_SKIP_TIME; protected override void LoadComplete() { base.LoadComplete(); // skip is not required if there is no extra "empty" time to skip. - if (Clock.CurrentTime > beginFadeTime - skip_buffer) + // we may need to remove this if rewinding before the initial player load position becomes a thing. + if (fadeOutBeginTime < gameplayClock.CurrentTime) { - Alpha = 0; Expire(); return; } - this.FadeInFromZero(fade_time); - using (BeginAbsoluteSequence(beginFadeTime)) - this.FadeOut(fade_time); - - button.Action = () => RequestSeek?.Invoke(beginFadeTime); - - displayTime = Time.Current; - - Expire(); + button.Action = () => RequestSkip?.Invoke(); + displayTime = gameplayClock.CurrentTime; } - protected override void PopIn() => this.FadeIn(); - - protected override void PopOut() => this.FadeOut(); - protected override void Update() { base.Update(); - remainingTimeBox.ResizeWidthTo((float)Math.Max(0, 1 - (Time.Current - displayTime) / (beginFadeTime - displayTime)), 120, Easing.OutQuint); + + var progress = fadeOutBeginTime <= displayTime ? 1 : Math.Max(0, 1 - (gameplayClock.CurrentTime - displayTime) / (fadeOutBeginTime - displayTime)); + + remainingTimeBox.Width = (float)Interpolation.Lerp(remainingTimeBox.Width, progress, Math.Clamp(Time.Elapsed / 40, 0, 1)); + + button.Enabled.Value = progress > 0; + buttonContainer.State.Value = progress > 0 ? Visibility.Visible : Visibility.Hidden; } protected override bool OnMouseMove(MouseMoveEvent e) { if (!e.HasAnyButtonPressed) fadeContainer.Show(); + return base.OnMouseMove(e); } @@ -152,7 +140,9 @@ namespace osu.Game.Screens.Play return false; } - public bool OnReleased(GlobalAction action) => false; + public void OnReleased(GlobalAction action) + { + } private class FadeContainer : Container, IStateful<Visibility> { @@ -182,8 +172,11 @@ namespace osu.Game.Screens.Play this.FadeIn(500, Easing.OutExpo); if (!IsHovered && !IsDragged) + { using (BeginDelayedSequence(1000)) scheduledHide = Schedule(Hide); + } + break; case Visibility.Hidden: @@ -208,10 +201,9 @@ namespace osu.Game.Screens.Play return true; } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { Show(); - return true; } public override void Hide() => State = Visibility.Hidden; @@ -219,6 +211,13 @@ namespace osu.Game.Screens.Play public override void Show() => State = Visibility.Visible; } + private class ButtonContainer : VisibilityContainer + { + protected override void PopIn() => this.FadeIn(fade_time); + + protected override void PopOut() => this.FadeOut(fade_time); + } + private class Button : OsuClickableContainer { private Color4 colourNormal; @@ -317,10 +316,10 @@ namespace osu.Game.Screens.Play return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { aspect.ScaleTo(1, 1000, Easing.OutElastic); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) @@ -333,13 +332,7 @@ namespace osu.Game.Screens.Play box.FlashColour(Color4.White, 500, Easing.OutQuint); aspect.ScaleTo(1.2f, 2000, Easing.OutQuint); - bool result = base.OnClick(e); - - // for now, let's disable the skip button after the first press. - // this will likely need to be contextual in the future (bound from external components). - Enabled.Value = false; - - return result; + return base.OnClick(e); } } } diff --git a/osu.Game/Screens/Play/SoloResults.cs b/osu.Game/Screens/Play/SoloResults.cs deleted file mode 100644 index 2b9aec257c..0000000000 --- a/osu.Game/Screens/Play/SoloResults.cs +++ /dev/null @@ -1,24 +0,0 @@ -// 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. - -using System.Collections.Generic; -using osu.Game.Scoring; -using osu.Game.Screens.Ranking; -using osu.Game.Screens.Ranking.Types; - -namespace osu.Game.Screens.Play -{ - public class SoloResults : Results - { - public SoloResults(ScoreInfo score) - : base(score) - { - } - - protected override IEnumerable<IResultPageInfo> CreateResultPages() => new IResultPageInfo[] - { - new ScoreOverviewPageInfo(Score, Beatmap.Value), - new LocalLeaderboardPageInfo(Score, Beatmap.Value) - }; - } -} diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index 6642efdf8b..acf4640aa4 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -11,16 +11,17 @@ using osu.Framework.Allocation; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Timing; +using osu.Game.Configuration; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.UI; namespace osu.Game.Screens.Play { public class SongProgress : OverlayContainer { + private const int info_height = 20; private const int 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 transition_duration = 200; @@ -31,10 +32,18 @@ namespace osu.Game.Screens.Play public Action<double> RequestSeek; - public override bool HandleNonPositionalInput => AllowSeeking; - public override bool HandlePositionalInput => AllowSeeking; + /// <summary> + /// Whether seeking is allowed and the progress bar should be shown. + /// </summary> + public readonly Bindable<bool> AllowSeeking = new Bindable<bool>(); - private double lastHitTime => ((objects.Last() as IHasEndTime)?.EndTime ?? objects.Last().StartTime) + 1; + public readonly Bindable<bool> ShowGraph = new Bindable<bool>(); + + //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; private double firstHitTime => objects.First().StartTime; @@ -54,27 +63,13 @@ namespace osu.Game.Screens.Play } } - private readonly BindableBool replayLoaded = new BindableBool(); - public IClock ReferenceClock; private IClock gameplayClock; - [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, GameplayClock clock) - { - if (clock != null) - gameplayClock = clock; - - graph.FillColour = bar.FillColour = colours.BlueLighter; - } - public SongProgress() { - const float graph_height = SquareGraph.Column.WIDTH * 6; - - Height = bottom_bar_height + graph_height + handle_size.Y; - Y = bottom_bar_height; + Masking = true; Children = new Drawable[] { @@ -83,8 +78,7 @@ namespace osu.Game.Screens.Play Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Bottom = bottom_bar_height + graph_height }, + Height = info_height, }, graph = new SongProgressGraph { @@ -96,7 +90,6 @@ namespace osu.Game.Screens.Play }, bar = new SongProgressBar(bottom_bar_height, graph_height, handle_size) { - Alpha = 0, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, OnSeek = time => RequestSeek?.Invoke(time), @@ -104,44 +97,34 @@ namespace osu.Game.Screens.Play }; } + [BackgroundDependencyLoader(true)] + private void load(OsuColour colours, GameplayClock clock, OsuConfigManager config) + { + base.LoadComplete(); + + if (clock != null) + gameplayClock = clock; + + config.BindWith(OsuSetting.ShowProgressGraph, ShowGraph); + + graph.FillColour = bar.FillColour = colours.BlueLighter; + } + protected override void LoadComplete() { Show(); - replayLoaded.ValueChanged += loaded => AllowSeeking = loaded.NewValue; - replayLoaded.TriggerChange(); + AllowSeeking.BindValueChanged(_ => updateBarVisibility(), true); + ShowGraph.BindValueChanged(_ => updateGraphVisibility(), true); } public void BindDrawableRuleset(DrawableRuleset drawableRuleset) { - replayLoaded.BindTo(drawableRuleset.HasReplayLoaded); - } - - private bool allowSeeking; - - public bool AllowSeeking - { - get => allowSeeking; - set - { - if (allowSeeking == value) return; - - allowSeeking = value; - updateBarVisibility(); - } - } - - private void updateBarVisibility() - { - bar.FadeTo(allowSeeking ? 1 : 0, transition_duration, Easing.In); - this.MoveTo(new Vector2(0, allowSeeking ? 0 : bottom_bar_height), transition_duration, Easing.In); - - info.Margin = new MarginPadding { Bottom = Height - (allowSeeking ? 0 : handle_size.Y) }; + AllowSeeking.BindTo(drawableRuleset.HasReplayLoaded); } protected override void PopIn() { - updateBarVisibility(); this.FadeIn(500, Easing.OutQuint); } @@ -164,6 +147,31 @@ namespace osu.Game.Screens.Play bar.CurrentTime = gameplayTime; graph.Progress = (int)(graph.ColumnCount * progress); + + Height = bottom_bar_height + graph_height + handle_size.Y + info_height - graph.Y; + } + + private void updateBarVisibility() + { + bar.ShowHandle = AllowSeeking.Value; + + updateInfoMargin(); + } + + private void updateGraphVisibility() + { + float barHeight = bottom_bar_height + handle_size.Y; + + bar.ResizeHeightTo(ShowGraph.Value ? barHeight + graph_height : barHeight, transition_duration, Easing.In); + graph.MoveToY(ShowGraph.Value ? 0 : bottom_bar_height + graph_height, transition_duration, Easing.In); + + updateInfoMargin(); + } + + private void updateInfoMargin() + { + float finalMargin = bottom_bar_height + (AllowSeeking.Value ? handle_size.Y : 0) + (ShowGraph.Value ? graph_height : 0); + info.TransformTo(nameof(info.Margin), new MarginPadding { Bottom = finalMargin }, transition_duration, Easing.In); } } } diff --git a/osu.Game/Screens/Play/SongProgressBar.cs b/osu.Game/Screens/Play/SongProgressBar.cs index dd7b5826d5..939b5fad1f 100644 --- a/osu.Game/Screens/Play/SongProgressBar.cs +++ b/osu.Game/Screens/Play/SongProgressBar.cs @@ -8,7 +8,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; -using osu.Framework.MathUtils; +using osu.Framework.Utils; +using osu.Framework.Threading; namespace osu.Game.Screens.Play { @@ -18,6 +19,23 @@ namespace osu.Game.Screens.Play private readonly Box fill; private readonly Container handleBase; + private readonly Container handleContainer; + + private bool showHandle; + + public bool ShowHandle + { + get => showHandle; + set + { + if (value == showHandle) + return; + + showHandle = value; + + handleBase.FadeTo(showHandle ? 1 : 0, 200); + } + } public Color4 FillColour { @@ -39,6 +57,8 @@ namespace osu.Game.Screens.Play set => CurrentNumber.Value = value; } + protected override bool AllowKeyboardInputWhenNotHovered => true; + public SongProgressBar(float barHeight, float handleBarHeight, Vector2 handleSize) { CurrentNumber.MinValue = 0; @@ -73,7 +93,7 @@ namespace osu.Game.Screens.Play Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, Width = 2, - Height = barHeight + handleBarHeight, + Alpha = 0, Colour = Color4.White, Position = new Vector2(2, 0), Children = new Drawable[] @@ -83,7 +103,7 @@ namespace osu.Game.Screens.Play Name = "HandleBar box", RelativeSizeAxes = Axes.Both, }, - new Container + handleContainer = new Container { Name = "Handle container", Origin = Anchor.BottomCentre, @@ -115,12 +135,23 @@ namespace osu.Game.Screens.Play { base.Update(); - float newX = (float)Interpolation.Lerp(handleBase.X, NormalizedValue * UsableWidth, MathHelper.Clamp(Time.Elapsed / 40, 0, 1)); + handleBase.Height = Height - handleContainer.Height; + float newX = (float)Interpolation.Lerp(handleBase.X, NormalizedValue * UsableWidth, Math.Clamp(Time.Elapsed / 40, 0, 1)); fill.Width = newX; handleBase.X = newX; } - protected override void OnUserChange(double value) => OnSeek?.Invoke(value); + private ScheduledDelegate scheduledSeek; + + protected override void OnUserChange(double value) + { + scheduledSeek?.Cancel(); + scheduledSeek = Schedule(() => + { + if (showHandle) + OnSeek?.Invoke(value); + }); + } } } diff --git a/osu.Game/Screens/Play/SongProgressGraph.cs b/osu.Game/Screens/Play/SongProgressGraph.cs index e480c5b502..78eb456bb5 100644 --- a/osu.Game/Screens/Play/SongProgressGraph.cs +++ b/osu.Game/Screens/Play/SongProgressGraph.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Collections.Generic; using System.Diagnostics; -using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects; namespace osu.Game.Screens.Play @@ -26,7 +25,7 @@ namespace osu.Game.Screens.Play return; var firstHit = objects.First().StartTime; - var lastHit = objects.Max(o => (o as IHasEndTime)?.EndTime ?? o.StartTime); + var lastHit = objects.Max(o => o.GetEndTime()); if (lastHit == 0) lastHit = objects.Last().StartTime; @@ -35,7 +34,7 @@ namespace osu.Game.Screens.Play foreach (var h in objects) { - var endTime = (h as IHasEndTime)?.EndTime ?? h.StartTime; + var endTime = h.GetEndTime(); Debug.Assert(endTime >= h.StartTime); diff --git a/osu.Game/Screens/Play/Spectator.cs b/osu.Game/Screens/Play/Spectator.cs new file mode 100644 index 0000000000..0f593db277 --- /dev/null +++ b/osu.Game/Screens/Play/Spectator.cs @@ -0,0 +1,362 @@ +// 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. + +using System; +using System.Collections.Generic; +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.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +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.Multi.Match.Components; +using osu.Game.Users; +using osuTK; + +namespace osu.Game.Screens.Play +{ + public class Spectator : OsuScreen + { + private readonly User targetUser; + + [Resolved] + private Bindable<WorkingBeatmap> beatmap { get; set; } + + [Resolved] + private Bindable<RulesetInfo> ruleset { get; set; } + + private Ruleset rulesetInstance; + + [Resolved] + private Bindable<IReadOnlyList<Mod>> mods { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private SpectatorStreamingClient spectatorStreaming { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } + + private Replay replay; + + private Container beatmapPanelContainer; + + private SpectatorState state; + + private IBindable<WeakReference<BeatmapSetInfo>> managerUpdated; + + private TriangleButton watchButton; + + private SettingsCheckbox automaticDownload; + + private BeatmapSetInfo onlineBeatmap; + + /// <summary> + /// Becomes true if a new state is waiting to be loaded (while this screen was not active). + /// </summary> + private bool newStatePending; + + public Spectator([NotNull] User targetUser) + { + this.targetUser = targetUser ?? throw new ArgumentNullException(nameof(targetUser)); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OsuConfigManager config) + { + InternalChild = new Container + { + Masking = true, + CornerRadius = 20, + AutoSizeAxes = Axes.Both, + AutoSizeDuration = 500, + AutoSizeEasing = Easing.OutQuint, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + Colour = colours.GreySeafoamDark, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Margin = new MarginPadding(20), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(15), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Spectator Mode", + Font = OsuFont.Default.With(size: 30), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(15), + Children = new Drawable[] + { + new UserGridPanel(targetUser) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 145, + Width = 290, + }, + new SpriteIcon + { + Size = new Vector2(40), + Icon = FontAwesome.Solid.ArrowRight, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + beatmapPanelContainer = new Container + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + }, + automaticDownload = new SettingsCheckbox + { + LabelText = "Automatically download beatmaps", + Current = config.GetBindable<bool>(OsuSetting.AutomaticallyDownloadWhenSpectating), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + watchButton = new PurpleTriangleButton + { + Text = "Start Watching", + Width = 250, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = attemptStart, + Enabled = { Value = false } + } + } + } + } + }; + } + + 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<WeakReference<BeatmapSetInfo>> beatmap) + { + if (beatmap.NewValue.TryGetTarget(out var beatmapSet) && beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == state.BeatmapID)) + Schedule(attemptStart); + } + + private void userSentFrames(int userId, FrameDataBundle data) + { + if (userId != targetUser.Id) + return; + + // this should never happen as the server sends the user's state on watching, + // but is here as a safety measure. + if (replay == null) + return; + + foreach (var frame in data.Frames) + { + IConvertibleReplayFrame convertibleFrame = rulesetInstance.CreateConvertibleReplayFrame(); + convertibleFrame.FromLegacy(frame, beatmap.Value.Beatmap); + + var convertedFrame = (ReplayFrame)convertibleFrame; + convertedFrame.Time = frame.Time; + + replay.Frames.Add(convertedFrame); + } + } + + private void userBeganPlaying(int userId, SpectatorState state) + { + if (userId != targetUser.Id) + return; + + 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; + + if (replay != null) + { + replay.HasReceivedAllFrames = true; + replay = null; + } + + Schedule(clearDisplay); + } + + private void clearDisplay() + { + watchButton.Enabled.Value = false; + beatmapPanelContainer.Clear(); + } + + private void attemptStart() + { + 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) + { + return; + } + + replay ??= new Replay { HasReceivedAllFrames = false }; + + var scoreInfo = new ScoreInfo + { + Beatmap = resolvedBeatmap, + User = targetUser, + Mods = state.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(), + Ruleset = resolvedRuleset.RulesetInfo, + }; + + ruleset.Value = resolvedRuleset.RulesetInfo; + rulesetInstance = resolvedRuleset; + + beatmap.Value = beatmaps.GetWorkingBeatmap(resolvedBeatmap); + watchButton.Enabled.Value = true; + + this.Push(new SpectatorPlayerLoader(new Score + { + ScoreInfo = scoreInfo, + Replay = replay, + })); + } + + private void showBeatmapPanel(SpectatorState state) + { + if (state?.BeatmapID == null) + { + beatmapPanelContainer.Clear(); + onlineBeatmap = null; + return; + } + + var req = new GetBeatmapSetRequest(state.BeatmapID.Value, BeatmapSetLookupType.BeatmapId); + req.Success += res => Schedule(() => + { + if (state != this.state) + return; + + onlineBeatmap = res.ToBeatmapSet(rulesets); + beatmapPanelContainer.Child = new GridBeatmapPanel(onlineBeatmap); + checkForAutomaticDownload(); + }); + + api.Queue(req); + } + + private void checkForAutomaticDownload() + { + if (onlineBeatmap == null) + return; + + if (!automaticDownload.Current.Value) + return; + + if (beatmaps.IsAvailableLocally(onlineBeatmap)) + return; + + beatmaps.Download(onlineBeatmap); + } + + 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/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs new file mode 100644 index 0000000000..fdf996150f --- /dev/null +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -0,0 +1,91 @@ +// 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. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Spectator; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Screens.Play +{ + public class SpectatorPlayer : Player + { + private readonly Score score; + + protected override bool CheckModsAllowFailure() => false; // todo: better support starting mid-way through beatmap + + public SpectatorPlayer(Score score) + { + this.score = score; + } + + protected override ResultsScreen CreateResults(ScoreInfo score) + { + return new SpectatorResultsScreen(score); + } + + [Resolved] + private SpectatorStreamingClient spectatorStreaming { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + spectatorStreaming.OnUserBeganPlaying += userBeganPlaying; + + AddInternal(new OsuSpriteText + { + Text = $"Watching {score.ScoreInfo.User.Username} playing live!", + Font = OsuFont.Default.With(size: 30), + Y = 100, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }); + } + + protected override void PrepareReplay() + { + DrawableRuleset?.SetReplayScore(score); + } + + protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) + { + // if we already have frames, start gameplay at the point in time they exist, should they be too far into the beatmap. + double? firstFrameTime = score.Replay.Frames.FirstOrDefault()?.Time; + + if (firstFrameTime == null || firstFrameTime <= gameplayStart + 5000) + return base.CreateGameplayClockContainer(beatmap, gameplayStart); + + return new GameplayClockContainer(beatmap, firstFrameTime.Value, true); + } + + public override bool OnExiting(IScreen next) + { + spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying; + return base.OnExiting(next); + } + + private void userBeganPlaying(int userId, SpectatorState state) + { + if (userId != score.ScoreInfo.UserID) return; + + Schedule(() => + { + if (this.IsCurrentScreen()) this.Exit(); + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (spectatorStreaming != null) + spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying; + } + } +} diff --git a/osu.Game/Screens/Play/SpectatorPlayerLoader.cs b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs new file mode 100644 index 0000000000..580af81166 --- /dev/null +++ b/osu.Game/Screens/Play/SpectatorPlayerLoader.cs @@ -0,0 +1,32 @@ +// 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. + +using System; +using osu.Framework.Screens; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Play +{ + public class SpectatorPlayerLoader : PlayerLoader + { + public readonly ScoreInfo Score; + + public SpectatorPlayerLoader(Score score) + : base(() => new SpectatorPlayer(score)) + { + if (score.Replay == null) + throw new ArgumentException($"{nameof(score)} must have a non-null {nameof(score.Replay)}.", nameof(score)); + + Score = score.ScoreInfo; + } + + public override void OnEntering(IScreen last) + { + // these will be reverted thanks to PlayerLoader's lease. + Mods.Value = Score.Mods; + Ruleset.Value = Score.Ruleset; + + base.OnEntering(last); + } + } +} diff --git a/osu.Game/Screens/Play/SpectatorResultsScreen.cs b/osu.Game/Screens/Play/SpectatorResultsScreen.cs new file mode 100644 index 0000000000..56ccfd2253 --- /dev/null +++ b/osu.Game/Screens/Play/SpectatorResultsScreen.cs @@ -0,0 +1,47 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Screens; +using osu.Game.Online.Spectator; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Screens.Play +{ + public class SpectatorResultsScreen : SoloResultsScreen + { + public SpectatorResultsScreen(ScoreInfo score) + : base(score) + { + } + + [Resolved] + private SpectatorStreamingClient spectatorStreaming { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + spectatorStreaming.OnUserBeganPlaying += userBeganPlaying; + } + + private void userBeganPlaying(int userId, SpectatorState state) + { + if (userId == Score.UserID) + { + Schedule(() => + { + if (this.IsCurrentScreen()) this.Exit(); + }); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (spectatorStreaming != null) + spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying; + } + } +} diff --git a/osu.Game/Screens/Play/SquareGraph.cs b/osu.Game/Screens/Play/SquareGraph.cs index 5b7a9574b6..36ce131411 100644 --- a/osu.Game/Screens/Play/SquareGraph.cs +++ b/osu.Game/Screens/Play/SquareGraph.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using osu.Framework; -using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -14,6 +13,7 @@ using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Allocation; +using osu.Framework.Layout; using osu.Framework.Threading; namespace osu.Game.Screens.Play @@ -22,6 +22,11 @@ namespace osu.Game.Screens.Play { private BufferedContainer<Column> columns; + public SquareGraph() + { + AddLayout(layout); + } + public int ColumnCount => columns?.Children.Count ?? 0; private int progress; @@ -38,7 +43,7 @@ namespace osu.Game.Screens.Play } } - private float[] calculatedValues = { }; // values but adjusted to fit the amount of columns + private float[] calculatedValues = Array.Empty<float>(); // values but adjusted to fit the amount of columns private int[] values; @@ -68,14 +73,7 @@ namespace osu.Game.Screens.Play } } - public override bool Invalidate(Invalidation invalidation = Invalidation.All, Drawable source = null, bool shallPropagate = true) - { - if ((invalidation & Invalidation.DrawSize) > 0) - layout.Invalidate(); - return base.Invalidate(invalidation, source, shallPropagate); - } - - private Cached layout = new Cached(); + private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawSize); private ScheduledDelegate scheduledCreate; protected override void Update() @@ -103,6 +101,7 @@ namespace osu.Game.Screens.Play var newColumns = new BufferedContainer<Column> { CacheDrawnFrameBuffer = true, + RedrawOnScale = false, RelativeSizeAxes = Axes.Both, }; @@ -255,7 +254,7 @@ namespace osu.Game.Screens.Play { Color4 colour = State == ColumnState.Lit ? LitColour : DimmedColour; - int countFilled = (int)MathHelper.Clamp(filled * drawableRows.Count, 0, drawableRows.Count); + int countFilled = (int)Math.Clamp(filled * drawableRows.Count, 0, drawableRows.Count); for (int i = 0; i < drawableRows.Count; i++) drawableRows[i].Colour = i < countFilled ? colour : EmptyColour; diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs new file mode 100644 index 0000000000..24f1116d0e --- /dev/null +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs @@ -0,0 +1,228 @@ +// 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. + +using System.Linq; +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.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Ranking.Contracted +{ + /// <summary> + /// The content that appears in the middle of a contracted <see cref="ScorePanel"/>. + /// </summary> + public class ContractedPanelMiddleContent : CompositeDrawable + { + private readonly ScoreInfo score; + + [Resolved] + private ScoreManager scoreManager { get; set; } + + /// <summary> + /// Creates a new <see cref="ContractedPanelMiddleContent"/>. + /// </summary> + /// <param name="score">The <see cref="ScoreInfo"/> to display.</param> + public ContractedPanelMiddleContent(ScoreInfo score) + { + this.score = score; + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerExponent = 2.5f, + CornerRadius = 20, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.25f), + Type = EdgeEffectType.Shadow, + Radius = 1, + Offset = new Vector2(0, 4) + }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("444") + }, + new UserCoverBackground + { + RelativeSizeAxes = Axes.Both, + User = score.User, + Colour = ColourInfo.GradientVertical(Color4.White.Opacity(0.5f), Color4Extensions.FromHex("#444").Opacity(0)) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new UpdateableAvatar(score.User) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(110), + Masking = true, + CornerExponent = 2.5f, + CornerRadius = 20, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.25f), + Type = EdgeEffectType.Shadow, + Radius = 8, + Offset = new Vector2(0, 4), + } + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = score.UserString, + Font = OsuFont.GetFont(size: 16, weight: FontWeight.SemiBold) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + ChildrenEnumerable = score.GetStatisticsForDisplay().Where(s => !s.Result.IsBonus()).Select(createStatistic) + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10 }, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new[] + { + createStatistic("Max Combo", $"x{score.MaxCombo}"), + createStatistic("Accuracy", $"{score.Accuracy.FormatAccuracy()}"), + } + }, + new ModDisplay + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + ExpansionMode = ExpansionMode.AlwaysExpanded, + DisplayUnrankedText = false, + Current = { Value = score.Mods }, + Scale = new Vector2(0.5f), + } + } + } + } + }, + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = 5 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = scoreManager.GetBindableTotalScoreString(score), + Font = OsuFont.GetFont(size: 20, weight: FontWeight.Medium, fixedWidth: true), + Spacing = new Vector2(-1, 0) + }, + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 2 }, + Child = new DrawableRank(score.Rank) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + } + } + } + }, + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 45), + } + }; + } + + private Drawable createStatistic(HitResultDisplayStatistic result) + => createStatistic(result.DisplayName, result.MaxCount == null ? $"{result.Count}" : $"{result.Count}/{result.MaxCount}"); + + private Drawable createStatistic(string key, string value) => new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = key, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }, + new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Text = value, + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), + Colour = Color4Extensions.FromHex("#FFDD55") + } + } + }; + } +} diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs new file mode 100644 index 0000000000..0935ee7fb2 --- /dev/null +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs @@ -0,0 +1,37 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Ranking.Contracted +{ + public class ContractedPanelTopContent : CompositeDrawable + { + private readonly ScoreInfo score; + + public ContractedPanelTopContent(ScoreInfo score) + { + this.score = score; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Y = 6, + Text = score.Position != null ? $"#{score.Position}" : string.Empty, + Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold) + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs new file mode 100644 index 0000000000..bca3a07fa6 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -0,0 +1,284 @@ +// 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. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +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.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 +{ + /// <summary> + /// The component that displays the player's accuracy on the results screen. + /// </summary> + public class AccuracyCircle : CompositeDrawable + { + /// <summary> + /// Duration for the transforms causing this component to appear. + /// </summary> + public const double APPEAR_DURATION = 200; + + /// <summary> + /// Delay before the accuracy circle starts filling. + /// </summary> + public const double ACCURACY_TRANSFORM_DELAY = 450; + + /// <summary> + /// Duration for the accuracy circle fill. + /// </summary> + public const double ACCURACY_TRANSFORM_DURATION = 3000; + + /// <summary> + /// Delay after <see cref="ACCURACY_TRANSFORM_DURATION"/> for the rank text (A/B/C/D/S/SS) to appear. + /// </summary> + public const double TEXT_APPEAR_DELAY = ACCURACY_TRANSFORM_DURATION / 2; + + /// <summary> + /// Delay before the rank circles start filling. + /// </summary> + public const double RANK_CIRCLE_TRANSFORM_DELAY = 150; + + /// <summary> + /// Duration for the rank circle fills. + /// </summary> + public const double RANK_CIRCLE_TRANSFORM_DURATION = 800; + + /// <summary> + /// Relative width of the rank circles. + /// </summary> + public const float RANK_CIRCLE_RADIUS = 0.06f; + + /// <summary> + /// Relative width of the circle showing the accuracy. + /// </summary> + private const float accuracy_circle_radius = 0.2f; + + /// <summary> + /// SS is displayed as a 1% region, otherwise it would be invisible. + /// </summary> + private const double virtual_ss_percentage = 0.01; + + /// <summary> + /// The easing for the circle filling transforms. + /// </summary> + public static readonly Easing ACCURACY_TRANSFORM_EASING = Easing.OutPow10; + + private readonly ScoreInfo score; + + private readonly bool withFlair; + + private SmoothCircularProgress accuracyCircle; + private SmoothCircularProgress innerMask; + private Container<RankBadge> badges; + private RankText rankText; + + private SkinnableSound applauseSound; + + public AccuracyCircle(ScoreInfo score, bool withFlair) + { + this.score = score; + this.withFlair = withFlair; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + InternalChildren = new Drawable[] + { + new SmoothCircularProgress + { + Name = "Background circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.Gray(47), + Alpha = 0.5f, + InnerRadius = accuracy_circle_radius + 0.01f, // Extends a little bit into the circle + Current = { Value = 1 }, + }, + accuracyCircle = new SmoothCircularProgress + { + Name = "Accuracy circle", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#7CF6FF"), Color4Extensions.FromHex("#BAFFA9")), + InnerRadius = accuracy_circle_radius, + }, + new BufferedContainer + { + Name = "Graded circles", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Padding = new MarginPadding(2), + Children = new Drawable[] + { + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.X), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 1 } + }, + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.S), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 1 - virtual_ss_percentage } + }, + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.A), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 0.95f } + }, + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.B), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 0.9f } + }, + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.C), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 0.8f } + }, + new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + Colour = OsuColour.ForRank(ScoreRank.D), + InnerRadius = RANK_CIRCLE_RADIUS, + Current = { Value = 0.7f } + }, + new RankNotch(0), + new RankNotch((float)(1 - virtual_ss_percentage)), + new RankNotch(0.95f), + new RankNotch(0.9f), + new RankNotch(0.8f), + new RankNotch(0.7f), + new BufferedContainer + { + Name = "Graded circle mask", + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(1), + Blending = new BlendingParameters + { + Source = BlendingType.DstColor, + Destination = BlendingType.OneMinusSrcAlpha, + SourceAlpha = BlendingType.One, + DestinationAlpha = BlendingType.SrcAlpha + }, + Child = innerMask = new SmoothCircularProgress + { + RelativeSizeAxes = Axes.Both, + InnerRadius = RANK_CIRCLE_RADIUS - 0.01f, + } + } + } + }, + badges = new Container<RankBadge> + { + Name = "Rank badges", + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Vertical = -15, Horizontal = -20 }, + Children = new[] + { + new RankBadge(1f, getRank(ScoreRank.X)), + new RankBadge(0.95f, getRank(ScoreRank.S)), + new RankBadge(0.9f, getRank(ScoreRank.A)), + new RankBadge(0.8f, getRank(ScoreRank.B)), + new RankBadge(0.7f, getRank(ScoreRank.C)), + new RankBadge(0.35f, getRank(ScoreRank.D)), + } + }, + 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) + { + foreach (var mod in score.Mods.OfType<IApplicableToScoreProcessor>()) + rank = mod.AdjustRank(rank, score.Accuracy); + + return rank; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.ScaleTo(0).Then().ScaleTo(1, APPEAR_DURATION, Easing.OutQuint); + + using (BeginDelayedSequence(RANK_CIRCLE_TRANSFORM_DELAY, true)) + innerMask.FillTo(1f, RANK_CIRCLE_TRANSFORM_DURATION, ACCURACY_TRANSFORM_EASING); + + using (BeginDelayedSequence(ACCURACY_TRANSFORM_DELAY, true)) + { + double targetAccuracy = score.Rank == ScoreRank.X || score.Rank == ScoreRank.XH ? 1 : Math.Min(1 - virtual_ss_percentage, score.Accuracy); + + accuracyCircle.FillTo(targetAccuracy, ACCURACY_TRANSFORM_DURATION, ACCURACY_TRANSFORM_EASING); + + foreach (var badge in badges) + { + if (badge.Accuracy > score.Accuracy) + continue; + + using (BeginDelayedSequence(inverseEasing(ACCURACY_TRANSFORM_EASING, Math.Min(1 - virtual_ss_percentage, badge.Accuracy) / targetAccuracy) * ACCURACY_TRANSFORM_DURATION, true)) + { + badge.Appear(); + } + } + + using (BeginDelayedSequence(TEXT_APPEAR_DELAY, true)) + { + this.Delay(-1440).Schedule(() => applauseSound?.Play()); + rankText.Appear(); + } + } + } + + private double inverseEasing(Easing easing, double targetValue) + { + double test = 0; + double result = 0; + int count = 2; + + while (Math.Abs(result - targetValue) > 0.005) + { + int dir = Math.Sign(targetValue - result); + + test += dir * 1.0 / count; + result = Interpolation.ApplyEasing(easing, test); + + count++; + } + + return test; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs new file mode 100644 index 0000000000..76cd408daa --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankBadge.cs @@ -0,0 +1,99 @@ +// 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. + +using System; +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.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Online.Leaderboards; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Accuracy +{ + /// <summary> + /// Contains a <see cref="DrawableRank"/> that is positioned around the <see cref="AccuracyCircle"/>. + /// </summary> + public class RankBadge : CompositeDrawable + { + /// <summary> + /// The accuracy value corresponding to the <see cref="ScoreRank"/> displayed by this badge. + /// </summary> + public readonly float Accuracy; + + private readonly ScoreRank rank; + + private Drawable rankContainer; + private Drawable overlay; + + /// <summary> + /// Creates a new <see cref="RankBadge"/>. + /// </summary> + /// <param name="accuracy">The accuracy value corresponding to <paramref name="rank"/>.</param> + /// <param name="rank">The <see cref="ScoreRank"/> to be displayed in this <see cref="RankBadge"/>.</param> + public RankBadge(float accuracy, ScoreRank rank) + { + Accuracy = accuracy; + this.rank = rank; + + RelativeSizeAxes = Axes.Both; + Alpha = 0; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = rankContainer = new Container + { + Origin = Anchor.Centre, + Size = new Vector2(28, 14), + Children = new[] + { + new DrawableRank(rank), + overlay = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = OsuColour.ForRank(rank).Opacity(0.2f), + Radius = 10, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + } + } + } + }; + } + + /// <summary> + /// Shows this <see cref="RankBadge"/>. + /// </summary> + public void Appear() + { + this.FadeIn(50); + overlay.FadeIn().FadeOut(500, Easing.In); + } + + protected override void Update() + { + base.Update(); + + // Starts at -90deg (top) and moves counter-clockwise by the accuracy + rankContainer.Position = circlePosition(-MathF.PI / 2 - (1 - Accuracy) * MathF.PI * 2); + } + + private Vector2 circlePosition(float t) + => DrawSize / 2 + new Vector2(MathF.Cos(t), MathF.Sin(t)) * DrawSize / 2; + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankNotch.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankNotch.cs new file mode 100644 index 0000000000..894790b5b6 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankNotch.cs @@ -0,0 +1,49 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Accuracy +{ + /// <summary> + /// A solid "notch" of the <see cref="AccuracyCircle"/> that appears at the ends of the rank circles to add separation. + /// </summary> + public class RankNotch : CompositeDrawable + { + private readonly float position; + + public RankNotch(float position) + { + this.position = position; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Rotation = position * 360f, + Child = new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Y, + Height = AccuracyCircle.RANK_CIRCLE_RADIUS, + Width = 1f, + Colour = OsuColour.Gray(0.3f), + EdgeSmoothness = new Vector2(1f) + } + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs new file mode 100644 index 0000000000..cc732382f4 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/RankText.cs @@ -0,0 +1,139 @@ +// 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. + +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.Sprites; +using osu.Game.Online.Leaderboards; +using osu.Game.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Ranking.Expanded.Accuracy +{ + /// <summary> + /// The text that appears in the middle of the <see cref="AccuracyCircle"/> displaying the user's rank. + /// </summary> + public class RankText : CompositeDrawable + { + private readonly ScoreRank rank; + + private BufferedContainer flash; + private BufferedContainer superFlash; + private GlowingSpriteText rankText; + + public RankText(ScoreRank rank) + { + this.rank = rank; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Alpha = 0; + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + rankText = new GlowingSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + GlowColour = OsuColour.ForRank(rank), + Spacing = new Vector2(-15, 0), + Text = DrawableRank.GetRankName(rank), + Font = OsuFont.Numeric.With(size: 76), + UseFullGlyphHeight = false + }, + superFlash = new BufferedContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BlurSigma = new Vector2(85), + Size = new Vector2(600), + CacheDrawnFrameBuffer = true, + Blending = BlendingParameters.Additive, + Alpha = 0, + Children = new[] + { + new Box + { + Colour = Color4.White, + Size = new Vector2(150), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }, + }, + flash = new BufferedContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + BlurSigma = new Vector2(35), + BypassAutoSizeAxes = Axes.Both, + Size = new Vector2(200), + CacheDrawnFrameBuffer = true, + Blending = BlendingParameters.Additive, + Alpha = 0, + Scale = new Vector2(1.8f), + Children = new[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Spacing = new Vector2(-15, 0), + Text = DrawableRank.GetRankName(rank), + Font = OsuFont.Numeric.With(size: 76), + UseFullGlyphHeight = false, + Shadow = false + }, + }, + }, + }; + } + + public void Appear() + { + this.FadeIn(); + + if (rank < ScoreRank.A) + { + this + .MoveToOffset(new Vector2(0, -20)) + .MoveToOffset(new Vector2(0, 20), 200, Easing.OutBounce); + + if (rank <= ScoreRank.D) + { + this.Delay(700) + .RotateTo(5, 150, Easing.In) + .MoveToOffset(new Vector2(0, 3), 150, Easing.In); + } + + this.FadeInFromZero(200, Easing.OutQuint); + return; + } + + flash.Colour = OsuColour.ForRank(rank); + + if (rank >= ScoreRank.S) + rankText.ScaleTo(1.05f).ScaleTo(1, 3000, Easing.OutQuint); + + if (rank >= ScoreRank.X) + { + flash.FadeOutFromOne(3000); + superFlash.FadeOutFromOne(800, Easing.OutQuint); + } + else + { + flash.FadeOutFromOne(1200, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/SmoothCircularProgress.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/SmoothCircularProgress.cs new file mode 100644 index 0000000000..106af31cae --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/SmoothCircularProgress.cs @@ -0,0 +1,126 @@ +// 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. + +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.Transforms; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Accuracy +{ + /// <summary> + /// Contains a <see cref="CircularProgress"/> with smoothened edges. + /// </summary> + public class SmoothCircularProgress : CompositeDrawable + { + public Bindable<double> Current + { + get => progress.Current; + set => progress.Current = value; + } + + public float InnerRadius + { + get => progress.InnerRadius; + set + { + progress.InnerRadius = value; + innerSmoothingContainer.Size = new Vector2(1 - value); + smoothingWedge.Height = value / 2; + } + } + + private readonly CircularProgress progress; + private readonly Container innerSmoothingContainer; + private readonly Drawable smoothingWedge; + + public SmoothCircularProgress() + { + Container smoothingWedgeContainer; + + InternalChild = new BufferedContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + progress = new CircularProgress { RelativeSizeAxes = Axes.Both }, + smoothingWedgeContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Child = smoothingWedge = new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Y, + Width = 1f, + EdgeSmoothness = new Vector2(2, 0), + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(-1), + Child = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + BorderThickness = 2, + Masking = true, + BorderColour = OsuColour.Gray(0.5f).Opacity(0.75f), + Blending = new BlendingParameters + { + AlphaEquation = BlendingEquation.ReverseSubtract, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + }, + innerSmoothingContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = Vector2.Zero, + Padding = new MarginPadding(-1), + Child = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + BorderThickness = 2, + BorderColour = OsuColour.Gray(0.5f).Opacity(0.75f), + Masking = true, + Blending = new BlendingParameters + { + AlphaEquation = BlendingEquation.ReverseSubtract, + }, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + }, + } + }; + + Current.BindValueChanged(c => + { + smoothingWedgeContainer.Alpha = c.NewValue > 0 ? 1 : 0; + smoothingWedgeContainer.Rotation = (float)(360 * c.NewValue); + }, true); + } + + public TransformSequence<CircularProgress> FillTo(double newValue, double duration = 0, Easing easing = Easing.None) + => progress.FillTo(newValue, duration, easing); + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs new file mode 100644 index 0000000000..ff6203bc25 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -0,0 +1,281 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osu.Game.Screens.Ranking.Expanded.Statistics; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded +{ + /// <summary> + /// The content that appears in the middle section of the <see cref="ScorePanel"/>. + /// </summary> + public class ExpandedPanelMiddleContent : CompositeDrawable + { + private const float padding = 10; + + private readonly ScoreInfo score; + private readonly bool withFlair; + + private readonly List<StatisticDisplay> statisticDisplays = new List<StatisticDisplay>(); + + private FillFlowContainer starAndModDisplay; + private RollingCounter<long> scoreCounter; + + [Resolved] + private ScoreManager scoreManager { get; set; } + + /// <summary> + /// Creates a new <see cref="ExpandedPanelMiddleContent"/>. + /// </summary> + /// <param name="score">The score to display.</param> + /// <param name="withFlair">Whether to add flair for a new score being set.</param> + public ExpandedPanelMiddleContent(ScoreInfo score, bool withFlair = false) + { + this.score = score; + this.withFlair = withFlair; + + RelativeSizeAxes = Axes.Both; + Masking = true; + + Padding = new MarginPadding(padding); + } + + [BackgroundDependencyLoader] + private void load(BeatmapDifficultyCache beatmapDifficultyCache) + { + var beatmap = score.Beatmap; + var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata; + var creator = metadata.Author?.Username; + + var topStatistics = new List<StatisticDisplay> + { + new AccuracyStatistic(score.Accuracy), + new ComboStatistic(score.MaxCombo, !score.Statistics.TryGetValue(HitResult.Miss, out var missCount) || missCount == 0), + new PerformanceStatistic(score), + }; + + var bottomStatistics = new List<HitResultStatistic>(); + + foreach (var result in score.GetStatisticsForDisplay()) + bottomStatistics.Add(new HitResultStatistic(result)); + + statisticDisplays.AddRange(topStatistics); + statisticDisplays.AddRange(bottomStatistics); + + var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).Result; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), + Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Top = 40 }, + RelativeSizeAxes = Axes.X, + Height = 230, + Child = new AccuracyCircle(score, withFlair) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + } + }, + scoreCounter = new TotalScoreCounter + { + Margin = new MarginPadding { Top = 0, Bottom = 5 }, + Current = { Value = 0 }, + Alpha = 0, + AlwaysPresent = true + }, + starAndModDisplay = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new StarRatingDisplay(starDifficulty) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + } + }, + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = beatmap.Version, + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), + }, + new OsuTextFlowContainer(s => s.Font = OsuFont.Torus.With(size: 12)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }.With(t => + { + if (!string.IsNullOrEmpty(creator)) + { + t.AddText("mapped by "); + t.AddText(creator, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + } + }) + } + }, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { topStatistics.Cast<Drawable>().ToArray() }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { bottomStatistics.Where(s => s.Result <= HitResult.Perfect).ToArray() }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { bottomStatistics.Where(s => s.Result > HitResult.Perfect).ToArray() }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + } + } + } + } + } + }, + new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), + Text = $"Played on {score.Date.ToLocalTime():d MMMM yyyy HH:mm}" + } + }; + + if (score.Mods.Any()) + { + starAndModDisplay.Add(new ModDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + DisplayUnrankedText = false, + ExpansionMode = ExpansionMode.AlwaysExpanded, + Scale = new Vector2(0.5f), + Current = { Value = score.Mods } + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Score counter value setting must be scheduled so it isn't transferred instantaneously + ScheduleAfterChildren(() => + { + using (BeginDelayedSequence(AccuracyCircle.ACCURACY_TRANSFORM_DELAY, true)) + { + scoreCounter.FadeIn(); + scoreCounter.Current = scoreManager.GetBindableTotalScore(score); + + double delay = 0; + + foreach (var stat in statisticDisplays) + { + using (BeginDelayedSequence(delay, true)) + stat.Appear(); + + delay += 200; + } + } + + if (!withFlair) + FinishTransforms(true); + }); + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs new file mode 100644 index 0000000000..5dfc43cc29 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelTopContent.cs @@ -0,0 +1,64 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Users; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded +{ + /// <summary> + /// The content that appears in the middle section of the <see cref="ScorePanel"/>. + /// </summary> + public class ExpandedPanelTopContent : CompositeDrawable + { + private readonly User user; + + /// <summary> + /// Creates a new <see cref="ExpandedPanelTopContent"/>. + /// </summary> + /// <param name="user">The <see cref="User"/> to display.</param> + public ExpandedPanelTopContent(User user) + { + this.user = user; + Anchor = Anchor.TopCentre; + Origin = Anchor.Centre; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new UpdateableAvatar(user) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(80), + CornerRadius = 20, + CornerExponent = 2.5f, + Masking = true, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = user.Username, + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold) + } + } + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs b/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs new file mode 100644 index 0000000000..f7e50fdc8a --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs @@ -0,0 +1,109 @@ +// 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. + +using System.Globalization; +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.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Ranking.Expanded +{ + /// <summary> + /// A pill that displays the star rating of a <see cref="BeatmapInfo"/>. + /// </summary> + public class StarRatingDisplay : CompositeDrawable + { + private readonly StarDifficulty difficulty; + + /// <summary> + /// Creates a new <see cref="StarRatingDisplay"/> using an already computed <see cref="StarDifficulty"/>. + /// </summary> + /// <param name="starDifficulty">The already computed <see cref="StarDifficulty"/> to display the star difficulty of.</param> + public StarRatingDisplay(StarDifficulty starDifficulty) + { + difficulty = starDifficulty; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, BeatmapDifficultyCache difficultyCache) + { + 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 + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = backgroundColour + }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 8, Vertical = 4 }, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2, 0), + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Size = new Vector2(7), + Icon = FontAwesome.Solid.Star, + Colour = Color4.Black + }, + 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; + }); + }) + } + } + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs new file mode 100644 index 0000000000..288a107874 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs @@ -0,0 +1,56 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + /// <summary> + /// A <see cref="StatisticDisplay"/> to display the player's accuracy. + /// </summary> + public class AccuracyStatistic : StatisticDisplay + { + private readonly double accuracy; + + private RollingCounter<double> counter; + + /// <summary> + /// Creates a new <see cref="AccuracyStatistic"/>. + /// </summary> + /// <param name="accuracy">The accuracy to display.</param> + public AccuracyStatistic(double accuracy) + : base("accuracy") + { + this.accuracy = accuracy; + } + + public override void Appear() + { + base.Appear(); + counter.Current.Value = accuracy; + } + + protected override Drawable CreateContent() => counter = new Counter(); + + private class Counter : RollingCounter<double> + { + protected override double RollingDuration => AccuracyCircle.ACCURACY_TRANSFORM_DURATION; + + protected override Easing RollingEasing => AccuracyCircle.ACCURACY_TRANSFORM_EASING; + + protected override string FormatCount(double count) => count.FormatAccuracy(); + + protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => + { + s.Font = OsuFont.Torus.With(size: 20, fixedWidth: true); + s.Spacing = new Vector2(-2, 0); + }); + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs new file mode 100644 index 0000000000..e13138c5a0 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs @@ -0,0 +1,71 @@ +// 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. + +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + /// <summary> + /// A <see cref="StatisticDisplay"/> to display the player's combo. + /// </summary> + public class ComboStatistic : CounterStatistic + { + private readonly bool isPerfect; + + private Drawable perfectText; + + /// <summary> + /// Creates a new <see cref="ComboStatistic"/>. + /// </summary> + /// <param name="combo">The combo to be displayed.</param> + /// <param name="isPerfect">Whether this is a perfect combo.</param> + public ComboStatistic(int combo, bool isPerfect) + : base("combo", combo) + { + this.isPerfect = isPerfect; + } + + public override void Appear() + { + base.Appear(); + + if (isPerfect) + { + using (BeginDelayedSequence(AccuracyCircle.ACCURACY_TRANSFORM_DURATION / 2, true)) + perfectText.FadeIn(50); + } + } + + protected override Drawable CreateContent() => new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new[] + { + base.CreateContent().With(d => + { + Anchor = Anchor.CentreLeft; + Origin = Anchor.CentreLeft; + }), + perfectText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = "PERFECT", + Font = OsuFont.Torus.With(size: 11, weight: FontWeight.SemiBold), + Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#66FFCC"), Color4Extensions.FromHex("#FF9AD7")), + Alpha = 0, + UseFullGlyphHeight = false, + } + } + }; + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs new file mode 100644 index 0000000000..d37f6c5e5f --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/CounterStatistic.cs @@ -0,0 +1,70 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + /// <summary> + /// A <see cref="StatisticDisplay"/> to display general numeric values. + /// </summary> + public class CounterStatistic : StatisticDisplay + { + private readonly int count; + private readonly int? maxCount; + + private RollingCounter<int> counter; + + /// <summary> + /// Creates a new <see cref="CounterStatistic"/>. + /// </summary> + /// <param name="header">The name of the statistic.</param> + /// <param name="count">The value to display.</param> + /// <param name="maxCount">The maximum value of <paramref name="count"/>. Not displayed if null.</param> + public CounterStatistic(string header, int count, int? maxCount = null) + : base(header) + { + this.count = count; + this.maxCount = maxCount; + } + + public override void Appear() + { + base.Appear(); + counter.Current.Value = count; + } + + protected override Drawable CreateContent() + { + var container = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Child = counter = new StatisticCounter + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + } + }; + + if (maxCount != null) + { + container.Add(new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Font = OsuFont.Torus.With(size: 12, fixedWidth: true), + Spacing = new Vector2(-2, 0), + Text = $"/{maxCount}" + }); + } + + return container; + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs new file mode 100644 index 0000000000..ada8dfabf0 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs @@ -0,0 +1,27 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Game.Graphics; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + public class HitResultStatistic : CounterStatistic + { + public readonly HitResult Result; + + public HitResultStatistic(HitResultDisplayStatistic result) + : base(result.DisplayName, result.Count, result.MaxCount) + { + Result = result.Result; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + HeaderText.Colour = colours.ForHitResult(Result); + } + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs new file mode 100644 index 0000000000..68da4ec724 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs @@ -0,0 +1,68 @@ +// 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. + +using System; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + public class PerformanceStatistic : StatisticDisplay + { + private readonly ScoreInfo score; + + private readonly Bindable<int> performance = new Bindable<int>(); + + private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + + private RollingCounter<int> counter; + + public PerformanceStatistic(ScoreInfo score) + : base("PP") + { + this.score = score; + } + + [BackgroundDependencyLoader] + private void load(ScorePerformanceCache performanceCache) + { + if (score.PP.HasValue) + { + setPerformanceValue(score.PP.Value); + } + else + { + performanceCache.CalculatePerformanceAsync(score, cancellationTokenSource.Token) + .ContinueWith(t => Schedule(() => setPerformanceValue(t.Result)), cancellationTokenSource.Token); + } + } + + private void setPerformanceValue(double? pp) + { + if (pp.HasValue) + performance.Value = (int)Math.Round(pp.Value, MidpointRounding.AwayFromZero); + } + + public override void Appear() + { + base.Appear(); + counter.Current.BindTo(performance); + } + + protected override void Dispose(bool isDisposing) + { + cancellationTokenSource?.Cancel(); + base.Dispose(isDisposing); + } + + protected override Drawable CreateContent() => counter = new StatisticCounter + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }; + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticCounter.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticCounter.cs new file mode 100644 index 0000000000..bbcfc43dc8 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticCounter.cs @@ -0,0 +1,25 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + public class StatisticCounter : RollingCounter<int> + { + protected override double RollingDuration => AccuracyCircle.ACCURACY_TRANSFORM_DURATION; + + protected override Easing RollingEasing => AccuracyCircle.ACCURACY_TRANSFORM_EASING; + + protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => + { + s.Font = OsuFont.Torus.With(size: 20, fixedWidth: true); + s.Spacing = new Vector2(-2, 0); + }); + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs new file mode 100644 index 0000000000..9206c58bc9 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/StatisticDisplay.cs @@ -0,0 +1,97 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Ranking.Expanded.Statistics +{ + /// <summary> + /// A statistic from the score to be displayed in the <see cref="ExpandedPanelMiddleContent"/>. + /// </summary> + public abstract class StatisticDisplay : CompositeDrawable + { + protected SpriteText HeaderText { get; private set; } + + private readonly string header; + private Drawable content; + + /// <summary> + /// Creates a new <see cref="StatisticDisplay"/>. + /// </summary> + /// <param name="header">The name of the statistic.</param> + protected StatisticDisplay(string header) + { + this.header = header; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new[] + { + new CircularContainer + { + RelativeSizeAxes = Axes.X, + Height = 12, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#222") + }, + HeaderText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold), + Text = header.ToUpperInvariant(), + } + } + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Children = new[] + { + content = CreateContent().With(d => + { + d.Anchor = Anchor.TopCentre; + d.Origin = Anchor.TopCentre; + d.Alpha = 0; + d.AlwaysPresent = true; + }), + } + } + } + }; + } + + /// <summary> + /// Shows the statistic value. + /// </summary> + public virtual void Appear() => content.FadeIn(100); + + /// <summary> + /// Creates the content for this <see cref="StatisticDisplay"/>. + /// </summary> + protected abstract Drawable CreateContent(); + } +} diff --git a/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs new file mode 100644 index 0000000000..65082d3fae --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs @@ -0,0 +1,40 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Ranking.Expanded.Accuracy; +using osuTK; + +namespace osu.Game.Screens.Ranking.Expanded +{ + /// <summary> + /// A counter for the player's total score to be displayed in the <see cref="ExpandedPanelMiddleContent"/>. + /// </summary> + public class TotalScoreCounter : RollingCounter<long> + { + protected override double RollingDuration => AccuracyCircle.ACCURACY_TRANSFORM_DURATION; + + protected override Easing RollingEasing => AccuracyCircle.ACCURACY_TRANSFORM_EASING; + + public TotalScoreCounter() + { + // Todo: AutoSize X removed here due to https://github.com/ppy/osu-framework/issues/3369 + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + } + + protected override string FormatCount(long count) => count.ToString("N0"); + + protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => + { + s.Anchor = Anchor.TopCentre; + s.Origin = Anchor.TopCentre; + + s.Font = OsuFont.Torus.With(size: 60, weight: FontWeight.Light, fixedWidth: true); + s.Spacing = new Vector2(-5, 0); + }); + } +} diff --git a/osu.Game/Screens/Ranking/Pages/LocalLeaderboardPage.cs b/osu.Game/Screens/Ranking/Pages/LocalLeaderboardPage.cs deleted file mode 100644 index c997dd6d30..0000000000 --- a/osu.Game/Screens/Ranking/Pages/LocalLeaderboardPage.cs +++ /dev/null @@ -1,43 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Scoring; -using osu.Game.Screens.Select.Leaderboards; -using osuTK; - -namespace osu.Game.Screens.Ranking.Pages -{ - public class LocalLeaderboardPage : ResultsPage - { - public LocalLeaderboardPage(ScoreInfo score, WorkingBeatmap beatmap = null) - : base(score, beatmap) - { - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Children = new Drawable[] - { - new Box - { - Colour = colours.Gray6, - RelativeSizeAxes = Axes.Both, - }, - new BeatmapLeaderboard - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Beatmap = Beatmap.BeatmapInfo ?? Score.Beatmap, - Scale = new Vector2(0.7f) - } - }; - } - } -} diff --git a/osu.Game/Screens/Ranking/Pages/ScoreResultsPage.cs b/osu.Game/Screens/Ranking/Pages/ScoreResultsPage.cs deleted file mode 100644 index 7c35742ff6..0000000000 --- a/osu.Game/Screens/Ranking/Pages/ScoreResultsPage.cs +++ /dev/null @@ -1,415 +0,0 @@ -// 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. - -using System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Extensions; -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.Textures; -using osu.Framework.Localisation; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.Leaderboards; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Screens.Play; -using osu.Game.Users; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Ranking.Pages -{ - public class ScoreResultsPage : ResultsPage - { - private Container scoreContainer; - private ScoreCounter scoreCounter; - - private readonly ScoreInfo score; - - public ScoreResultsPage(ScoreInfo score, WorkingBeatmap beatmap) - : base(score, beatmap) - { - this.score = score; - } - - private FillFlowContainer<DrawableScoreStatistic> statisticsContainer; - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - const float user_header_height = 120; - - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = user_header_height }, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - } - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new UserHeader(Score.User) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Height = user_header_height, - }, - new UpdateableRank(Score.Rank) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Size = new Vector2(150, 60), - Margin = new MarginPadding(20), - }, - scoreContainer = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - Height = 60, - Children = new Drawable[] - { - new SongProgressGraph - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.5f, - Objects = Beatmap.Beatmap.HitObjects, - }, - scoreCounter = new SlowScoreCounter(6) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = colours.PinkDarker, - Y = 10, - TextSize = 56, - }, - } - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Colour = colours.PinkDarker, - Shadow = false, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Text = "total score", - Margin = new MarginPadding { Bottom = 15 }, - }, - new BeatmapDetails(Beatmap.BeatmapInfo) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding { Bottom = 10 }, - }, - new DateTimeDisplay(Score.Date.LocalDateTime) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - new Container - { - RelativeSizeAxes = Axes.X, - Size = new Vector2(0.75f, 1), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding { Top = 10, Bottom = 10 }, - Children = new Drawable[] - { - new Box - { - Colour = ColourInfo.GradientHorizontal( - colours.GrayC.Opacity(0), - colours.GrayC.Opacity(0.9f)), - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.5f, 1), - }, - new Box - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = ColourInfo.GradientHorizontal( - colours.GrayC.Opacity(0.9f), - colours.GrayC.Opacity(0)), - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.5f, 1), - }, - } - }, - statisticsContainer = new FillFlowContainer<DrawableScoreStatistic> - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Direction = FillDirection.Horizontal, - LayoutDuration = 200, - LayoutEasing = Easing.OutQuint - }, - }, - }, - new ReplayDownloadButton(score) - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Margin = new MarginPadding { Bottom = 10 }, - Size = new Vector2(50, 30), - }, - }; - - statisticsContainer.ChildrenEnumerable = Score.Statistics.OrderByDescending(p => p.Key).Select(s => new DrawableScoreStatistic(s)); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Schedule(() => - { - scoreCounter.Increment(Score.TotalScore); - - int delay = 0; - - foreach (var s in statisticsContainer.Children) - { - s.FadeOut() - .Then(delay += 200) - .FadeIn(300 + delay, Easing.Out); - } - }); - } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - scoreCounter.Scale = new Vector2(Math.Min(1f, (scoreContainer.DrawWidth - 20) / scoreCounter.DrawWidth)); - } - - private class DrawableScoreStatistic : Container - { - private readonly KeyValuePair<HitResult, int> statistic; - - public DrawableScoreStatistic(KeyValuePair<HitResult, int> statistic) - { - this.statistic = statistic; - - AutoSizeAxes = Axes.Both; - Margin = new MarginPadding { Left = 5, Right = 5 }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Children = new Drawable[] - { - new OsuSpriteText - { - Text = statistic.Value.ToString().PadLeft(4, '0'), - Colour = colours.Gray7, - Font = OsuFont.GetFont(size: 30), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - new OsuSpriteText - { - Text = statistic.Key.GetDescription(), - Colour = colours.Gray7, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - Y = 26, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - }; - } - } - - private class DateTimeDisplay : Container - { - private readonly DateTime date; - - public DateTimeDisplay(DateTime date) - { - this.date = date; - - AutoSizeAxes = Axes.Y; - - Width = 140; - - Masking = true; - CornerRadius = 5; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colours.Gray6, - }, - new OsuSpriteText - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Text = date.ToShortDateString(), - Padding = new MarginPadding { Horizontal = 10, Vertical = 5 }, - Colour = Color4.White, - }, - new OsuSpriteText - { - Origin = Anchor.CentreRight, - Anchor = Anchor.CentreRight, - Text = date.ToShortTimeString(), - Padding = new MarginPadding { Horizontal = 10, Vertical = 5 }, - Colour = Color4.White, - } - }; - } - } - - private class BeatmapDetails : Container - { - private readonly BeatmapInfo beatmap; - - private readonly OsuSpriteText title; - private readonly OsuSpriteText artist; - private readonly OsuSpriteText versionMapper; - - public BeatmapDetails(BeatmapInfo beatmap) - { - this.beatmap = beatmap; - - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] - { - new FillFlowContainer - { - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - title = new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Shadow = false, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 24, italics: true), - }, - artist = new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Shadow = false, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 20, italics: true), - }, - versionMapper = new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Shadow = false, - Font = OsuFont.GetFont(weight: FontWeight.Bold), - }, - } - } - }; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - title.Colour = artist.Colour = colours.BlueDarker; - versionMapper.Colour = colours.Gray8; - - var creator = beatmap.Metadata.Author?.Username; - - if (!string.IsNullOrEmpty(creator)) - { - versionMapper.Text = $"mapped by {creator}"; - - if (!string.IsNullOrEmpty(beatmap.Version)) - versionMapper.Text = $"{beatmap.Version} - " + versionMapper.Text; - } - - title.Text = new LocalisedString((beatmap.Metadata.TitleUnicode, beatmap.Metadata.Title)); - artist.Text = new LocalisedString((beatmap.Metadata.ArtistUnicode, beatmap.Metadata.Artist)); - } - } - - private class UserHeader : Container - { - private readonly User user; - private readonly Sprite cover; - - public UserHeader(User user) - { - this.user = user; - Children = new Drawable[] - { - cover = new Sprite - { - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fill, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new OsuSpriteText - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Text = user.Username, - Font = OsuFont.GetFont(size: 30, weight: FontWeight.Regular, italics: true), - Padding = new MarginPadding { Bottom = 10 }, - } - }; - } - - [BackgroundDependencyLoader] - private void load(LargeTextureStore textures) - { - if (!string.IsNullOrEmpty(user.CoverUrl)) - cover.Texture = textures.Get(user.CoverUrl); - } - } - - private class SlowScoreCounter : ScoreCounter - { - protected override double RollingDuration => 3000; - - protected override Easing RollingEasing => Easing.OutPow10; - - public SlowScoreCounter(uint leading = 0) - : base(leading) - { - DisplayedCountSpriteText.Shadow = false; - DisplayedCountSpriteText.Font = DisplayedCountSpriteText.Font.With(Typeface.Venera, weight: FontWeight.Light); - UseCommaSeparator = true; - } - } - } -} diff --git a/osu.Game/Screens/Ranking/IResultPageInfo.cs b/osu.Game/Screens/Ranking/PanelState.cs similarity index 53% rename from osu.Game/Screens/Ranking/IResultPageInfo.cs rename to osu.Game/Screens/Ranking/PanelState.cs index cc86e7441a..94e2c7cef4 100644 --- a/osu.Game/Screens/Ranking/IResultPageInfo.cs +++ b/osu.Game/Screens/Ranking/PanelState.cs @@ -1,16 +1,11 @@ // 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. -using osu.Framework.Graphics.Sprites; - namespace osu.Game.Screens.Ranking { - public interface IResultPageInfo + public enum PanelState { - IconUsage Icon { get; } - - string Name { get; } - - ResultsPage CreatePage(); + Expanded, + Contracted } } diff --git a/osu.Game/Screens/Play/ReplayDownloadButton.cs b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs similarity index 67% rename from osu.Game/Screens/Play/ReplayDownloadButton.cs rename to osu.Game/Screens/Ranking/ReplayDownloadButton.cs index 290e00f287..b76842f405 100644 --- a/osu.Game/Screens/Play/ReplayDownloadButton.cs +++ b/osu.Game/Screens/Ranking/ReplayDownloadButton.cs @@ -2,17 +2,20 @@ // 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.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Scoring; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Graphics.UserInterface; +using osuTK; -namespace osu.Game.Screens.Play +namespace osu.Game.Screens.Ranking { public class ReplayDownloadButton : DownloadTrackingComposite<ScoreInfo, ScoreManager> { + public Bindable<ScoreInfo> Score => Model; + private DownloadButton button; private ShakeContainer shakeContainer; @@ -23,7 +26,7 @@ namespace osu.Game.Screens.Play if (State.Value == DownloadState.LocallyAvailable) return ReplayAvailability.Local; - if (Model.Value is APILegacyScoreInfo apiScore && apiScore.Replay) + if (!string.IsNullOrEmpty(Model.Value?.Hash)) return ReplayAvailability.Online; return ReplayAvailability.NotAvailable; @@ -33,6 +36,7 @@ namespace osu.Game.Screens.Play public ReplayDownloadButton(ScoreInfo score) : base(score) { + Size = new Vector2(50, 30); } [BackgroundDependencyLoader(true)] @@ -52,7 +56,7 @@ namespace osu.Game.Screens.Play switch (State.Value) { case DownloadState.LocallyAvailable: - game?.PresentScore(Model.Value); + game?.PresentScore(Model.Value, ScorePresentType.Gameplay); break; case DownloadState.NotDownloaded: @@ -70,23 +74,33 @@ namespace osu.Game.Screens.Play { button.State.Value = state.NewValue; - switch (replayAvailability) - { - case ReplayAvailability.Local: - button.TooltipText = @"Watch replay"; - break; - - case ReplayAvailability.Online: - button.TooltipText = @"Download replay"; - break; - - default: - button.TooltipText = @"Replay unavailable"; - break; - } + updateTooltip(); }, true); - button.Enabled.Value = replayAvailability != ReplayAvailability.NotAvailable; + Model.BindValueChanged(_ => + { + button.Enabled.Value = replayAvailability != ReplayAvailability.NotAvailable; + + updateTooltip(); + }, true); + } + + private void updateTooltip() + { + switch (replayAvailability) + { + case ReplayAvailability.Local: + button.TooltipText = @"watch replay"; + break; + + case ReplayAvailability.Online: + button.TooltipText = @"download replay"; + break; + + default: + button.TooltipText = @"replay unavailable"; + break; + } } private enum ReplayAvailability diff --git a/osu.Game/Screens/Ranking/ResultModeButton.cs b/osu.Game/Screens/Ranking/ResultModeButton.cs deleted file mode 100644 index 1383511241..0000000000 --- a/osu.Game/Screens/Ranking/ResultModeButton.cs +++ /dev/null @@ -1,95 +0,0 @@ -// 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. - -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osuTK; -using osuTK.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; - -namespace osu.Game.Screens.Ranking -{ - public class ResultModeButton : TabItem<IResultPageInfo>, IHasTooltip - { - private readonly IconUsage icon; - private Color4 activeColour; - private Color4 inactiveColour; - private CircularContainer colouredPart; - - public ResultModeButton(IResultPageInfo mode) - : base(mode) - { - icon = mode.Icon; - TooltipText = mode.Name; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Size = new Vector2(50); - - Masking = true; - CornerRadius = 25; - - activeColour = colours.PinkDarker; - inactiveColour = OsuColour.Gray(0.8f); - - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.4f), - Type = EdgeEffectType.Shadow, - Radius = 5, - }; - - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - colouredPart = new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(0.8f), - BorderThickness = 4, - BorderColour = Color4.White, - Colour = inactiveColour, - Children = new Drawable[] - { - new Box - { - AlwaysPresent = true, //for border rendering - RelativeSizeAxes = Axes.Both, - Colour = Color4.Transparent, - }, - new SpriteIcon - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shadow = false, - Colour = OsuColour.Gray(0.95f), - Icon = icon, - Size = new Vector2(20), - } - } - } - }; - } - - protected override void OnActivated() => colouredPart.FadeColour(activeColour, 200, Easing.OutQuint); - - protected override void OnDeactivated() => colouredPart.FadeColour(inactiveColour, 200, Easing.OutQuint); - - public string TooltipText { get; private set; } - } -} diff --git a/osu.Game/Screens/Ranking/ResultModeTabControl.cs b/osu.Game/Screens/Ranking/ResultModeTabControl.cs deleted file mode 100644 index b0d94a4be6..0000000000 --- a/osu.Game/Screens/Ranking/ResultModeTabControl.cs +++ /dev/null @@ -1,30 +0,0 @@ -// 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. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.UserInterface; -using osuTK; - -namespace osu.Game.Screens.Ranking -{ - public class ResultModeTabControl : TabControl<IResultPageInfo> - { - public ResultModeTabControl() - { - TabContainer.Anchor = Anchor.BottomCentre; - TabContainer.Origin = Anchor.BottomCentre; - TabContainer.Spacing = new Vector2(15); - - TabContainer.Masking = false; - TabContainer.Padding = new MarginPadding(5); - } - - protected override Dropdown<IResultPageInfo> CreateDropdown() => null; - - protected override TabItem<IResultPageInfo> CreateTabItem(IResultPageInfo value) => new ResultModeButton(value) - { - Anchor = TabContainer.Anchor, - Origin = TabContainer.Origin - }; - } -} diff --git a/osu.Game/Screens/Ranking/Results.cs b/osu.Game/Screens/Ranking/Results.cs deleted file mode 100644 index cac26b3dbf..0000000000 --- a/osu.Game/Screens/Ranking/Results.cs +++ /dev/null @@ -1,280 +0,0 @@ -// 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. - -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Screens; -using osu.Game.Graphics.Containers; -using osu.Game.Screens.Backgrounds; -using osuTK; -using osuTK.Graphics; -using osu.Game.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics.Sprites; -using osu.Game.Scoring; - -namespace osu.Game.Screens.Ranking -{ - public abstract class Results : OsuScreen - { - protected const float BACKGROUND_BLUR = 20; - - private Container circleOuterBackground; - private Container circleOuter; - private Container circleInner; - - private ParallaxContainer backgroundParallax; - - private ResultModeTabControl modeChangeButtons; - - public override bool DisallowExternalBeatmapRulesetChanges => true; - - protected readonly ScoreInfo Score; - - private Container currentPage; - - protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(Beatmap.Value); - - private const float overscan = 1.3f; - - private const float circle_outer_scale = 0.96f; - - protected Results(ScoreInfo score) - { - Score = score; - } - - private const float transition_time = 800; - - private IEnumerable<Drawable> allCircles => new Drawable[] { circleOuterBackground, circleInner, circleOuter }; - - public override void OnEntering(IScreen last) - { - base.OnEntering(last); - ((BackgroundScreenBeatmap)Background).BlurAmount.Value = BACKGROUND_BLUR; - Background.ScaleTo(1.1f, transition_time, Easing.OutQuint); - - allCircles.ForEach(c => - { - c.FadeOut(); - c.ScaleTo(0); - }); - - backgroundParallax.FadeOut(); - modeChangeButtons.FadeOut(); - currentPage?.FadeOut(); - - circleOuterBackground - .FadeIn(transition_time, Easing.OutQuint) - .ScaleTo(1, transition_time, Easing.OutQuint); - - using (BeginDelayedSequence(transition_time * 0.25f, true)) - { - circleOuter - .FadeIn(transition_time, Easing.OutQuint) - .ScaleTo(1, transition_time, Easing.OutQuint); - - using (BeginDelayedSequence(transition_time * 0.3f, true)) - { - backgroundParallax.FadeIn(transition_time, Easing.OutQuint); - - circleInner - .FadeIn(transition_time, Easing.OutQuint) - .ScaleTo(1, transition_time, Easing.OutQuint); - - using (BeginDelayedSequence(transition_time * 0.4f, true)) - { - modeChangeButtons.FadeIn(transition_time, Easing.OutQuint); - currentPage?.FadeIn(transition_time, Easing.OutQuint); - } - } - } - } - - public override bool OnExiting(IScreen next) - { - allCircles.ForEach(c => - { - c.ScaleTo(0, transition_time, Easing.OutSine); - }); - - Background.ScaleTo(1f, transition_time / 4, Easing.OutQuint); - - this.FadeOut(transition_time / 4); - - return base.OnExiting(next); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - InternalChildren = new Drawable[] - { - new AspectContainer - { - RelativeSizeAxes = Axes.Y, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Height = overscan, - Children = new Drawable[] - { - circleOuterBackground = new CircularContainer - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Masking = true, - Children = new Drawable[] - { - new Box - { - Alpha = 0.2f, - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - } - } - }, - circleOuter = new CircularContainer - { - Size = new Vector2(circle_outer_scale), - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.4f), - Type = EdgeEffectType.Shadow, - Radius = 15, - }, - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - backgroundParallax = new ParallaxContainer - { - RelativeSizeAxes = Axes.Both, - ParallaxAmount = 0.01f, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] - { - new Sprite - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.2f, - Texture = Beatmap.Value.Background, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fill - } - } - }, - modeChangeButtons = new ResultModeTabControl - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - RelativeSizeAxes = Axes.X, - Height = 50, - Margin = new MarginPadding { Bottom = 110 }, - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.BottomCentre, - Text = $"{Score.MaxCombo}x", - RelativePositionAxes = Axes.X, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 40), - X = 0.1f, - Colour = colours.BlueDarker, - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopCentre, - Text = "max combo", - Font = OsuFont.GetFont(size: 20), - RelativePositionAxes = Axes.X, - X = 0.1f, - Colour = colours.Gray6, - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.BottomCentre, - Text = $"{Score.Accuracy:P2}", - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 40), - RelativePositionAxes = Axes.X, - X = 0.9f, - Colour = colours.BlueDarker, - }, - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.TopCentre, - Text = "accuracy", - Font = OsuFont.GetFont(size: 20), - RelativePositionAxes = Axes.X, - X = 0.9f, - Colour = colours.Gray6, - }, - } - }, - circleInner = new CircularContainer - { - Size = new Vector2(0.6f), - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.4f), - Type = EdgeEffectType.Shadow, - Radius = 15, - }, - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.White, - }, - } - } - } - } - }; - - var pages = CreateResultPages(); - - foreach (var p in pages) - modeChangeButtons.AddItem(p); - - modeChangeButtons.Current.Value = pages.FirstOrDefault(); - - modeChangeButtons.Current.BindValueChanged(page => - { - currentPage?.FadeOut(); - currentPage?.Expire(); - - currentPage = page.NewValue?.CreatePage(); - - if (currentPage != null) - LoadComponentAsync(currentPage, circleInner.Add); - }, true); - } - - protected abstract IEnumerable<IResultPageInfo> CreateResultPages(); - } -} diff --git a/osu.Game/Screens/Ranking/ResultsPage.cs b/osu.Game/Screens/Ranking/ResultsPage.cs deleted file mode 100644 index 8776c599dd..0000000000 --- a/osu.Game/Screens/Ranking/ResultsPage.cs +++ /dev/null @@ -1,92 +0,0 @@ -// 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. - -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.Graphics.Shapes; -using osu.Game.Beatmaps; -using osu.Game.Graphics; -using osu.Game.Scoring; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Screens.Ranking -{ - public abstract class ResultsPage : Container - { - protected readonly ScoreInfo Score; - protected readonly WorkingBeatmap Beatmap; - private CircularContainer content; - private Box fill; - - protected override Container<Drawable> Content => content; - - protected ResultsPage(ScoreInfo score, WorkingBeatmap beatmap) - { - Score = score; - Beatmap = beatmap; - RelativeSizeAxes = Axes.Both; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - fill.Delay(400).FadeInFromZero(600); - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AddRangeInternal(new Drawable[] - { - fill = new Box - { - Alpha = 0, - RelativeSizeAxes = Axes.Both, - Colour = colours.Gray6 - }, - new CircularContainer - { - EdgeEffect = new EdgeEffectParameters - { - Colour = colours.GrayF.Opacity(0.8f), - Type = EdgeEffectType.Shadow, - Radius = 1, - }, - RelativeSizeAxes = Axes.Both, - Masking = true, - BorderThickness = 20, - BorderColour = Color4.White, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true - }, - } - }, - content = new CircularContainer - { - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.2f), - Type = EdgeEffectType.Shadow, - Radius = 15, - }, - RelativeSizeAxes = Axes.Both, - Masking = true, - Size = new Vector2(0.88f), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } - }); - } - } -} diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs new file mode 100644 index 0000000000..f8bdf0140c --- /dev/null +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -0,0 +1,353 @@ +// 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. + +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.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Screens; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Online.API; +using osu.Game.Scoring; +using osu.Game.Screens.Backgrounds; +using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; + +namespace osu.Game.Screens.Ranking +{ + public abstract class ResultsScreen : OsuScreen, IKeyBindingHandler<GlobalAction> + { + protected const float BACKGROUND_BLUR = 20; + private static readonly float screen_height = 768 - TwoLayerButton.SIZE_EXTENDED.Y; + + public override bool DisallowExternalBeatmapRulesetChanges => true; + + // Temporary for now to stop dual transitions. Should respect the current toolbar mode, but there's no way to do so currently. + public override bool HideOverlaysOnEnter => true; + + protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(Beatmap.Value); + + public readonly Bindable<ScoreInfo> SelectedScore = new Bindable<ScoreInfo>(); + + public readonly ScoreInfo Score; + + protected ScorePanelList ScorePanelList { get; private set; } + + [Resolved(CanBeNull = true)] + private Player player { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + private StatisticsPanel statisticsPanel; + private Drawable bottomPanel; + private Container<ScorePanel> detachedPanelContainer; + + private bool fetchedInitialScores; + private APIRequest nextPageRequest; + + private readonly bool allowRetry; + + protected ResultsScreen(ScoreInfo score, bool allowRetry = true) + { + Score = score; + this.allowRetry = allowRetry; + + SelectedScore.Value = score; + } + + [BackgroundDependencyLoader] + private void load() + { + FillFlowContainer buttons; + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + new VerticalScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + statisticsPanel = new StatisticsPanel + { + RelativeSizeAxes = Axes.Both, + Score = { BindTarget = SelectedScore } + }, + ScorePanelList = new ScorePanelList + { + RelativeSizeAxes = Axes.Both, + SelectedScore = { BindTarget = SelectedScore }, + PostExpandAction = () => statisticsPanel.ToggleVisibility() + }, + detachedPanelContainer = new Container<ScorePanel> + { + RelativeSizeAxes = Axes.Both + }, + } + } + }, + }, + new[] + { + bottomPanel = new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.X, + Height = TwoLayerButton.SIZE_EXTENDED.Y, + Alpha = 0, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#333") + }, + buttons = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5), + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new ReplayDownloadButton(null) + { + Score = { BindTarget = SelectedScore }, + Width = 300 + }, + } + } + } + } + } + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + } + }; + + if (Score != null) + ScorePanelList.AddScore(Score, true); + + if (player != null && allowRetry) + { + buttons.Add(new RetryButton { Width = 300 }); + + AddInternal(new HotkeyRetryOverlay + { + Action = () => + { + if (!this.IsCurrentScreen()) return; + + player?.Restart(); + }, + }); + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + var req = FetchScores(fetchScoresCallback); + + if (req != null) + api.Queue(req); + + statisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true); + } + + protected override void Update() + { + base.Update(); + + if (fetchedInitialScores && nextPageRequest == null) + { + if (ScorePanelList.IsScrolledToStart) + nextPageRequest = FetchNextPage(-1, fetchScoresCallback); + else if (ScorePanelList.IsScrolledToEnd) + nextPageRequest = FetchNextPage(1, fetchScoresCallback); + + if (nextPageRequest != null) + { + // Scheduled after children to give the list a chance to update its scroll position and not potentially trigger a second request too early. + nextPageRequest.Success += () => ScheduleAfterChildren(() => nextPageRequest = null); + nextPageRequest.Failure += _ => ScheduleAfterChildren(() => nextPageRequest = null); + + api.Queue(nextPageRequest); + } + } + } + + /// <summary> + /// Performs a fetch/refresh of scores to be displayed. + /// </summary> + /// <param name="scoresCallback">A callback which should be called when fetching is completed. Scheduling is not required.</param> + /// <returns>An <see cref="APIRequest"/> responsible for the fetch operation. This will be queued and performed automatically.</returns> + protected virtual APIRequest FetchScores(Action<IEnumerable<ScoreInfo>> scoresCallback) => null; + + /// <summary> + /// Performs a fetch of the next page of scores. This is invoked every frame until a non-null <see cref="APIRequest"/> is returned. + /// </summary> + /// <param name="direction">The fetch direction. -1 to fetch scores greater than the current start of the list, and 1 to fetch scores lower than the current end of the list.</param> + /// <param name="scoresCallback">A callback which should be called when fetching is completed. Scheduling is not required.</param> + /// <returns>An <see cref="APIRequest"/> responsible for the fetch operation. This will be queued and performed automatically.</returns> + protected virtual APIRequest FetchNextPage(int direction, Action<IEnumerable<ScoreInfo>> scoresCallback) => null; + + private void fetchScoresCallback(IEnumerable<ScoreInfo> scores) => Schedule(() => + { + foreach (var s in scores) + addScore(s); + + fetchedInitialScores = true; + }); + + public override void OnEntering(IScreen last) + { + base.OnEntering(last); + + ((BackgroundScreenBeatmap)Background).BlurAmount.Value = BACKGROUND_BLUR; + + Background.FadeTo(0.5f, 250); + bottomPanel.FadeTo(1, 250); + } + + public override bool OnExiting(IScreen next) + { + Background.FadeTo(1, 250); + + return base.OnExiting(next); + } + + public override bool OnBackButton() + { + if (statisticsPanel.State.Value == Visibility.Visible) + { + statisticsPanel.Hide(); + return true; + } + + return false; + } + + private void addScore(ScoreInfo score) + { + var panel = ScorePanelList.AddScore(score); + + if (detachedPanel != null) + panel.Alpha = 0; + } + + private ScorePanel detachedPanel; + + private void onStatisticsStateChanged(ValueChangedEvent<Visibility> state) + { + if (state.NewValue == Visibility.Visible) + { + // Detach the panel in its original location, and move into the desired location in the local container. + var expandedPanel = ScorePanelList.GetPanelForScore(SelectedScore.Value); + var screenSpacePos = expandedPanel.ScreenSpaceDrawQuad.TopLeft; + + // Detach and move into the local container. + ScorePanelList.Detach(expandedPanel); + detachedPanelContainer.Add(expandedPanel); + + // Move into its original location in the local container first, then to the final location. + var origLocation = detachedPanelContainer.ToLocalSpace(screenSpacePos).X; + expandedPanel.MoveToX(origLocation) + .Then() + .MoveToX(StatisticsPanel.SIDE_PADDING, 150, Easing.OutQuint); + + // Hide contracted panels. + foreach (var contracted in ScorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) + contracted.FadeOut(150, Easing.OutQuint); + ScorePanelList.HandleInput = false; + + // Dim background. + Background.FadeTo(0.1f, 150); + + detachedPanel = expandedPanel; + } + else if (detachedPanel != null) + { + var screenSpacePos = detachedPanel.ScreenSpaceDrawQuad.TopLeft; + + // Remove from the local container and re-attach. + detachedPanelContainer.Remove(detachedPanel); + ScorePanelList.Attach(detachedPanel); + + // Move into its original location in the attached container first, then to the final location. + var origLocation = detachedPanel.Parent.ToLocalSpace(screenSpacePos); + detachedPanel.MoveTo(origLocation) + .Then() + .MoveTo(new Vector2(0, origLocation.Y), 150, Easing.OutQuint); + + // Show contracted panels. + foreach (var contracted in ScorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) + contracted.FadeIn(150, Easing.OutQuint); + ScorePanelList.HandleInput = true; + + // Un-dim background. + Background.FadeTo(0.5f, 150); + + detachedPanel = null; + } + } + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.Select: + statisticsPanel.ToggleVisibility(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } + + private class VerticalScrollContainer : OsuScrollContainer + { + protected override Container<Drawable> Content => content; + + private readonly Container content; + + public VerticalScrollContainer() + { + base.Content.Add(content = new Container { RelativeSizeAxes = Axes.X }); + } + + protected override void Update() + { + base.Update(); + content.Height = Math.Max(screen_height, DrawHeight); + } + } + } +} diff --git a/osu.Game/Screens/Ranking/RetryButton.cs b/osu.Game/Screens/Ranking/RetryButton.cs new file mode 100644 index 0000000000..59b69bc949 --- /dev/null +++ b/osu.Game/Screens/Ranking/RetryButton.cs @@ -0,0 +1,54 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Play; +using osuTK; + +namespace osu.Game.Screens.Ranking +{ + public class RetryButton : OsuAnimatedButton + { + private readonly Box background; + + [Resolved(canBeNull: true)] + private Player player { get; set; } + + public RetryButton() + { + Size = new Vector2(50, 30); + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(13), + Icon = FontAwesome.Solid.ArrowCircleLeft, + }, + }; + + TooltipText = "retry"; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + background.Colour = colours.Green; + + if (player != null) + Action = () => player.Restart(); + } + } +} diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs new file mode 100644 index 0000000000..df710e4eb8 --- /dev/null +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -0,0 +1,309 @@ +// 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. + +using System; +using osu.Framework; +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.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Contracted; +using osu.Game.Screens.Ranking.Expanded; +using osu.Game.Users; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Ranking +{ + public class ScorePanel : CompositeDrawable, IStateful<PanelState> + { + /// <summary> + /// Width of the panel when contracted. + /// </summary> + public const float CONTRACTED_WIDTH = 130; + + /// <summary> + /// Height of the panel when contracted. + /// </summary> + private const float contracted_height = 385; + + /// <summary> + /// Width of the panel when expanded. + /// </summary> + public const float EXPANDED_WIDTH = 360; + + /// <summary> + /// Height of the panel when expanded. + /// </summary> + private const float expanded_height = 586; + + /// <summary> + /// Height of the top layer when the panel is expanded. + /// </summary> + private const float expanded_top_layer_height = 53; + + /// <summary> + /// Height of the top layer when the panel is contracted. + /// </summary> + private const float contracted_top_layer_height = 30; + + /// <summary> + /// Duration for the panel to resize into its expanded/contracted size. + /// </summary> + private const double resize_duration = 200; + + /// <summary> + /// Delay after <see cref="resize_duration"/> before the top layer is expanded. + /// </summary> + private const double top_layer_expand_delay = 100; + + /// <summary> + /// Duration for the top layer expansion. + /// </summary> + private const double top_layer_expand_duration = 200; + + /// <summary> + /// Duration for the panel contents to fade in. + /// </summary> + private const double content_fade_duration = 50; + + private static readonly ColourInfo expanded_top_layer_colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#444"), Color4Extensions.FromHex("#333")); + private static readonly ColourInfo expanded_middle_layer_colour = ColourInfo.GradientVertical(Color4Extensions.FromHex("#555"), Color4Extensions.FromHex("#333")); + private static readonly Color4 contracted_top_layer_colour = Color4Extensions.FromHex("#353535"); + private static readonly Color4 contracted_middle_layer_colour = Color4Extensions.FromHex("#353535"); + + public event Action<PanelState> StateChanged; + + /// <summary> + /// An action to be invoked if this <see cref="ScorePanel"/> is clicked while in an expanded state. + /// </summary> + public Action PostExpandAction; + + public readonly ScoreInfo Score; + + private bool displayWithFlair; + + private Container content; + + private Container topLayerContainer; + private Drawable topLayerBackground; + private Container topLayerContentContainer; + private Drawable topLayerContent; + + private Container middleLayerContainer; + private Drawable middleLayerBackground; + private Container middleLayerContentContainer; + private Drawable middleLayerContent; + + public ScorePanel(ScoreInfo score, bool isNewLocalScore = false) + { + Score = score; + displayWithFlair = isNewLocalScore; + } + + [BackgroundDependencyLoader] + private void load() + { + // ScorePanel doesn't include the top extruding area in its own size. + // Adding a manual offset here allows the expanded version to take on an "acceptable" vertical centre when at 100% UI scale. + const float vertical_fudge = 20; + + InternalChild = content = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(40), + Y = vertical_fudge, + Children = new Drawable[] + { + topLayerContainer = new Container + { + Name = "Top layer", + RelativeSizeAxes = Axes.X, + Alpha = 0, + Height = 120, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = 20, + CornerExponent = 2.5f, + Masking = true, + Child = topLayerBackground = new Box { RelativeSizeAxes = Axes.Both } + }, + topLayerContentContainer = new Container { RelativeSizeAxes = Axes.Both } + } + }, + middleLayerContainer = new Container + { + Name = "Middle layer", + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = 20, + CornerExponent = 2.5f, + Masking = true, + Children = new[] + { + middleLayerBackground = new Box { RelativeSizeAxes = Axes.Both }, + new UserCoverBackground + { + RelativeSizeAxes = Axes.Both, + User = Score.User, + Colour = ColourInfo.GradientVertical(Color4.White.Opacity(0.5f), Color4Extensions.FromHex("#444").Opacity(0)) + } + } + }, + middleLayerContentContainer = new Container { RelativeSizeAxes = Axes.Both } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + updateState(); + + topLayerBackground.FinishTransforms(false, nameof(Colour)); + middleLayerBackground.FinishTransforms(false, nameof(Colour)); + } + + private PanelState state = PanelState.Contracted; + + public PanelState State + { + get => state; + set + { + if (state == value) + return; + + state = value; + + if (IsLoaded) + updateState(); + + StateChanged?.Invoke(value); + } + } + + private void updateState() + { + topLayerContent?.FadeOut(content_fade_duration).Expire(); + middleLayerContent?.FadeOut(content_fade_duration).Expire(); + + switch (state) + { + 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); + + topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User).With(d => d.Alpha = 0)); + middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score, displayWithFlair).With(d => d.Alpha = 0)); + + // only the first expanded display should happen with flair. + displayWithFlair = false; + break; + + 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); + + 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); + + 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)) + { + topLayerContainer.FadeIn(); + + switch (state) + { + case PanelState.Expanded: + topLayerContainer.MoveToY(-expanded_top_layer_height / 2, top_layer_expand_duration, Easing.OutQuint); + middleLayerContainer.MoveToY(expanded_top_layer_height / 2, top_layer_expand_duration, Easing.OutQuint); + break; + + case PanelState.Contracted: + topLayerContainer.MoveToY(-contracted_top_layer_height / 2, top_layer_expand_duration, Easing.OutQuint); + middleLayerContainer.MoveToY(contracted_top_layer_height / 2, top_layer_expand_duration, Easing.OutQuint); + break; + } + + topLayerContent?.FadeIn(content_fade_duration); + middleLayerContent?.FadeIn(content_fade_duration); + } + } + + public override Vector2 Size + { + get => base.Size; + set + { + base.Size = value; + + // Auto-size isn't used to avoid 1-frame issues and because the score panel is removed/re-added to the container. + if (trackingContainer != null) + trackingContainer.Size = value; + } + } + + protected override bool OnClick(ClickEvent e) + { + if (State == PanelState.Contracted) + { + State = PanelState.Expanded; + return true; + } + + PostExpandAction?.Invoke(); + + return true; + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + => base.ReceivePositionalInputAt(screenSpacePos) + || topLayerContainer.ReceivePositionalInputAt(screenSpacePos) + || middleLayerContainer.ReceivePositionalInputAt(screenSpacePos); + + private ScorePanelTrackingContainer trackingContainer; + + /// <summary> + /// Creates a <see cref="ScorePanelTrackingContainer"/> which this <see cref="ScorePanel"/> can reside inside. + /// The <see cref="ScorePanelTrackingContainer"/> will track the size of this <see cref="ScorePanel"/>. + /// </summary> + /// <remarks> + /// This <see cref="ScorePanel"/> is immediately added as a child of the <see cref="ScorePanelTrackingContainer"/>. + /// </remarks> + /// <returns>The <see cref="ScorePanelTrackingContainer"/>.</returns> + /// <exception cref="InvalidOperationException">If a <see cref="ScorePanelTrackingContainer"/> already exists.</exception> + public ScorePanelTrackingContainer CreateTrackingContainer() + { + if (trackingContainer != null) + throw new InvalidOperationException("A score panel container has already been created."); + + return trackingContainer = new ScorePanelTrackingContainer(this); + } + } +} diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs new file mode 100644 index 0000000000..77b3d8fc3b --- /dev/null +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -0,0 +1,329 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Screens.Ranking +{ + public class ScorePanelList : CompositeDrawable + { + /// <summary> + /// Normal spacing between all panels. + /// </summary> + private const float panel_spacing = 5; + + /// <summary> + /// Spacing around both sides of the expanded panel. This is added on top of <see cref="panel_spacing"/>. + /// </summary> + private const float expanded_panel_spacing = 15; + + /// <summary> + /// Minimum distance from either end point of the list that the list can be considered scrolled to the end point. + /// </summary> + private const float scroll_endpoint_distance = 100; + + /// <summary> + /// Whether this <see cref="ScorePanelList"/> can be scrolled and is currently scrolled to the start. + /// </summary> + public bool IsScrolledToStart => flow.Count > 0 && scroll.ScrollableExtent > 0 && scroll.Current <= scroll_endpoint_distance; + + /// <summary> + /// Whether this <see cref="ScorePanelList"/> can be scrolled and is currently scrolled to the end. + /// </summary> + public bool IsScrolledToEnd => flow.Count > 0 && scroll.ScrollableExtent > 0 && scroll.IsScrolledToEnd(scroll_endpoint_distance); + + /// <summary> + /// The current scroll position. + /// </summary> + public double Current => scroll.Current; + + /// <summary> + /// The scrollable extent. + /// </summary> + public double ScrollableExtent => scroll.ScrollableExtent; + + /// <summary> + /// An action to be invoked if a <see cref="ScorePanel"/> is clicked while in an expanded state. + /// </summary> + public Action PostExpandAction; + + public readonly Bindable<ScoreInfo> SelectedScore = new Bindable<ScoreInfo>(); + + private readonly Flow flow; + private readonly Scroll scroll; + private ScorePanel expandedPanel; + + /// <summary> + /// Creates a new <see cref="ScorePanelList"/>. + /// </summary> + public ScorePanelList() + { + RelativeSizeAxes = Axes.Both; + + InternalChild = scroll = new Scroll + { + RelativeSizeAxes = Axes.Both, + HandleScroll = () => expandedPanel?.IsHovered != true, // handle horizontal scroll only when not hovering the expanded panel. + Child = flow = new Flow + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(panel_spacing, 0), + AutoSizeAxes = Axes.Both, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedScore.BindValueChanged(selectedScoreChanged, true); + } + + /// <summary> + /// Adds a <see cref="ScoreInfo"/> to this list. + /// </summary> + /// <param name="score">The <see cref="ScoreInfo"/> to add.</param> + /// <param name="isNewLocalScore">Whether this is a score that has just been achieved locally. Controls whether flair is added to the display or not.</param> + public ScorePanel AddScore(ScoreInfo score, bool isNewLocalScore = false) + { + var panel = new ScorePanel(score, isNewLocalScore) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + PostExpandAction = () => PostExpandAction?.Invoke() + }.With(p => + { + p.StateChanged += s => + { + if (s == PanelState.Expanded) + SelectedScore.Value = p.Score; + }; + }); + + flow.Add(panel.CreateTrackingContainer().With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + })); + + if (IsLoaded) + { + if (SelectedScore.Value == score) + { + SelectedScore.TriggerChange(); + } + else + { + // We want the scroll position to remain relative to the expanded panel. When a new panel is added after the expanded panel, nothing needs to be done. + // But when a panel is added before the expanded panel, we need to offset the scroll position by the width of the new panel. + if (expandedPanel != null && flow.GetPanelIndex(score) < flow.GetPanelIndex(expandedPanel.Score)) + { + // A somewhat hacky property is used here because we need to: + // 1) Scroll after the scroll container's visible range is updated. + // 2) Scroll before the scroll container's scroll position is updated. + // Without this, we would have a 1-frame positioning error which looks very jarring. + scroll.InstantScrollTarget = (scroll.InstantScrollTarget ?? scroll.Target) + ScorePanel.CONTRACTED_WIDTH + panel_spacing; + } + } + } + + return panel; + } + + /// <summary> + /// Brings a <see cref="ScoreInfo"/> to the centre of the screen and expands it. + /// </summary> + /// <param name="score">The <see cref="ScoreInfo"/> to present.</param> + private void selectedScoreChanged(ValueChangedEvent<ScoreInfo> score) + { + // avoid contracting panels unnecessarily when TriggerChange is fired manually. + if (score.OldValue != score.NewValue) + { + // Contract the old panel. + foreach (var t in flow.Where(t => t.Panel.Score == score.OldValue)) + { + t.Panel.State = PanelState.Contracted; + t.Margin = new MarginPadding(); + } + } + + // Find the panel corresponding to the new score. + var expandedTrackingComponent = flow.SingleOrDefault(t => t.Panel.Score == score.NewValue); + expandedPanel = expandedTrackingComponent?.Panel; + + if (expandedPanel == null) + return; + + Debug.Assert(expandedTrackingComponent != null); + + // Expand the new panel. + expandedTrackingComponent.Margin = new MarginPadding { Horizontal = expanded_panel_spacing }; + expandedPanel.State = PanelState.Expanded; + + // requires schedule after children to ensure the flow (and thus ScrollContainer's ScrollableExtent) has been updated. + ScheduleAfterChildren(() => + { + // Scroll to the new panel. This is done manually since we need: + // 1) To scroll after the scroll container's visible range is updated. + // 2) To account for the centre anchor/origins of panels. + // In the end, it's easier to compute the scroll position manually. + float scrollOffset = flow.GetPanelIndex(expandedPanel.Score) * (ScorePanel.CONTRACTED_WIDTH + panel_spacing); + scroll.ScrollTo(scrollOffset); + }); + } + + protected override void Update() + { + base.Update(); + + float offset = DrawWidth / 2f; + + // Add padding to both sides such that the centre of an expanded panel on either side is in the middle of the screen. + + if (SelectedScore.Value != null) + { + // The expanded panel has extra padding applied to it, so it needs to be included into the offset. + offset -= ScorePanel.EXPANDED_WIDTH / 2f + expanded_panel_spacing; + } + else + offset -= ScorePanel.CONTRACTED_WIDTH / 2f; + + flow.Padding = new MarginPadding { Horizontal = offset }; + } + + private bool handleInput = true; + + /// <summary> + /// Whether this <see cref="ScorePanelList"/> or any of the <see cref="ScorePanel"/>s contained should handle scroll or click input. + /// Setting to <c>false</c> will also hide the scrollbar. + /// </summary> + public bool HandleInput + { + get => handleInput; + set + { + handleInput = value; + scroll.ScrollbarVisible = value; + } + } + + public override bool PropagatePositionalInputSubTree => HandleInput && base.PropagatePositionalInputSubTree; + + public override bool PropagateNonPositionalInputSubTree => HandleInput && base.PropagateNonPositionalInputSubTree; + + /// <summary> + /// Enumerates all <see cref="ScorePanel"/>s contained in this <see cref="ScorePanelList"/>. + /// </summary> + /// <returns></returns> + public IEnumerable<ScorePanel> GetScorePanels() => flow.Select(t => t.Panel); + + /// <summary> + /// Finds the <see cref="ScorePanel"/> corresponding to a <see cref="ScoreInfo"/>. + /// </summary> + /// <param name="score">The <see cref="ScoreInfo"/> to find the corresponding <see cref="ScorePanel"/> for.</param> + /// <returns>The <see cref="ScorePanel"/>.</returns> + public ScorePanel GetPanelForScore(ScoreInfo score) => flow.Single(t => t.Panel.Score == score).Panel; + + /// <summary> + /// Detaches a <see cref="ScorePanel"/> from its <see cref="ScorePanelTrackingContainer"/>, allowing the panel to be moved elsewhere in the hierarchy. + /// </summary> + /// <param name="panel">The <see cref="ScorePanel"/> to detach.</param> + /// <exception cref="InvalidOperationException">If <paramref name="panel"/> is not a part of this <see cref="ScorePanelList"/>.</exception> + public void Detach(ScorePanel panel) + { + var container = flow.SingleOrDefault(t => t.Panel == panel); + if (container == null) + throw new InvalidOperationException("Panel is not contained by the score panel list."); + + container.Detach(); + } + + /// <summary> + /// Attaches a <see cref="ScorePanel"/> to its <see cref="ScorePanelTrackingContainer"/> in this <see cref="ScorePanelList"/>. + /// </summary> + /// <param name="panel">The <see cref="ScorePanel"/> to attach.</param> + /// <exception cref="InvalidOperationException">If <paramref name="panel"/> is not a part of this <see cref="ScorePanelList"/>.</exception> + public void Attach(ScorePanel panel) + { + var container = flow.SingleOrDefault(t => t.Panel == panel); + if (container == null) + throw new InvalidOperationException("Panel is not contained by the score panel list."); + + container.Attach(); + } + + private class Flow : FillFlowContainer<ScorePanelTrackingContainer> + { + public override IEnumerable<Drawable> FlowingChildren => applySorting(AliveInternalChildren); + + public int GetPanelIndex(ScoreInfo score) => applySorting(Children).TakeWhile(s => s.Panel.Score != score).Count(); + + private IEnumerable<ScorePanelTrackingContainer> applySorting(IEnumerable<Drawable> drawables) => drawables.OfType<ScorePanelTrackingContainer>() + .OrderByDescending(s => s.Panel.Score.TotalScore) + .ThenBy(s => s.Panel.Score.OnlineScoreID); + + protected override int Compare(Drawable x, Drawable y) + { + var tX = (ScorePanelTrackingContainer)x; + var tY = (ScorePanelTrackingContainer)y; + + int result = tY.Panel.Score.TotalScore.CompareTo(tX.Panel.Score.TotalScore); + + if (result != 0) + return result; + + if (tX.Panel.Score.OnlineScoreID == null || tY.Panel.Score.OnlineScoreID == null) + return base.Compare(x, y); + + return tX.Panel.Score.OnlineScoreID.Value.CompareTo(tY.Panel.Score.OnlineScoreID.Value); + } + } + + private class Scroll : OsuScrollContainer + { + public new float Target => base.Target; + + public Scroll() + : base(Direction.Horizontal) + { + } + + /// <summary> + /// The target that will be scrolled to instantaneously next frame. + /// </summary> + public float? InstantScrollTarget; + + /// <summary> + /// Whether this container should handle scroll trigger events. + /// </summary> + public Func<bool> HandleScroll; + + protected override void UpdateAfterChildren() + { + if (InstantScrollTarget != null) + { + ScrollTo(InstantScrollTarget.Value, false); + InstantScrollTarget = null; + } + + base.UpdateAfterChildren(); + } + + public override bool HandlePositionalInput => HandleScroll(); + + public override bool HandleNonPositionalInput => HandleScroll(); + } + } +} diff --git a/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs b/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs new file mode 100644 index 0000000000..c8010d1c32 --- /dev/null +++ b/osu.Game/Screens/Ranking/ScorePanelTrackingContainer.cs @@ -0,0 +1,50 @@ +// 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. + +using System; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Screens.Ranking +{ + /// <summary> + /// A <see cref="CompositeDrawable"/> which tracks the size of a <see cref="ScorePanel"/>, to which the <see cref="ScorePanel"/> can be added or removed. + /// </summary> + public class ScorePanelTrackingContainer : CompositeDrawable + { + /// <summary> + /// The <see cref="ScorePanel"/> that created this <see cref="ScorePanelTrackingContainer"/>. + /// </summary> + public readonly ScorePanel Panel; + + internal ScorePanelTrackingContainer(ScorePanel panel) + { + Panel = panel; + Attach(); + } + + /// <summary> + /// Detaches the <see cref="ScorePanel"/> from this <see cref="ScorePanelTrackingContainer"/>, removing it as a child. + /// This <see cref="ScorePanelTrackingContainer"/> will continue tracking any size changes. + /// </summary> + /// <exception cref="InvalidOperationException">If the <see cref="ScorePanel"/> is already detached.</exception> + public void Detach() + { + if (InternalChildren.Count == 0) + throw new InvalidOperationException("Score panel container is not attached."); + + RemoveInternal(Panel); + } + + /// <summary> + /// Attaches the <see cref="ScorePanel"/> to this <see cref="ScorePanelTrackingContainer"/>, adding it as a child. + /// </summary> + /// <exception cref="InvalidOperationException">If the <see cref="ScorePanel"/> is already attached.</exception> + public void Attach() + { + if (InternalChildren.Count > 0) + throw new InvalidOperationException("Score panel container is already attached."); + + AddInternal(Panel); + } + } +} diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs new file mode 100644 index 0000000000..9cf2e6757a --- /dev/null +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -0,0 +1,36 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Ranking +{ + public class SoloResultsScreen : ResultsScreen + { + [Resolved] + private RulesetStore rulesets { get; set; } + + public SoloResultsScreen(ScoreInfo score, bool allowRetry = true) + : base(score, allowRetry) + { + } + + protected override APIRequest FetchScores(Action<IEnumerable<ScoreInfo>> scoresCallback) + { + 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; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs new file mode 100644 index 0000000000..93885b6e02 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -0,0 +1,173 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// <summary> + /// A graph which displays the distribution of hit timing in a series of <see cref="HitEvent"/>s. + /// </summary> + public class HitEventTimingDistributionGraph : CompositeDrawable + { + /// <summary> + /// The number of bins on each side of the timing distribution. + /// </summary> + private const int timing_distribution_bins = 50; + + /// <summary> + /// The total number of bins in the timing distribution, including bins on both sides and the centre bin at 0. + /// </summary> + private const int total_timing_distribution_bins = timing_distribution_bins * 2 + 1; + + /// <summary> + /// The centre bin, with a timing distribution very close to/at 0. + /// </summary> + private const int timing_distribution_centre_bin_index = timing_distribution_bins; + + /// <summary> + /// The number of data points shown on each side of the axis below the graph. + /// </summary> + private const float axis_points = 5; + + private readonly IReadOnlyList<HitEvent> hitEvents; + + /// <summary> + /// Creates a new <see cref="HitEventTimingDistributionGraph"/>. + /// </summary> + /// <param name="hitEvents">The <see cref="HitEvent"/>s to display the timing distribution of.</param> + public HitEventTimingDistributionGraph(IReadOnlyList<HitEvent> hitEvents) + { + this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit()).ToList(); + } + + [BackgroundDependencyLoader] + private void load() + { + if (hitEvents == null || hitEvents.Count == 0) + return; + + int[] bins = new int[total_timing_distribution_bins]; + + double binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins); + + // Prevent div-by-0 by enforcing a minimum bin size + binSize = Math.Max(1, binSize); + + foreach (var e in hitEvents) + { + int binOffset = (int)Math.Round(e.TimeOffset / binSize, MidpointRounding.AwayFromZero); + bins[timing_distribution_centre_bin_index + binOffset]++; + } + + int maxCount = bins.Max(); + var bars = new Drawable[total_timing_distribution_bins]; + for (int i = 0; i < bars.Length; i++) + bars[i] = new Bar { Height = Math.Max(0.05f, (float)bins[i] / maxCount) }; + + Container axisFlow; + + InternalChild = new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Width = 0.8f, + Content = new[] + { + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] { bars } + } + }, + new Drawable[] + { + axisFlow = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + }, + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + } + }; + + // Our axis will contain one centre element + 5 points on each side, each with a value depending on the number of bins * bin size. + double maxValue = timing_distribution_bins * binSize; + double axisValueStep = maxValue / axis_points; + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "0", + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }); + + for (int i = 1; i <= axis_points; i++) + { + double axisValue = i * axisValueStep; + float position = (float)(axisValue / maxValue); + float alpha = 1f - position * 0.8f; + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + X = -position / 2, + Alpha = alpha, + Text = axisValue.ToString("-0"), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }); + + axisFlow.Add(new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativePositionAxes = Axes.X, + X = position / 2, + Alpha = alpha, + Text = axisValue.ToString("+0"), + Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold) + }); + } + } + + private class Bar : CompositeDrawable + { + public Bar() + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + + RelativeSizeAxes = Axes.Both; + + Padding = new MarginPadding { Horizontal = 1 }; + + InternalChild = new Circle + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#66FFCC") + }; + } + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs new file mode 100644 index 0000000000..6fe7e4eda8 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticItem.cs @@ -0,0 +1,88 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// <summary> + /// Represents a simple statistic item (one that only needs textual display). + /// Richer visualisations should be done with <see cref="StatisticItem"/>s. + /// </summary> + public abstract class SimpleStatisticItem : Container + { + /// <summary> + /// The text to display as the statistic's value. + /// </summary> + protected string Value + { + set => this.value.Text = value; + } + + private readonly OsuSpriteText value; + + /// <summary> + /// Creates a new simple statistic item. + /// </summary> + /// <param name="name">The name of the statistic.</param> + protected SimpleStatisticItem(string name) + { + Name = name; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + AddRange(new[] + { + new OsuSpriteText + { + Text = Name, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.GetFont(size: 14) + }, + value = new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold) + } + }); + } + } + + /// <summary> + /// Strongly-typed generic specialisation for <see cref="SimpleStatisticItem"/>. + /// </summary> + public class SimpleStatisticItem<TValue> : SimpleStatisticItem + { + private TValue value; + + /// <summary> + /// The statistic's value to be displayed. + /// </summary> + public new TValue Value + { + get => value; + set + { + this.value = value; + base.Value = DisplayValue(value); + } + } + + /// <summary> + /// Used to convert <see cref="Value"/> to a text representation. + /// Defaults to using <see cref="object.ToString"/>. + /// </summary> + protected virtual string DisplayValue(TValue value) => value.ToString(); + + public SimpleStatisticItem(string name) + : base(name) + { + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs new file mode 100644 index 0000000000..8b503cc04e --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs @@ -0,0 +1,123 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// <summary> + /// Represents a table with simple statistics (ones that only need textual display). + /// Richer visualisations should be done with <see cref="StatisticRow"/>s and <see cref="StatisticItem"/>s. + /// </summary> + public class SimpleStatisticTable : CompositeDrawable + { + private readonly SimpleStatisticItem[] items; + private readonly int columnCount; + + private FillFlowContainer[] columns; + + /// <summary> + /// Creates a statistic row for the supplied <see cref="SimpleStatisticItem"/>s. + /// </summary> + /// <param name="columnCount">The number of columns to layout the <paramref name="items"/> into.</param> + /// <param name="items">The <see cref="SimpleStatisticItem"/>s to display in this row.</param> + public SimpleStatisticTable(int columnCount, [ItemNotNull] IEnumerable<SimpleStatisticItem> items) + { + if (columnCount < 1) + throw new ArgumentOutOfRangeException(nameof(columnCount)); + + this.columnCount = columnCount; + this.items = items.ToArray(); + } + + [BackgroundDependencyLoader] + private void load() + { + columns = new FillFlowContainer[columnCount]; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + ColumnDimensions = createColumnDimensions().ToArray(), + Content = new[] { createColumns().ToArray() } + }; + + for (int i = 0; i < items.Length; ++i) + columns[i % columnCount].Add(items[i]); + } + + private IEnumerable<Dimension> createColumnDimensions() + { + for (int column = 0; column < columnCount; ++column) + { + if (column > 0) + yield return new Dimension(GridSizeMode.Absolute, 30); + + yield return new Dimension(); + } + } + + private IEnumerable<Drawable> createColumns() + { + for (int column = 0; column < columnCount; ++column) + { + if (column > 0) + { + yield return new Spacer + { + Alpha = items.Length > column ? 1 : 0 + }; + } + + yield return columns[column] = createColumn(); + } + } + + private FillFlowContainer createColumn() => new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical + }; + + private class Spacer : CompositeDrawable + { + public Spacer() + { + RelativeSizeAxes = Axes.Both; + Padding = new MarginPadding { Vertical = 4 }; + + InternalChild = new CircularContainer + { + RelativeSizeAxes = Axes.Y, + Width = 3, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + CornerRadius = 2, + Masking = true, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#222") + } + }; + } + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs b/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs new file mode 100644 index 0000000000..485d24d024 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs @@ -0,0 +1,90 @@ +// 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. + +using System.Diagnostics.CodeAnalysis; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// <summary> + /// Wraps a <see cref="StatisticItem"/> to add a header and suitable layout for use in <see cref="ResultsScreen"/>. + /// </summary> + internal class StatisticContainer : CompositeDrawable + { + /// <summary> + /// Creates a new <see cref="StatisticContainer"/>. + /// </summary> + /// <param name="item">The <see cref="StatisticItem"/> to display.</param> + public StatisticContainer([NotNull] StatisticItem item) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] + { + new[] + { + createHeader(item) + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 15 }, + Child = item.Content + } + }, + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + } + }; + } + + private static Drawable createHeader(StatisticItem item) + { + if (string.IsNullOrEmpty(item.Name)) + return Empty(); + + return new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Children = new Drawable[] + { + new Circle + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 9, + Width = 4, + Colour = Color4Extensions.FromHex("#00FFAA") + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = item.Name, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold), + } + } + }; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs new file mode 100644 index 0000000000..4903983759 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs @@ -0,0 +1,43 @@ +// 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. + +using JetBrains.Annotations; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// <summary> + /// An item to be displayed in a row of statistics inside the results screen. + /// </summary> + public class StatisticItem + { + /// <summary> + /// The name of this item. + /// </summary> + public readonly string Name; + + /// <summary> + /// The <see cref="Drawable"/> content to be displayed. + /// </summary> + public readonly Drawable Content; + + /// <summary> + /// The <see cref="Dimension"/> of this row. This can be thought of as the column dimension of an encompassing <see cref="GridContainer"/>. + /// </summary> + public readonly Dimension Dimension; + + /// <summary> + /// Creates a new <see cref="StatisticItem"/>, to be displayed inside a <see cref="StatisticRow"/> in the results screen. + /// </summary> + /// <param name="name">The name of the item. Can be <see cref="string.Empty"/> to hide the item header.</param> + /// <param name="content">The <see cref="Drawable"/> content to be displayed.</param> + /// <param name="dimension">The <see cref="Dimension"/> of this item. This can be thought of as the column dimension of an encompassing <see cref="GridContainer"/>.</param> + public StatisticItem([NotNull] string name, [NotNull] Drawable content, [CanBeNull] Dimension dimension = null) + { + Name = name; + Content = content; + Dimension = dimension; + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs b/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs new file mode 100644 index 0000000000..e1ca9799a3 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs @@ -0,0 +1,19 @@ +// 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. + +using JetBrains.Annotations; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// <summary> + /// A row of statistics to be displayed in the results screen. + /// </summary> + public class StatisticRow + { + /// <summary> + /// The columns of this <see cref="StatisticRow"/>. + /// </summary> + [ItemNotNull] + public StatisticItem[] Columns; + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs new file mode 100644 index 0000000000..f1ae1f9d73 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -0,0 +1,171 @@ +// 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. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Placeholders; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osuTK; + +namespace osu.Game.Screens.Ranking.Statistics +{ + public class StatisticsPanel : VisibilityContainer + { + public const float SIDE_PADDING = 30; + + public readonly Bindable<ScoreInfo> Score = new Bindable<ScoreInfo>(); + + protected override bool StartHidden => true; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + private readonly Container content; + private readonly LoadingSpinner spinner; + + public StatisticsPanel() + { + InternalChild = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Left = ScorePanel.EXPANDED_WIDTH + SIDE_PADDING * 3, + Right = SIDE_PADDING, + Top = SIDE_PADDING, + Bottom = 50 // Approximate padding to the bottom of the score panel. + }, + Children = new Drawable[] + { + content = new Container { RelativeSizeAxes = Axes.Both }, + spinner = new LoadingSpinner() + } + }; + } + + [BackgroundDependencyLoader] + private void load() + { + Score.BindValueChanged(populateStatistics, true); + } + + private CancellationTokenSource loadCancellation; + + private void populateStatistics(ValueChangedEvent<ScoreInfo> score) + { + loadCancellation?.Cancel(); + loadCancellation = null; + + foreach (var child in content) + child.FadeOut(150).Expire(); + + var newScore = score.NewValue; + + if (newScore == null) + return; + + if (newScore.HitEvents == null || newScore.HitEvents.Count == 0) + { + content.Add(new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new MessagePlaceholder("Extended statistics are only available after watching a replay!"), + new ReplayDownloadButton(newScore) + { + Scale = new Vector2(1.5f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } + }); + } + else + { + spinner.Show(); + + var localCancellationSource = loadCancellation = new CancellationTokenSource(); + IBeatmap playableBeatmap = null; + + // Todo: The placement of this is temporary. Eventually we'll both generate the playable beatmap _and_ run through it in a background task to generate the hit events. + Task.Run(() => + { + playableBeatmap = beatmapManager.GetWorkingBeatmap(newScore.Beatmap).GetPlayableBeatmap(newScore.Ruleset, newScore.Mods ?? Array.Empty<Mod>()); + }, loadCancellation.Token).ContinueWith(t => Schedule(() => + { + var rows = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Spacing = new Vector2(30, 15), + Alpha = 0 + }; + + foreach (var row in newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap)) + { + rows.Add(new GridContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] + { + row.Columns?.Select(c => new StatisticContainer(c) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }).Cast<Drawable>().ToArray() + }, + ColumnDimensions = Enumerable.Range(0, row.Columns?.Length ?? 0) + .Select(i => row.Columns[i].Dimension ?? new Dimension()).ToArray(), + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } + }); + } + + LoadComponentAsync(rows, d => + { + if (Score.Value != newScore) + return; + + spinner.Hide(); + content.Add(d); + d.FadeIn(250, Easing.OutQuint); + }, localCancellationSource.Token); + }), localCancellationSource.Token); + } + } + + protected override bool OnClick(ClickEvent e) + { + ToggleVisibility(); + return true; + } + + protected override void PopIn() => this.FadeIn(150, Easing.OutQuint); + + protected override void PopOut() => this.FadeOut(150, Easing.OutQuint); + + protected override void Dispose(bool isDisposing) + { + loadCancellation?.Cancel(); + + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs b/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs new file mode 100644 index 0000000000..055db143d1 --- /dev/null +++ b/osu.Game/Screens/Ranking/Statistics/UnstableRate.cs @@ -0,0 +1,40 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Screens.Ranking.Statistics +{ + /// <summary> + /// Displays the unstable rate statistic for a given play. + /// </summary> + public class UnstableRate : SimpleStatisticItem<double> + { + /// <summary> + /// Creates and computes an <see cref="UnstableRate"/> statistic. + /// </summary> + /// <param name="hitEvents">Sequence of <see cref="HitEvent"/>s to calculate the unstable rate based on.</param> + public UnstableRate(IEnumerable<HitEvent> hitEvents) + : base("Unstable Rate") + { + var timeOffsets = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit()) + .Select(ev => ev.TimeOffset).ToArray(); + Value = 10 * standardDeviation(timeOffsets); + } + + private static double standardDeviation(double[] timeOffsets) + { + if (timeOffsets.Length == 0) + return double.NaN; + + var mean = timeOffsets.Average(); + var squares = timeOffsets.Select(offset => Math.Pow(offset - mean, 2)).Sum(); + return Math.Sqrt(squares / timeOffsets.Length); + } + + protected override string DisplayValue(double value) => double.IsNaN(value) ? "(not available)" : value.ToString("N2"); + } +} diff --git a/osu.Game/Screens/Ranking/Types/LocalLeaderboardPageInfo.cs b/osu.Game/Screens/Ranking/Types/LocalLeaderboardPageInfo.cs deleted file mode 100644 index fe183c5f89..0000000000 --- a/osu.Game/Screens/Ranking/Types/LocalLeaderboardPageInfo.cs +++ /dev/null @@ -1,28 +0,0 @@ -// 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. - -using osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps; -using osu.Game.Scoring; -using osu.Game.Screens.Ranking.Pages; - -namespace osu.Game.Screens.Ranking.Types -{ - public class LocalLeaderboardPageInfo : IResultPageInfo - { - private readonly ScoreInfo score; - private readonly WorkingBeatmap beatmap; - - public LocalLeaderboardPageInfo(ScoreInfo score, WorkingBeatmap beatmap) - { - this.score = score; - this.beatmap = beatmap; - } - - public IconUsage Icon => FontAwesome.Solid.User; - - public string Name => @"Local Leaderboard"; - - public ResultsPage CreatePage() => new LocalLeaderboardPage(score, beatmap); - } -} diff --git a/osu.Game/Screens/Ranking/Types/ScoreOverviewPageInfo.cs b/osu.Game/Screens/Ranking/Types/ScoreOverviewPageInfo.cs deleted file mode 100644 index 424dbff6f6..0000000000 --- a/osu.Game/Screens/Ranking/Types/ScoreOverviewPageInfo.cs +++ /dev/null @@ -1,30 +0,0 @@ -// 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. - -using osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps; -using osu.Game.Scoring; -using osu.Game.Screens.Ranking.Pages; - -namespace osu.Game.Screens.Ranking.Types -{ - public class ScoreOverviewPageInfo : IResultPageInfo - { - public IconUsage Icon => FontAwesome.Solid.Asterisk; - - public string Name => "Overview"; - private readonly ScoreInfo score; - private readonly WorkingBeatmap beatmap; - - public ScoreOverviewPageInfo(ScoreInfo score, WorkingBeatmap beatmap) - { - this.score = score; - this.beatmap = beatmap; - } - - public ResultsPage CreatePage() - { - return new ScoreResultsPage(score, beatmap); - } - } -} diff --git a/osu.Game/Screens/ScorePresentType.cs b/osu.Game/Screens/ScorePresentType.cs new file mode 100644 index 0000000000..3216f92091 --- /dev/null +++ b/osu.Game/Screens/ScorePresentType.cs @@ -0,0 +1,11 @@ +// 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. + +namespace osu.Game.Screens +{ + public enum ScorePresentType + { + Results, + Gameplay + } +} diff --git a/osu.Game/Screens/ScreenWhiteBox.cs b/osu.Game/Screens/ScreenWhiteBox.cs index 5648dd997b..3d8fd5dad7 100644 --- a/osu.Game/Screens/ScreenWhiteBox.cs +++ b/osu.Game/Screens/ScreenWhiteBox.cs @@ -20,38 +20,17 @@ namespace osu.Game.Screens { public class ScreenWhiteBox : OsuScreen { + private readonly UnderConstructionMessage message; + private const double transition_time = 1000; protected virtual IEnumerable<Type> PossibleChildren => null; - private readonly FillFlowContainer textContainer; - private readonly Container boxContainer; - protected override BackgroundScreen CreateBackground() => new BackgroundScreenCustom(@"Backgrounds/bg2"); - public override void OnEntering(IScreen last) - { - base.OnEntering(last); - - Alpha = 0; - textContainer.Position = new Vector2(DrawSize.X / 16, 0); - - boxContainer.ScaleTo(0.2f); - boxContainer.RotateTo(-20); - - using (BeginDelayedSequence(300, true)) - { - boxContainer.ScaleTo(1, transition_time, Easing.OutElastic); - boxContainer.RotateTo(0, transition_time / 2, Easing.OutQuint); - - textContainer.MoveTo(Vector2.Zero, transition_time, Easing.OutExpo); - this.FadeIn(transition_time, Easing.OutExpo); - } - } - public override bool OnExiting(IScreen next) { - textContainer.MoveTo(new Vector2(DrawSize.X / 16, 0), transition_time, Easing.OutExpo); + message.TextContainer.MoveTo(new Vector2(DrawSize.X / 16, 0), transition_time, Easing.OutExpo); this.FadeOut(transition_time, Easing.OutExpo); return base.OnExiting(next); @@ -61,7 +40,7 @@ namespace osu.Game.Screens { base.OnSuspending(next); - textContainer.MoveTo(new Vector2(-(DrawSize.X / 16), 0), transition_time, Easing.OutExpo); + message.TextContainer.MoveTo(new Vector2(-(DrawSize.X / 16), 0), transition_time, Easing.OutExpo); this.FadeOut(transition_time, Easing.OutExpo); } @@ -69,7 +48,7 @@ namespace osu.Game.Screens { base.OnResuming(last); - textContainer.MoveTo(Vector2.Zero, transition_time, Easing.OutExpo); + message.TextContainer.MoveTo(Vector2.Zero, transition_time, Easing.OutExpo); this.FadeIn(transition_time, Easing.OutExpo); } @@ -79,65 +58,7 @@ namespace osu.Game.Screens InternalChildren = new Drawable[] { - boxContainer = new Container - { - Size = new Vector2(0.3f), - RelativeSizeAxes = Axes.Both, - CornerRadius = 20, - Masking = true, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - - Colour = getColourFor(GetType()), - Alpha = 0.2f, - Blending = BlendingMode.Additive, - }, - textContainer = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new SpriteIcon - { - Icon = FontAwesome.Solid.UniversalAccess, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Size = new Vector2(50), - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = GetType().Name, - Colour = getColourFor(GetType()).Lighten(0.8f), - Font = OsuFont.GetFont(size: 50), - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = "is not yet ready for use!", - Font = OsuFont.GetFont(size: 20), - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = "please check back a bit later.", - Font = OsuFont.GetFont(size: 14), - }, - } - }, - } - }, + message = new UnderConstructionMessage(GetType().Name), childModeButtons = new FillFlowContainer { Direction = FillDirection.Vertical, @@ -155,24 +76,24 @@ namespace osu.Game.Screens childModeButtons.Add(new ChildModeButton { Text = $@"{t.Name}", - BackgroundColour = getColourFor(t), - HoverColour = getColourFor(t).Lighten(0.2f), + BackgroundColour = getColourFor(t.Name), + HoverColour = getColourFor(t.Name).Lighten(0.2f), Action = delegate { this.Push(Activator.CreateInstance(t) as Screen); } }); } } } - private Color4 getColourFor(Type type) + private static Color4 getColourFor(object type) { - int hash = type.Name.GetHashCode(); - byte r = (byte)MathHelper.Clamp(((hash & 0xFF0000) >> 16) * 0.8f, 20, 255); - byte g = (byte)MathHelper.Clamp(((hash & 0x00FF00) >> 8) * 0.8f, 20, 255); - byte b = (byte)MathHelper.Clamp((hash & 0x0000FF) * 0.8f, 20, 255); + 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); return new Color4(r, g, b, 255); } - public class ChildModeButton : TwoLayerButton + private class ChildModeButton : TwoLayerButton { public ChildModeButton() { @@ -181,5 +102,104 @@ namespace osu.Game.Screens Origin = Anchor.BottomRight; } } + + public class UnderConstructionMessage : CompositeDrawable + { + public FillFlowContainer TextContainer { get; } + + private readonly Container boxContainer; + + public UnderConstructionMessage(string name) + { + RelativeSizeAxes = Axes.Both; + Size = new Vector2(0.3f); + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + var colour = getColourFor(name); + + InternalChildren = new Drawable[] + { + boxContainer = new Container + { + CornerRadius = 20, + Masking = true, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + + Colour = colour, + Alpha = 0.2f, + Blending = BlendingParameters.Additive, + }, + TextContainer = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new SpriteIcon + { + Icon = FontAwesome.Solid.UniversalAccess, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Size = new Vector2(50), + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = name, + Colour = colour.Lighten(0.8f), + Font = OsuFont.GetFont(size: 36), + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "is not yet ready for use!", + Font = OsuFont.GetFont(size: 20), + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "please check back a bit later.", + Font = OsuFont.GetFont(size: 14), + }, + } + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + TextContainer.Position = new Vector2(DrawSize.X / 16, 0); + + boxContainer.Hide(); + boxContainer.ScaleTo(0.2f); + boxContainer.RotateTo(-20); + + using (BeginDelayedSequence(300, true)) + { + boxContainer.ScaleTo(1, transition_time, Easing.OutElastic); + boxContainer.RotateTo(0, transition_time / 2, Easing.OutQuint); + + TextContainer.MoveTo(Vector2.Zero, transition_time, Easing.OutExpo); + boxContainer.FadeIn(transition_time, Easing.OutExpo); + } + } + } } } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index cf88b697d9..83631fd383 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -1,33 +1,43 @@ // 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. -using osuTK; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using System; using System.Collections.Generic; -using System.Linq; -using osu.Game.Configuration; -using osuTK.Input; -using osu.Framework.MathUtils; using System.Diagnostics; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Caching; -using osu.Framework.Threading; -using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; +using osu.Game.Input.Bindings; using osu.Game.Screens.Select.Carousel; +using osuTK; +using osuTK.Input; namespace osu.Game.Screens.Select { - public class BeatmapCarousel : OsuScrollContainer + public class BeatmapCarousel : CompositeDrawable, IKeyBindingHandler<GlobalAction> { - private const float bleed_top = FilterControl.HEIGHT; - private const float bleed_bottom = Footer.HEIGHT; + /// <summary> + /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it. + /// </summary> + public float BleedTop { get; set; } + + /// <summary> + /// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it. + /// </summary> + public float BleedBottom { get; set; } /// <summary> /// Triggered when the <see cref="BeatmapSets"/> loaded change and are completely loaded. @@ -46,6 +56,11 @@ namespace osu.Game.Screens.Select /// </summary> public BeatmapSetInfo SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet; + /// <summary> + /// A function to optionally decide on a recommended difficulty from a beatmap set. + /// </summary> + public Func<IEnumerable<BeatmapInfo>, BeatmapInfo> GetRecommendedBeatmap; + private CarouselBeatmapSet selectedBeatmapSet; /// <summary> @@ -56,13 +71,31 @@ namespace osu.Game.Screens.Select public override bool HandleNonPositionalInput => AllowSelection; public override bool HandlePositionalInput => AllowSelection; + public override bool PropagatePositionalInputSubTree => AllowSelection; + public override bool PropagateNonPositionalInputSubTree => AllowSelection; + + private (int first, int last) displayedRange; + + /// <summary> + /// Extend the range to retain already loaded pooled drawables. + /// </summary> + private const float distance_offscreen_before_unload = 1024; + + /// <summary> + /// Extend the range to update positions / retrieve pooled drawables outside of visible range. + /// </summary> + private const float distance_offscreen_to_preload = 512; // todo: adjust this appropriately once we can make set panel contents load while off-screen. + /// <summary> /// Whether carousel items have completed asynchronously loaded. /// </summary> public bool BeatmapSetsLoaded { get; private set; } + private readonly CarouselScrollContainer scroll; + private IEnumerable<CarouselBeatmapSet> beatmapSets => root.Children.OfType<CarouselBeatmapSet>(); + // todo: only used for testing, maybe remove. public IEnumerable<BeatmapSetInfo> BeatmapSets { get => beatmapSets.Select(g => g.BeatmapSet); @@ -73,17 +106,19 @@ namespace osu.Game.Screens.Select { CarouselRoot newRoot = new CarouselRoot(this); - beatmapSets.Select(createCarouselSet).Where(g => g != null).ForEach(newRoot.AddChild); - newRoot.Filter(activeCriteria); - - // preload drawables as the ctor overhead is quite high currently. - var _ = newRoot.Drawables; + newRoot.AddChildren(beatmapSets.Select(createCarouselSet).Where(g => g != null)); root = newRoot; - scrollableContent.Clear(false); + if (selectedBeatmapSet != null && !beatmapSets.Contains(selectedBeatmapSet.BeatmapSet)) + selectedBeatmapSet = null; + + ScrollableContent.Clear(false); itemsCache.Invalidate(); scrollPositionCache.Invalidate(); + // apply any pending filter operation that may have been delayed (see applyActiveCriteria's scheduling behaviour when BeatmapSetsLoaded is false). + FlushPendingFilterOperations(); + // Run on late scheduler want to ensure this runs after all pending UpdateBeatmapSet / RemoveBeatmapSet operations are run. SchedulerAfterChildren.Add(() => { @@ -92,11 +127,12 @@ namespace osu.Game.Screens.Select }); } - private readonly List<float> yPositions = new List<float>(); - private Cached itemsCache = new Cached(); - private Cached scrollPositionCache = new Cached(); + private readonly List<CarouselItem> visibleItems = new List<CarouselItem>(); - private readonly Container<DrawableCarouselItem> scrollableContent; + private readonly Cached itemsCache = new Cached(); + private readonly Cached scrollPositionCache = new Cached(); + + protected readonly Container<DrawableCarouselItem> ScrollableContent; public Bindable<bool> RightClickScrollingEnabled = new Bindable<bool>(); @@ -104,35 +140,64 @@ namespace osu.Game.Screens.Select private readonly List<CarouselBeatmapSet> previouslyVisitedRandomSets = new List<CarouselBeatmapSet>(); private readonly Stack<CarouselBeatmap> randomSelectedBeatmaps = new Stack<CarouselBeatmap>(); - protected List<DrawableCarouselItem> Items = new List<DrawableCarouselItem>(); private CarouselRoot root; + private IBindable<WeakReference<BeatmapSetInfo>> itemUpdated; + private IBindable<WeakReference<BeatmapSetInfo>> itemRemoved; + private IBindable<WeakReference<BeatmapInfo>> itemHidden; + private IBindable<WeakReference<BeatmapInfo>> itemRestored; + + private readonly DrawablePool<DrawableCarouselBeatmapSet> setPool = new DrawablePool<DrawableCarouselBeatmapSet>(100); + public BeatmapCarousel() { root = new CarouselRoot(this); - Child = new OsuContextMenuContainer + InternalChild = new OsuContextMenuContainer { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = scrollableContent = new Container<DrawableCarouselItem> + RelativeSizeAxes = Axes.Both, + Child = scroll = new CarouselScrollContainer { - RelativeSizeAxes = Axes.X, + Masking = false, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + setPool, + ScrollableContent = new Container<DrawableCarouselItem> + { + RelativeSizeAxes = Axes.X, + } + } } }; } + [Resolved] + private BeatmapManager beatmaps { get; set; } + [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuConfigManager config, BeatmapManager beatmaps) + private void load(OsuConfigManager config) { config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm); config.BindWith(OsuSetting.SongSelectRightMouseScroll, RightClickScrollingEnabled); - RightClickScrollingEnabled.ValueChanged += enabled => RightMouseScrollbar = enabled.NewValue; + RightClickScrollingEnabled.ValueChanged += enabled => scroll.RightMouseScrollbar = enabled.NewValue; RightClickScrollingEnabled.TriggerChange(); - loadBeatmapSets(beatmaps.GetAllUsableBeatmapSetsEnumerable()); + itemUpdated = beatmaps.ItemUpdated.GetBoundCopy(); + itemUpdated.BindValueChanged(beatmapUpdated); + itemRemoved = beatmaps.ItemRemoved.GetBoundCopy(); + itemRemoved.BindValueChanged(beatmapRemoved); + itemHidden = beatmaps.BeatmapHidden.GetBoundCopy(); + itemHidden.BindValueChanged(beatmapHidden); + itemRestored = beatmaps.BeatmapRestored.GetBoundCopy(); + itemRestored.BindValueChanged(beatmapRestored); + + if (!beatmapSets.Any()) + loadBeatmapSets(GetLoadableBeatmaps()); } + protected virtual IEnumerable<BeatmapSetInfo> GetLoadableBeatmaps() => beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.AllButFiles); + public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() => { var existingSet = beatmapSets.FirstOrDefault(b => b.BeatmapSet.ID == beatmapSet.ID); @@ -166,9 +231,11 @@ namespace osu.Game.Screens.Select root.AddChild(newSet); - applyActiveCriteria(false, false); + // only reset scroll position if already near the scroll target. + // without this, during a large beatmap import it is impossible to navigate the carousel. + applyActiveCriteria(false, alwaysResetScrollPosition: false); - //check if we can/need to maintain our current selection. + // check if we can/need to maintain our current selection. if (previouslySelectedID != null) select((CarouselItem)newSet.Beatmaps.FirstOrDefault(b => b.Beatmap.ID == previouslySelectedID) ?? newSet); @@ -178,15 +245,15 @@ namespace osu.Game.Screens.Select /// <summary> /// Selects a given beatmap on the carousel. - /// - /// If bypassFilters is false, we will try to select another unfiltered beatmap in the same set. If the - /// entire set is filtered, no selection is made. /// </summary> /// <param name="beatmap">The beatmap to select.</param> /// <param name="bypassFilters">Whether to select the beatmap even if it is filtered (i.e., not visible on carousel).</param> /// <returns>True if a selection was made, False if it wasn't.</returns> public bool SelectBeatmap(BeatmapInfo beatmap, bool bypassFilters = true) { + // ensure that any pending events from BeatmapManager have been run before attempting a selection. + Scheduler.Update(); + if (beatmap?.Hidden != false) return false; @@ -202,14 +269,21 @@ namespace osu.Game.Screens.Select continue; if (!bypassFilters && item.Filtered.Value) - // The beatmap exists in this set but is filtered, so look for the first unfiltered map in the set - item = set.Beatmaps.FirstOrDefault(b => !b.Filtered.Value); + return false; - if (item != null) + select(item); + + // if we got here and the set is filtered, it means we were bypassing filters. + // in this case, reapplying the filter is necessary to ensure the panel is in the correct place + // (since it is forcefully being included in the carousel). + if (set.Filtered.Value) { - select(item); - return true; + Debug.Assert(bypassFilters); + + applyActiveCriteria(false); } + + return true; } return false; @@ -222,46 +296,40 @@ namespace osu.Game.Screens.Select /// <param name="skipDifficulties">Whether to skip individual difficulties and only increment over full groups.</param> public void SelectNext(int direction = 1, bool skipDifficulties = true) { - var visibleItems = Items.Where(s => !s.Item.Filtered.Value).ToList(); - - if (!visibleItems.Any()) + if (beatmapSets.All(s => s.Filtered.Value)) return; - DrawableCarouselItem drawable = null; + if (skipDifficulties) + selectNextSet(direction, true); + else + selectNextDifficulty(direction); + } - if (selectedBeatmap != null && (drawable = selectedBeatmap.Drawables.FirstOrDefault()) == null) - // if the selected beatmap isn't present yet, we can't correctly change selection. - // we can fix this by changing this method to not reference drawables / Items in the first place. + private void selectNextSet(int direction, bool skipDifficulties) + { + var unfilteredSets = beatmapSets.Where(s => !s.Filtered.Value).ToList(); + + var nextSet = unfilteredSets[(unfilteredSets.IndexOf(selectedBeatmapSet) + direction + unfilteredSets.Count) % unfilteredSets.Count]; + + if (skipDifficulties) + select(nextSet); + else + select(direction > 0 ? nextSet.Beatmaps.First(b => !b.Filtered.Value) : nextSet.Beatmaps.Last(b => !b.Filtered.Value)); + } + + private void selectNextDifficulty(int direction) + { + if (selectedBeatmap == null) return; - int originalIndex = visibleItems.IndexOf(drawable); - int currentIndex = originalIndex; + var unfilteredDifficulties = selectedBeatmapSet.Children.Where(s => !s.Filtered.Value).ToList(); - // local function to increment the index in the required direction, wrapping over extremities. - int incrementIndex() => currentIndex = (currentIndex + direction + visibleItems.Count) % visibleItems.Count; + int index = unfilteredDifficulties.IndexOf(selectedBeatmap); - while (incrementIndex() != originalIndex) - { - var item = visibleItems[currentIndex].Item; - - if (item.Filtered.Value || item.State.Value == CarouselItemState.Selected) continue; - - switch (item) - { - case CarouselBeatmap beatmap: - if (skipDifficulties) continue; - - select(beatmap); - return; - - case CarouselBeatmapSet set: - if (skipDifficulties) - select(set); - else - select(direction > 0 ? set.Beatmaps.First(b => !b.Filtered.Value) : set.Beatmaps.Last(b => !b.Filtered.Value)); - return; - } - } + if (index + direction < 0 || index + direction >= unfilteredDifficulties.Count) + selectNextSet(direction, false); + else + select(unfilteredDifficulties[index + direction]); } /// <summary> @@ -270,6 +338,9 @@ namespace osu.Game.Screens.Select /// <returns>True if a selection could be made, else False.</returns> public bool SelectNextRandom() { + if (!AllowSelection) + return false; + var visibleSets = beatmapSets.Where(s => !s.Filtered.Value).ToList(); if (!visibleSets.Any()) return false; @@ -302,8 +373,7 @@ namespace osu.Game.Screens.Select else set = visibleSets.ElementAt(RNG.Next(visibleSets.Count)); - var visibleBeatmaps = set.Beatmaps.Where(s => !s.Filtered.Value).ToList(); - select(visibleBeatmaps[RNG.Next(visibleBeatmaps.Count)]); + select(set); return true; } @@ -342,27 +412,27 @@ namespace osu.Game.Screens.Select /// <summary> /// Half the height of the visible content. /// <remarks> - /// This is different from the height of <see cref="ScrollContainer{T}.displayableContent"/>, since + /// This is different from the height of <see cref="ScrollContainer{T}"/>.displayableContent, since /// the beatmap carousel bleeds into the <see cref="FilterControl"/> and the <see cref="Footer"/> /// </remarks> /// </summary> - private float visibleHalfHeight => (DrawHeight + bleed_bottom + bleed_top) / 2; + private float visibleHalfHeight => (DrawHeight + BleedBottom + BleedTop) / 2; /// <summary> /// The position of the lower visible bound with respect to the current scroll position. /// </summary> - private float visibleBottomBound => Current + DrawHeight + bleed_bottom; + private float visibleBottomBound => scroll.Current + DrawHeight + BleedBottom; /// <summary> /// The position of the upper visible bound with respect to the current scroll position. /// </summary> - private float visibleUpperBound => Current - bleed_top; + private float visibleUpperBound => scroll.Current - BleedTop; public void FlushPendingFilterOperations() { if (PendingFilter?.Completed == false) { - applyActiveCriteria(false, false); + applyActiveCriteria(false); Update(); } } @@ -372,12 +442,24 @@ namespace osu.Game.Screens.Select if (newCriteria != null) activeCriteria = newCriteria; - applyActiveCriteria(debounce, true); + applyActiveCriteria(debounce); } - private void applyActiveCriteria(bool debounce, bool scroll) + private void applyActiveCriteria(bool debounce, bool alwaysResetScrollPosition = true) { - if (root.Children.Any() != true) return; + PendingFilter?.Cancel(); + PendingFilter = null; + + if (debounce) + PendingFilter = Scheduler.AddDelayed(perform, 250); + else + { + // if initial load is not yet finished, this will be run inline in loadBeatmapSets to ensure correct order of operation. + if (!BeatmapSetsLoaded) + PendingFilter = Schedule(perform); + else + perform(); + } void perform() { @@ -385,138 +467,239 @@ namespace osu.Game.Screens.Select root.Filter(activeCriteria); itemsCache.Invalidate(); - if (scroll) scrollPositionCache.Invalidate(); + + if (alwaysResetScrollPosition || !scroll.UserScrolling) + ScrollToSelected(); } - - PendingFilter?.Cancel(); - PendingFilter = null; - - if (debounce) - PendingFilter = Scheduler.AddDelayed(perform, 250); - else - perform(); } private float? scrollTarget; + /// <summary> + /// Scroll to the current <see cref="SelectedBeatmap"/>. + /// </summary> public void ScrollToSelected() => scrollPositionCache.Invalidate(); + #region Key / button selection logic + protected override bool OnKeyDown(KeyDownEvent e) { - int direction = 0; - bool skipDifficulties = false; - switch (e.Key) { - case Key.Up: - direction = -1; - break; - - case Key.Down: - direction = 1; - break; - case Key.Left: - direction = -1; - skipDifficulties = true; - break; + if (!e.Repeat) + beginRepeatSelection(() => SelectNext(-1, true), e.Key); + return true; case Key.Right: - direction = 1; - skipDifficulties = true; + if (!e.Repeat) + beginRepeatSelection(() => SelectNext(1, true), e.Key); + return true; + } + + return false; + } + + protected override void OnKeyUp(KeyUpEvent e) + { + switch (e.Key) + { + case Key.Left: + case Key.Right: + endRepeatSelection(e.Key); break; } - if (direction == 0) - return base.OnKeyDown(e); - - SelectNext(direction, skipDifficulties); - return true; + base.OnKeyUp(e); } - protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => ReceivePositionalInputAt(screenSpacePos); + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.SelectNext: + beginRepeatSelection(() => SelectNext(1, false), action); + return true; + + case GlobalAction.SelectPrevious: + beginRepeatSelection(() => SelectNext(-1, false), action); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + switch (action) + { + case GlobalAction.SelectNext: + case GlobalAction.SelectPrevious: + endRepeatSelection(action); + break; + } + } + + private ScheduledDelegate repeatDelegate; + private object lastRepeatSource; + + /// <summary> + /// Begin repeating the specified selection action. + /// </summary> + /// <param name="action">The action to perform.</param> + /// <param name="source">The source of the action. Used in conjunction with <see cref="endRepeatSelection"/> to only cancel the correct action (most recently pressed key).</param> + private void beginRepeatSelection(Action action, object source) + { + endRepeatSelection(); + + lastRepeatSource = source; + repeatDelegate = this.BeginKeyRepeat(Scheduler, action); + } + + private void endRepeatSelection(object source = null) + { + // only the most recent source should be able to cancel the current action. + if (source != null && !EqualityComparer<object>.Default.Equals(lastRepeatSource, source)) + return; + + repeatDelegate?.Cancel(); + repeatDelegate = null; + lastRepeatSource = null; + } + + #endregion protected override void Update() { base.Update(); - if (!itemsCache.IsValid) - updateItems(); + bool revalidateItems = !itemsCache.IsValid; - if (!scrollPositionCache.IsValid) - updateScrollPosition(); + // First we iterate over all non-filtered carousel items and populate their + // vertical position data. + if (revalidateItems) + updateYPositions(); - // Remove all items that should no longer be on-screen - scrollableContent.RemoveAll(p => p.Y < visibleUpperBound - p.DrawHeight || p.Y > visibleBottomBound || !p.IsPresent); + // This data is consumed to find the currently displayable range. + // This is the range we want to keep drawables for, and should exceed the visible range slightly to avoid drawable churn. + var newDisplayRange = getDisplayRange(); - // Find index range of all items that should be on-screen - Trace.Assert(Items.Count == yPositions.Count); - - int firstIndex = yPositions.BinarySearch(visibleUpperBound - DrawableCarouselItem.MAX_HEIGHT); - if (firstIndex < 0) firstIndex = ~firstIndex; - int lastIndex = yPositions.BinarySearch(visibleBottomBound); - if (lastIndex < 0) lastIndex = ~lastIndex; - - int notVisibleCount = 0; - - // Add those items within the previously found index range that should be displayed. - for (int i = firstIndex; i < lastIndex; ++i) + // If the filtered items or visible range has changed, pooling requirements need to be checked. + // This involves fetching new items from the pool, returning no-longer required items. + if (revalidateItems || newDisplayRange != displayedRange) { - DrawableCarouselItem item = Items[i]; + displayedRange = newDisplayRange; - if (!item.Item.Visible) + if (visibleItems.Count > 0) { - if (!item.IsPresent) - notVisibleCount++; - continue; - } + var toDisplay = visibleItems.GetRange(displayedRange.first, displayedRange.last - displayedRange.first + 1); - float depth = i + (item is DrawableCarouselBeatmapSet ? -Items.Count : 0); - - // Only add if we're not already part of the content. - if (!scrollableContent.Contains(item)) - { - // Makes sure headers are always _below_ items, - // and depth flows downward. - item.Depth = depth; - - switch (item.LoadState) + foreach (var panel in ScrollableContent.Children) { - case LoadState.NotLoaded: - LoadComponentAsync(item); - break; + if (toDisplay.Remove(panel.Item)) + { + // panel already displayed. + continue; + } - case LoadState.Loading: - break; - - default: - scrollableContent.Add(item); - break; + // panel loaded as drawable but not required by visible range. + // remove but only if too far off-screen + if (panel.Y + panel.DrawHeight < visibleUpperBound - distance_offscreen_before_unload || panel.Y > visibleBottomBound + distance_offscreen_before_unload) + { + // may want a fade effect here (could be seen if a huge change happens, like a set with 20 difficulties becomes selected). + panel.ClearTransforms(); + panel.Expire(); + } + } + + // Add those items within the previously found index range that should be displayed. + foreach (var item in toDisplay) + { + var panel = setPool.Get(p => p.Item = item); + + panel.Depth = item.CarouselYPosition; + panel.Y = item.CarouselYPosition; + + ScrollableContent.Add(panel); } - } - else - { - scrollableContent.ChangeChildDepth(item, depth); } } - // this is not actually useful right now, but once we have groups may well be. - if (notVisibleCount > 50) - itemsCache.Invalidate(); + // Finally, if the filtered items have changed, animate drawables to their new locations. + // This is common if a selected/collapsed state has changed. + if (revalidateItems) + { + foreach (DrawableCarouselItem panel in ScrollableContent.Children) + { + panel.MoveToY(panel.Item.CarouselYPosition, 800, Easing.OutQuint); + } + } - // Update externally controlled state of currently visible items - // (e.g. x-offset and opacity). - foreach (DrawableCarouselItem p in scrollableContent.Children) - updateItem(p); + // Update externally controlled state of currently visible items (e.g. x-offset and opacity). + // This is a per-frame update on all drawable panels. + foreach (DrawableCarouselItem item in ScrollableContent.Children) + { + updateItem(item); + + if (item is DrawableCarouselBeatmapSet set) + { + foreach (var diff in set.DrawableBeatmaps) + updateItem(diff, item); + } + } } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); + private readonly CarouselBoundsItem carouselBoundsItem = new CarouselBoundsItem(); - // aggressively dispose "off-screen" items to reduce GC pressure. - foreach (var i in Items) - i.Dispose(); + private (int firstIndex, int lastIndex) getDisplayRange() + { + // Find index range of all items that should be on-screen + carouselBoundsItem.CarouselYPosition = visibleUpperBound - distance_offscreen_to_preload; + int firstIndex = visibleItems.BinarySearch(carouselBoundsItem); + if (firstIndex < 0) firstIndex = ~firstIndex; + + carouselBoundsItem.CarouselYPosition = visibleBottomBound + distance_offscreen_to_preload; + int lastIndex = visibleItems.BinarySearch(carouselBoundsItem); + if (lastIndex < 0) lastIndex = ~lastIndex; + + // as we can't be 100% sure on the size of individual carousel drawables, + // always play it safe and extend bounds by one. + firstIndex = Math.Max(0, firstIndex - 1); + lastIndex = Math.Clamp(lastIndex + 1, firstIndex, Math.Max(0, visibleItems.Count - 1)); + + return (firstIndex, lastIndex); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (!scrollPositionCache.IsValid) + updateScrollPosition(); + } + + private void beatmapRemoved(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var item)) + RemoveBeatmapSet(item); + } + + private void beatmapUpdated(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var item)) + UpdateBeatmapSet(item); + } + + private void beatmapRestored(ValueChangedEvent<WeakReference<BeatmapInfo>> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var b)) + UpdateBeatmapSet(beatmaps.QueryBeatmapSet(s => s.ID == b.BeatmapSetInfoID)); + } + + private void beatmapHidden(ValueChangedEvent<WeakReference<BeatmapInfo>> weakItem) + { + if (weakItem.NewValue.TryGetTarget(out var b)) + UpdateBeatmapSet(beatmaps.QueryBeatmapSet(s => s.ID == b.BeatmapSetInfoID)); } private CarouselBeatmapSet createCarouselSet(BeatmapSetInfo beatmapSet) @@ -526,12 +709,12 @@ namespace osu.Game.Screens.Select // todo: remove the need for this. foreach (var b in beatmapSet.Beatmaps) - { - if (b.Metadata == null) - b.Metadata = beatmapSet.Metadata; - } + b.Metadata ??= beatmapSet.Metadata; - var set = new CarouselBeatmapSet(beatmapSet); + var set = new CarouselBeatmapSet(beatmapSet) + { + GetRecommendedBeatmap = beatmaps => GetRecommendedBeatmap?.Invoke(beatmaps) + }; foreach (var c in set.Beatmaps) { @@ -543,7 +726,7 @@ namespace osu.Game.Screens.Select SelectionChanged?.Invoke(c.Beatmap); itemsCache.Invalidate(); - scrollPositionCache.Invalidate(); + ScrollToSelected(); } }; } @@ -551,71 +734,62 @@ namespace osu.Game.Screens.Select return set; } + private const float panel_padding = 5; + /// <summary> /// Computes the target Y positions for every item in the carousel. /// </summary> /// <returns>The Y position of the currently selected item.</returns> - private void updateItems() + private void updateYPositions() { - Items = root.Drawables.ToList(); - - yPositions.Clear(); + visibleItems.Clear(); float currentY = visibleHalfHeight; - DrawableCarouselBeatmapSet lastSet = null; scrollTarget = null; - foreach (DrawableCarouselItem d in Items) + foreach (CarouselItem item in root.Children) { - if (d.IsPresent) + if (item.Filtered.Value) + continue; + + switch (item) { - switch (d) + case CarouselBeatmapSet set: { - case DrawableCarouselBeatmapSet set: - lastSet = set; + visibleItems.Add(set); + set.CarouselYPosition = currentY; - set.MoveToX(set.Item.State.Value == CarouselItemState.Selected ? -100 : 0, 500, Easing.OutExpo); - set.MoveToY(currentY, 750, Easing.OutExpo); - break; + if (item.State.Value == CarouselItemState.Selected) + { + // scroll position at currentY makes the set panel appear at the very top of the carousel's screen space + // move down by half of visible height (height of the carousel's visible extent, including semi-transparent areas) + // then reapply the top semi-transparent area (because carousel's screen space starts below it) + scrollTarget = currentY + DrawableCarouselBeatmapSet.HEIGHT - visibleHalfHeight + BleedTop; - case DrawableCarouselBeatmap beatmap: - if (beatmap.Item.State.Value == CarouselItemState.Selected) - scrollTarget = currentY + beatmap.DrawHeight / 2 - DrawHeight / 2; - - void performMove(float y, float? startY = null) + foreach (var b in set.Beatmaps) { - if (startY != null) beatmap.MoveTo(new Vector2(0, startY.Value)); - beatmap.MoveToX(beatmap.Item.State.Value == CarouselItemState.Selected ? -50 : 0, 500, Easing.OutExpo); - beatmap.MoveToY(y, 750, Easing.OutExpo); + if (!b.Visible) + continue; + + if (b.State.Value == CarouselItemState.Selected) + { + scrollTarget += b.TotalHeight / 2; + break; + } + + scrollTarget += b.TotalHeight; } + } - Debug.Assert(lastSet != null); - - float? setY = null; - if (!d.IsLoaded || beatmap.Alpha == 0) // can't use IsPresent due to DrawableCarouselItem override. - setY = lastSet.Y + lastSet.DrawHeight + 5; - - if (d.IsLoaded) - performMove(currentY, setY); - else - { - float y = currentY; - d.OnLoadComplete += _ => performMove(y, setY); - } - - break; + currentY += set.TotalHeight + panel_padding; + break; } } - - yPositions.Add(currentY); - - if (d.Item.Visible) - currentY += d.DrawHeight + 5; } currentY += visibleHalfHeight; - scrollableContent.Height = currentY; + ScrollableContent.Height = currentY; if (BeatmapSetsLoaded && (selectedBeatmapSet == null || selectedBeatmap == null || selectedBeatmapSet.State.Value != CarouselItemState.Selected)) { @@ -626,10 +800,22 @@ namespace osu.Game.Screens.Select itemsCache.Validate(); } + private bool firstScroll = true; + private void updateScrollPosition() { - if (scrollTarget != null) ScrollTo(scrollTarget.Value); - scrollPositionCache.Validate(); + if (scrollTarget != null) + { + if (firstScroll) + { + // reduce movement when first displaying the carousel. + scroll.ScrollTo(scrollTarget.Value - 200, false); + firstScroll = false; + } + + scroll.ScrollTo(scrollTarget.Value); + scrollPositionCache.Validate(); + } } /// <summary> @@ -644,8 +830,8 @@ namespace osu.Game.Screens.Select { // The radius of the circle the carousel moves on. const float circle_radius = 3; - double discriminant = Math.Max(0, circle_radius * circle_radius - dist * dist); - float x = (circle_radius - (float)Math.Sqrt(discriminant)) * halfHeight; + float discriminant = MathF.Max(0, circle_radius * circle_radius - dist * dist); + float x = (circle_radius - MathF.Sqrt(discriminant)) * halfHeight; return 125 + x; } @@ -654,21 +840,31 @@ namespace osu.Game.Screens.Select /// Update a item's x position and multiplicative alpha based on its y position and /// the current scroll position. /// </summary> - /// <param name="p">The item to be updated.</param> - private void updateItem(DrawableCarouselItem p) + /// <param name="item">The item to be updated.</param> + /// <param name="parent">For nested items, the parent of the item to be updated.</param> + private void updateItem(DrawableCarouselItem item, DrawableCarouselItem parent = null) { - float itemDrawY = p.Position.Y - visibleUpperBound + p.DrawHeight / 2; + Vector2 posInScroll = ScrollableContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre); + float itemDrawY = posInScroll.Y - visibleUpperBound; float dist = Math.Abs(1f - itemDrawY / visibleHalfHeight); - // Setting the origin position serves as an additive position on top of potential - // local transformation we may want to apply (e.g. when a item gets selected, we - // may want to smoothly transform it leftwards.) - p.OriginPosition = new Vector2(-offsetX(dist, visibleHalfHeight), 0); + // adjusting the item's overall X position can cause it to become masked away when + // child items (difficulties) are still visible. + item.Header.X = offsetX(dist, visibleHalfHeight) - (parent?.X ?? 0); // We are applying a multiplicative alpha (which is internally done by nesting an // additional container and setting that container's alpha) such that we can - // layer transformations on top, with a similar reasoning to the previous comment. - p.SetMultiplicativeAlpha(MathHelper.Clamp(1.75f - 1.5f * dist, 0, 1)); + // layer alpha transformations on top. + item.SetMultiplicativeAlpha(Math.Clamp(1.75f - 1.5f * dist, 0, 1)); + } + + /// <summary> + /// A carousel item strictly used for binary search purposes. + /// </summary> + private class CarouselBoundsItem : CarouselItem + { + public override DrawableCarouselItem CreateDrawableRepresentation() => + throw new NotImplementedException(); } private class CarouselRoot : CarouselGroupEagerSelect @@ -677,16 +873,68 @@ namespace osu.Game.Screens.Select public CarouselRoot(BeatmapCarousel carousel) { + // root should always remain selected. if not, PerformSelection will not be called. + State.Value = CarouselItemState.Selected; + State.ValueChanged += state => State.Value = CarouselItemState.Selected; + this.carousel = carousel; } protected override void PerformSelection() { - if (LastSelected == null) - carousel.SelectNextRandom(); + if (LastSelected == null || LastSelected.Filtered.Value) + carousel?.SelectNextRandom(); else base.PerformSelection(); } } + + private class CarouselScrollContainer : OsuScrollContainer + { + private bool rightMouseScrollBlocked; + + /// <summary> + /// Whether the last scroll event was user triggered, directly on the scroll container. + /// </summary> + public bool UserScrolling { get; private set; } + + // ReSharper disable once OptionalParameterHierarchyMismatch 2020.3 EAP4 bug. (https://youtrack.jetbrains.com/issue/RSRP-481535?p=RIDER-51910) + protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) + { + UserScrolling = true; + base.OnUserScroll(value, animated, distanceDecay); + } + + public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null) + { + UserScrolling = false; + base.ScrollTo(value, animated, distanceDecay); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Right) + { + // we need to block right click absolute scrolling when hovering a carousel item so context menus can display. + // this can be reconsidered when we have an alternative to right click scrolling. + if (GetContainingInputManager().HoveredDrawables.OfType<DrawableCarouselItem>().Any()) + { + rightMouseScrollBlocked = true; + return false; + } + } + + rightMouseScrollBlocked = false; + return base.OnMouseDown(e); + } + + protected override bool OnDragStart(DragStartEvent e) + { + if (rightMouseScrollBlocked) + return false; + + return base.OnDragStart(e); + } + } } } diff --git a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs index c9b6ca7bb3..b32416b361 100644 --- a/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs +++ b/osu.Game/Screens/Select/BeatmapClearScoresDialog.cs @@ -14,7 +14,8 @@ namespace osu.Game.Screens.Select { public class BeatmapClearScoresDialog : PopupDialog { - private ScoreManager scoreManager; + [Resolved] + private ScoreManager scoreManager { get; set; } public BeatmapClearScoresDialog(BeatmapInfo beatmap, Action onCompletion) { @@ -38,11 +39,5 @@ namespace osu.Game.Screens.Select }, }; } - - [BackgroundDependencyLoader] - private void load(ScoreManager scoreManager) - { - this.scoreManager = scoreManager; - } } } diff --git a/osu.Game/Screens/Select/BeatmapDetailArea.cs b/osu.Game/Screens/Select/BeatmapDetailArea.cs index b66a2ffe0f..89ae92ec91 100644 --- a/osu.Game/Screens/Select/BeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/BeatmapDetailArea.cs @@ -2,89 +2,105 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; -using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Screens.Select { - public class BeatmapDetailArea : Container + public abstract class BeatmapDetailArea : Container { private const float details_padding = 10; - private readonly Container content; - protected override Container<Drawable> Content => content; - - public readonly BeatmapDetails Details; - public readonly BeatmapLeaderboard Leaderboard; - private WorkingBeatmap beatmap; - public WorkingBeatmap Beatmap + public virtual WorkingBeatmap Beatmap { get => beatmap; set { beatmap = value; - Leaderboard.Beatmap = beatmap?.BeatmapInfo; - Details.Beatmap = beatmap?.BeatmapInfo; + + Details.Beatmap = value?.BeatmapInfo; } } - public BeatmapDetailArea() + public readonly BeatmapDetails Details; + + protected Bindable<BeatmapDetailAreaTabItem> CurrentTab => tabControl.Current; + + protected Bindable<bool> CurrentModsFilter => tabControl.CurrentModsFilter; + + private readonly Container content; + protected override Container<Drawable> Content => content; + + private readonly BeatmapDetailAreaTabControl tabControl; + + protected BeatmapDetailArea() { AddRangeInternal(new Drawable[] { - new BeatmapDetailAreaTabControl - { - RelativeSizeAxes = Axes.X, - OnFilter = (tab, mods) => - { - Leaderboard.FilterMods = mods; - - switch (tab) - { - case BeatmapDetailTab.Details: - Details.Show(); - Leaderboard.Hide(); - break; - - default: - Details.Hide(); - Leaderboard.Scope = (BeatmapLeaderboardScope)tab - 1; - Leaderboard.Show(); - break; - } - }, - }, content = new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = BeatmapDetailAreaTabControl.HEIGHT }, + Child = Details = new BeatmapDetails + { + RelativeSizeAxes = Axes.X, + Alpha = 0, + Margin = new MarginPadding { Top = details_padding }, + } }, - }); - - AddRange(new Drawable[] - { - Details = new BeatmapDetails + tabControl = new BeatmapDetailAreaTabControl { RelativeSizeAxes = Axes.X, - Alpha = 0, - Margin = new MarginPadding { Top = details_padding }, + TabItems = CreateTabItems(), + OnFilter = OnTabChanged, }, - Leaderboard = new BeatmapLeaderboard - { - RelativeSizeAxes = Axes.Both, - } }); } + /// <summary> + /// Refreshes the currently-displayed details. + /// </summary> + public virtual void Refresh() + { + } + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); Details.Height = Math.Min(DrawHeight - details_padding * 3 - BeatmapDetailAreaTabControl.HEIGHT, 450); } + + /// <summary> + /// Invoked when a new tab is selected. + /// </summary> + /// <param name="tab">The tab that was selected.</param> + /// <param name="selectedMods">Whether the currently-selected mods should be considered.</param> + protected virtual void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) + { + switch (tab) + { + case BeatmapDetailAreaDetailTabItem _: + Details.Show(); + break; + + default: + Details.Hide(); + break; + } + } + + /// <summary> + /// Creates the tabs to be displayed. + /// </summary> + /// <returns>The tabs.</returns> + protected virtual BeatmapDetailAreaTabItem[] CreateTabItems() => new BeatmapDetailAreaTabItem[] + { + new BeatmapDetailAreaDetailTabItem(), + }; } } diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs new file mode 100644 index 0000000000..7376cb4708 --- /dev/null +++ b/osu.Game/Screens/Select/BeatmapDetailAreaDetailTabItem.cs @@ -0,0 +1,10 @@ +// 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. + +namespace osu.Game.Screens.Select +{ + public class BeatmapDetailAreaDetailTabItem : BeatmapDetailAreaTabItem + { + public override string Name => "Details"; + } +} diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs new file mode 100644 index 0000000000..066944e9d2 --- /dev/null +++ b/osu.Game/Screens/Select/BeatmapDetailAreaLeaderboardTabItem.cs @@ -0,0 +1,22 @@ +// 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. + +using System; + +namespace osu.Game.Screens.Select +{ + public class BeatmapDetailAreaLeaderboardTabItem<TScope> : BeatmapDetailAreaTabItem + where TScope : Enum + { + public override string Name => Scope.ToString(); + + public override bool FilterableByMods => true; + + public readonly TScope Scope; + + public BeatmapDetailAreaLeaderboardTabItem(TScope scope) + { + Scope = scope; + } + } +} diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs b/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs index 7f82d3cc12..df8c68a0dd 100644 --- a/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs +++ b/osu.Game/Screens/Select/BeatmapDetailAreaTabControl.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,13 +18,31 @@ namespace osu.Game.Screens.Select { public class BeatmapDetailAreaTabControl : Container { - public static readonly float HEIGHT = 24; + public const float HEIGHT = 24; + + public Bindable<BeatmapDetailAreaTabItem> Current + { + get => tabs.Current; + set => tabs.Current = value; + } + + public Bindable<bool> CurrentModsFilter + { + get => modsCheckbox.Current; + set => modsCheckbox.Current = value; + } + + public Action<BeatmapDetailAreaTabItem, bool> OnFilter; // passed the selected tab and if mods is checked + + public IReadOnlyList<BeatmapDetailAreaTabItem> TabItems + { + get => tabs.Items; + set => tabs.Items = value; + } + private readonly OsuTabControlCheckbox modsCheckbox; - private readonly OsuTabControl<BeatmapDetailTab> tabs; - - public Action<BeatmapDetailTab, bool> OnFilter; //passed the selected tab and if mods is checked - - private Bindable<BeatmapDetailTab> selectedTab; + private readonly OsuTabControl<BeatmapDetailAreaTabItem> tabs; + private readonly Container tabsContainer; public BeatmapDetailAreaTabControl() { @@ -39,17 +58,22 @@ namespace osu.Game.Screens.Select Height = 1, Colour = Color4.White.Opacity(0.2f), }, - tabs = new OsuTabControl<BeatmapDetailTab> + tabsContainer = new Container { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.Both, + Child = tabs = new OsuTabControl<BeatmapDetailAreaTabItem> + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + IsSwitchable = true, + }, }, modsCheckbox = new OsuTabControlCheckbox { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Text = @"Mods", + Text = @"Selected Mods", Alpha = 0, }, }; @@ -62,27 +86,22 @@ namespace osu.Game.Screens.Select private void load(OsuColour colour, OsuConfigManager config) { modsCheckbox.AccentColour = tabs.AccentColour = colour.YellowLight; - - selectedTab = config.GetBindable<BeatmapDetailTab>(OsuSetting.BeatmapDetailTab); - - tabs.Current.BindTo(selectedTab); - tabs.Current.TriggerChange(); } private void invokeOnFilter() { OnFilter?.Invoke(tabs.Current.Value, modsCheckbox.Current.Value); - modsCheckbox.FadeTo(tabs.Current.Value == BeatmapDetailTab.Details ? 0 : 1, 200, Easing.OutQuint); + if (tabs.Current.Value.FilterableByMods) + { + modsCheckbox.FadeTo(1, 200, Easing.OutQuint); + tabsContainer.Padding = new MarginPadding { Right = 100 }; + } + else + { + modsCheckbox.FadeTo(0, 200, Easing.OutQuint); + tabsContainer.Padding = new MarginPadding(); + } } } - - public enum BeatmapDetailTab - { - Details, - Local, - Country, - Global, - Friends - } } diff --git a/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs b/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs new file mode 100644 index 0000000000..f28e5a7c22 --- /dev/null +++ b/osu.Game/Screens/Select/BeatmapDetailAreaTabItem.cs @@ -0,0 +1,35 @@ +// 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. + +using System; + +namespace osu.Game.Screens.Select +{ + public abstract class BeatmapDetailAreaTabItem : IEquatable<BeatmapDetailAreaTabItem> + { + /// <summary> + /// The name of this tab, to be displayed in the tab control. + /// </summary> + public abstract string Name { get; } + + /// <summary> + /// Whether the contents of this tab can be filtered by the user's currently-selected mods. + /// </summary> + public virtual bool FilterableByMods => false; + + public override string ToString() => Name; + + public bool Equals(BeatmapDetailAreaTabItem other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return Name == other.Name; + } + + public override int GetHashCode() + { + return Name != null ? Name.GetHashCode() : 0; + } + } +} diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 23dd87d8ea..71f78c5c95 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -7,7 +7,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; using System.Linq; using osu.Game.Online.API; using osu.Framework.Threading; @@ -17,6 +16,7 @@ using osu.Game.Screens.Select.Details; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; @@ -35,9 +35,10 @@ namespace osu.Game.Screens.Select private readonly MetadataSection description, source, tags; private readonly Container failRetryContainer; private readonly FailRetryGraph failRetryGraph; - private readonly DimmedLoadingAnimation loading; + private readonly LoadingLayer loading; - private IAPIProvider api; + [Resolved] + private IAPIProvider api { get; set; } private ScheduledDelegate pendingBeatmapSwitch; @@ -62,6 +63,8 @@ namespace osu.Game.Screens.Select public BeatmapDetails() { + Container content; + Children = new Drawable[] { new Box @@ -69,7 +72,7 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.Both, Colour = Color4.Black.Opacity(0.5f), }, - new Container + content = new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Horizontal = spacing }, @@ -156,19 +159,10 @@ namespace osu.Game.Screens.Select }, }, }, - loading = new DimmedLoadingAnimation - { - RelativeSizeAxes = Axes.Both, - }, + loading = new LoadingLayer(content), }; } - [BackgroundDependencyLoader] - private void load(IAPIProvider api) - { - this.api = api; - } - protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); @@ -207,7 +201,7 @@ namespace osu.Game.Screens.Select Schedule(() => { if (beatmap != requestedBeatmap) - //the beatmap has been changed since we started the lookup. + // the beatmap has been changed since we started the lookup. return; var b = res.ToBeatmap(rulesets); @@ -228,7 +222,7 @@ namespace osu.Game.Screens.Select Schedule(() => { if (beatmap != requestedBeatmap) - //the beatmap has been changed since we started the lookup. + // the beatmap has been changed since we started the lookup. return; updateMetrics(); @@ -242,7 +236,7 @@ namespace osu.Game.Screens.Select private void updateMetrics() { var hasRatings = beatmap?.BeatmapSet?.Metrics?.Ratings?.Any() ?? false; - var hasRetriesFails = (beatmap?.Metrics?.Retries?.Any() ?? false) && (beatmap?.Metrics.Fails?.Any() ?? false); + var hasRetriesFails = (beatmap?.Metrics?.Retries?.Any() ?? false) || (beatmap?.Metrics?.Fails?.Any() ?? false); if (hasRatings) { @@ -306,6 +300,7 @@ namespace osu.Game.Screens.Select public MetadataSection(string title) { + Alpha = 0; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -365,35 +360,5 @@ namespace osu.Game.Screens.Select }); } } - - private class DimmedLoadingAnimation : VisibilityContainer - { - private readonly LoadingAnimation loading; - - public DimmedLoadingAnimation() - { - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.5f), - }, - loading = new LoadingAnimation(), - }; - } - - protected override void PopIn() - { - this.FadeIn(transition_duration, Easing.OutQuint); - loading.Show(); - } - - protected override void PopOut() - { - this.FadeOut(transition_duration, Easing.OutQuint); - loading.Hide(); - } - } } } diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 2551ffe2fc..04c1f6efe4 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using JetBrains.Annotations; using osuTK; using osuTK.Graphics; @@ -13,7 +14,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; -using osu.Framework.MathUtils; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; @@ -23,18 +24,27 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Ranking.Expanded; namespace osu.Game.Screens.Select { - public class BeatmapInfoWedge : OverlayContainer + public class BeatmapInfoWedge : VisibilityContainer { - private static readonly Vector2 wedged_container_shear = new Vector2(0.15f, 0); + private const float shear_width = 36.75f; + + private static readonly Vector2 wedged_container_shear = new Vector2(shear_width / SongSelect.WEDGE_HEIGHT, 0); private readonly IBindable<RulesetInfo> ruleset = new Bindable<RulesetInfo>(); + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + + private IBindable<StarDifficulty> beatmapDifficulty; + protected BufferedWedgeInfo Info; public BeatmapInfoWedge() @@ -60,8 +70,6 @@ namespace osu.Game.Screens.Select ruleset.ValueChanged += _ => updateDisplay(); } - protected override bool BlockPositionalInput => false; - protected override void PopIn() { this.MoveToX(0, 800, Easing.OutQuint); @@ -78,6 +86,8 @@ namespace osu.Game.Screens.Select private WorkingBeatmap beatmap; + private CancellationTokenSource cancellationSource; + public WorkingBeatmap Beatmap { get => beatmap; @@ -86,6 +96,13 @@ namespace osu.Game.Screens.Select if (beatmap == value) return; beatmap = value; + cancellationSource?.Cancel(); + cancellationSource = new CancellationTokenSource(); + + beatmapDifficulty?.UnbindAll(); + beatmapDifficulty = difficultyCache.GetBindableDifficulty(beatmap.BeatmapInfo, cancellationSource.Token); + beatmapDifficulty.BindValueChanged(_ => updateDisplay()); + updateDisplay(); } } @@ -96,33 +113,44 @@ namespace osu.Game.Screens.Select private void updateDisplay() { - void removeOldInfo() - { - State.Value = beatmap == null ? Visibility.Hidden : Visibility.Visible; + Scheduler.AddOnce(perform); - Info?.FadeOut(250); - Info?.Expire(); - Info = null; + void perform() + { + void removeOldInfo() + { + State.Value = beatmap == null ? Visibility.Hidden : Visibility.Visible; + + Info?.FadeOut(250); + Info?.Expire(); + Info = null; + } + + if (beatmap == null) + { + removeOldInfo(); + return; + } + + LoadComponentAsync(loadingInfo = new BufferedWedgeInfo(beatmap, ruleset.Value, beatmapDifficulty.Value) + { + Shear = -Shear, + Depth = Info?.Depth + 1 ?? 0 + }, loaded => + { + // ensure we are the most recent loaded wedge. + if (loaded != loadingInfo) return; + + removeOldInfo(); + Add(Info = loaded); + }); } + } - if (beatmap == null) - { - removeOldInfo(); - return; - } - - LoadComponentAsync(loadingInfo = new BufferedWedgeInfo(beatmap, ruleset.Value) - { - Shear = -Shear, - Depth = Info?.Depth + 1 ?? 0 - }, loaded => - { - // ensure we are the most recent loaded wedge. - if (loaded != loadingInfo) return; - - removeOldInfo(); - Add(Info = loaded); - }); + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + cancellationSource?.Cancel(); } public class BufferedWedgeInfo : BufferedContainer @@ -139,11 +167,14 @@ namespace osu.Game.Screens.Select private readonly WorkingBeatmap beatmap; private readonly RulesetInfo ruleset; + private readonly StarDifficulty starDifficulty; - public BufferedWedgeInfo(WorkingBeatmap beatmap, RulesetInfo userRuleset) + public BufferedWedgeInfo(WorkingBeatmap beatmap, RulesetInfo userRuleset, StarDifficulty difficulty) + : base(pixelSnapping: true) { this.beatmap = beatmap; ruleset = userRuleset ?? beatmap.BeatmapInfo.Ruleset; + starDifficulty = difficulty; } [BackgroundDependencyLoader] @@ -152,8 +183,8 @@ namespace osu.Game.Screens.Select var beatmapInfo = beatmap.BeatmapInfo; var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); - PixelSnapping = true; CacheDrawnFrameBuffer = true; + RelativeSizeAxes = Axes.Both; titleBinding = localisation.GetLocalisedString(new LocalisedString((metadata.TitleUnicode, metadata.Title))); @@ -187,7 +218,7 @@ namespace osu.Game.Screens.Select }, }, }, - new DifficultyColourBar(beatmapInfo) + new DifficultyColourBar(starDifficulty) { RelativeSizeAxes = Axes.Y, Width = 20, @@ -198,14 +229,17 @@ namespace osu.Game.Screens.Select Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, Direction = FillDirection.Vertical, - Margin = new MarginPadding { Top = 10, Left = 25, Right = 10, Bottom = 20 }, - AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = 10, Left = 25, Right = shear_width * 2.5f }, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, Children = new Drawable[] { VersionLabel = new OsuSpriteText { Text = beatmapInfo.Version, Font = OsuFont.GetFont(size: 24, italics: true), + RelativeSizeAxes = Axes.X, + Truncate = true, }, } }, @@ -215,12 +249,22 @@ namespace osu.Game.Screens.Select Anchor = Anchor.TopRight, Origin = Anchor.TopRight, Direction = FillDirection.Vertical, - Margin = new MarginPadding { Top = 14, Left = 10, Right = 18, Bottom = 20 }, + Padding = new MarginPadding { Top = 14, Right = shear_width / 2 }, AutoSizeAxes = Axes.Both, - Children = new Drawable[] + Shear = wedged_container_shear, + Children = new[] { + createStarRatingDisplay(starDifficulty).With(display => + { + display.Anchor = Anchor.TopRight; + display.Origin = Anchor.TopRight; + display.Shear = -wedged_container_shear; + }), StatusPill = new BeatmapSetOnlineStatusPill { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Shear = -wedged_container_shear, TextSize = 11, TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, Status = beatmapInfo.Status, @@ -232,19 +276,24 @@ namespace osu.Game.Screens.Select Name = "Centre-aligned metadata", Anchor = Anchor.CentreLeft, Origin = Anchor.TopLeft, - Y = -22, + Y = -7, Direction = FillDirection.Vertical, - Margin = new MarginPadding { Top = 15, Left = 25, Right = 10, Bottom = 20 }, - AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 25, Right = shear_width }, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, Children = new Drawable[] { TitleLabel = new OsuSpriteText { Font = OsuFont.GetFont(size: 28, italics: true), + RelativeSizeAxes = Axes.X, + Truncate = true, }, ArtistLabel = new OsuSpriteText { Font = OsuFont.GetFont(size: 17, italics: true), + RelativeSizeAxes = Axes.X, + Truncate = true, }, MapperContainer = new FillFlowContainer { @@ -272,6 +321,13 @@ namespace osu.Game.Screens.Select StatusPill.Hide(); } + private static Drawable createStarRatingDisplay(StarDifficulty difficulty) => difficulty.Stars > 0 + ? new StarRatingDisplay(difficulty) + { + Margin = new MarginPadding { Bottom = 5 } + } + : Empty(); + private void setMetadata(string source) { ArtistLabel.Text = artistBinding.Value; @@ -290,31 +346,38 @@ namespace osu.Game.Screens.Select labels.Add(new InfoLabel(new BeatmapStatistic { Name = "Length", - Icon = FontAwesome.Regular.Clock, + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), Content = TimeSpan.FromMilliseconds(b.BeatmapInfo.Length).ToString(@"m\:ss"), })); labels.Add(new InfoLabel(new BeatmapStatistic { Name = "BPM", - Icon = FontAwesome.Regular.Circle, + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Bpm), Content = getBPMRange(b), })); - IBeatmap playableBeatmap; - try { - // Try to get the beatmap with the user's ruleset - playableBeatmap = beatmap.GetPlayableBeatmap(ruleset, Array.Empty<Mod>()); - } - 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<Mod>()); - } + IBeatmap playableBeatmap; - labels.AddRange(playableBeatmap.GetStatistics().Select(s => new InfoLabel(s))); + try + { + // Try to get the beatmap with the user's ruleset + playableBeatmap = beatmap.GetPlayableBeatmap(ruleset, Array.Empty<Mod>()); + } + 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<Mod>()); + } + + labels.AddRange(playableBeatmap.GetStatistics().Select(s => new InfoLabel(s))); + } + catch (Exception e) + { + Logger.Error(e, "Could not load beatmap successfully!"); + } } return labels.ToArray(); @@ -353,7 +416,7 @@ namespace osu.Game.Screens.Select public class InfoLabel : Container, IHasTooltip { - public string TooltipText { get; private set; } + public string TooltipText { get; } public InfoLabel(BeatmapStatistic statistic) { @@ -374,7 +437,7 @@ namespace osu.Game.Screens.Select Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Colour = OsuColour.FromHex(@"441288"), + Colour = Color4Extensions.FromHex(@"441288"), Icon = FontAwesome.Solid.Square, Rotation = 45, }, @@ -383,10 +446,18 @@ namespace osu.Game.Screens.Select Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Scale = new Vector2(0.8f), - Colour = OsuColour.FromHex(@"f7dd55"), - Icon = statistic.Icon, + Colour = Color4Extensions.FromHex(@"f7dd55"), + Icon = FontAwesome.Regular.Circle, + Size = new Vector2(0.8f) }, + statistic.CreateIcon().With(i => + { + i.Anchor = Anchor.Centre; + i.Origin = Anchor.Centre; + i.RelativeSizeAxes = Axes.Both; + i.Colour = Color4Extensions.FromHex(@"f7dd55"); + i.Size = new Vector2(0.64f); + }), } }, new OsuSpriteText @@ -402,31 +473,35 @@ namespace osu.Game.Screens.Select } } - private class DifficultyColourBar : DifficultyColouredContainer + private class DifficultyColourBar : Container { - public DifficultyColourBar(BeatmapInfo beatmap) - : base(beatmap) + private readonly StarDifficulty difficulty; + + public DifficultyColourBar(StarDifficulty difficulty) { + this.difficulty = difficulty; } [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours) { const float full_opacity_ratio = 0.7f; + var difficultyColour = colours.ForDifficultyRating(difficulty.DifficultyRating); + Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = AccentColour, + Colour = difficultyColour, Width = full_opacity_ratio, }, new Box { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Both, - Colour = AccentColour, + Colour = difficultyColour, Alpha = 0.5f, X = full_opacity_ratio, Width = 1 - full_opacity_ratio, diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 712ab7b571..1aab50037a 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -10,6 +10,8 @@ namespace osu.Game.Screens.Select.Carousel { public class CarouselBeatmap : CarouselItem { + public override float TotalHeight => DrawableCarouselBeatmap.HEIGHT; + public readonly BeatmapInfo Beatmap; public CarouselBeatmap(BeatmapInfo beatmap) @@ -18,18 +20,58 @@ namespace osu.Game.Screens.Select.Carousel State.Value = CarouselItemState.Collapsed; } - protected override DrawableCarouselItem CreateDrawableRepresentation() => new DrawableCarouselBeatmap(this); + public override DrawableCarouselItem CreateDrawableRepresentation() => new DrawableCarouselBeatmap(this); public override void Filter(FilterCriteria criteria) { base.Filter(criteria); - bool match = criteria.Ruleset == null || Beatmap.RulesetID == criteria.Ruleset.ID || (Beatmap.RulesetID == 0 && criteria.Ruleset.ID > 0 && criteria.AllowConvertedBeatmaps); + bool match = + criteria.Ruleset == null || + Beatmap.RulesetID == criteria.Ruleset.ID || + (Beatmap.RulesetID == 0 && criteria.Ruleset.ID > 0 && criteria.AllowConvertedBeatmaps); - foreach (var criteriaTerm in criteria.SearchTerms) - match &= - Beatmap.Metadata.SearchableTerms.Any(term => term.IndexOf(criteriaTerm, StringComparison.InvariantCultureIgnoreCase) >= 0) || - Beatmap.Version.IndexOf(criteriaTerm, StringComparison.InvariantCultureIgnoreCase) >= 0; + if (Beatmap.BeatmapSet?.Equals(criteria.SelectedBeatmapSet) == true) + { + // only check ruleset equality or convertability for selected beatmap + Filtered.Value = !match; + return; + } + + match &= !criteria.StarDifficulty.HasFilter || criteria.StarDifficulty.IsInRange(Beatmap.StarDifficulty); + match &= !criteria.ApproachRate.HasFilter || criteria.ApproachRate.IsInRange(Beatmap.BaseDifficulty.ApproachRate); + match &= !criteria.DrainRate.HasFilter || criteria.DrainRate.IsInRange(Beatmap.BaseDifficulty.DrainRate); + match &= !criteria.CircleSize.HasFilter || criteria.CircleSize.IsInRange(Beatmap.BaseDifficulty.CircleSize); + match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(Beatmap.Length); + match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(Beatmap.BPM); + + match &= !criteria.BeatDivisor.HasFilter || criteria.BeatDivisor.IsInRange(Beatmap.BeatDivisor); + match &= !criteria.OnlineStatus.HasFilter || criteria.OnlineStatus.IsInRange(Beatmap.Status); + + match &= !criteria.Creator.HasFilter || criteria.Creator.Matches(Beatmap.Metadata.AuthorString); + match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(Beatmap.Metadata.Artist) || + criteria.Artist.Matches(Beatmap.Metadata.ArtistUnicode); + + match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(Beatmap.StarDifficulty); + + if (match) + { + var terms = Beatmap.SearchableTerms; + + foreach (var criteriaTerm in criteria.SearchTerms) + match &= terms.Any(term => term.Contains(criteriaTerm, StringComparison.InvariantCultureIgnoreCase)); + + // if a match wasn't found via text matching of terms, do a second catch-all check matching against online IDs. + // this should be done after text matching so we can prioritise matching numbers in metadata. + if (!match && criteria.SearchNumber.HasValue) + { + match = (Beatmap.OnlineBeatmapID == criteria.SearchNumber.Value) || + (Beatmap.BeatmapSet?.OnlineBeatmapSetID == criteria.SearchNumber.Value); + } + } + + if (match) + match &= criteria.Collection?.Beatmaps.Contains(Beatmap) ?? true; Filtered.Value = !match; } diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs index 5a3996bb49..7935debac7 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs @@ -12,10 +12,27 @@ namespace osu.Game.Screens.Select.Carousel { public class CarouselBeatmapSet : CarouselGroupEagerSelect { + public override float TotalHeight + { + get + { + switch (State.Value) + { + case CarouselItemState.Selected: + return DrawableCarouselBeatmapSet.HEIGHT + Children.Count(c => c.Visible) * DrawableCarouselBeatmap.HEIGHT; + + default: + return DrawableCarouselBeatmapSet.HEIGHT; + } + } + } + public IEnumerable<CarouselBeatmap> Beatmaps => InternalChildren.OfType<CarouselBeatmap>(); public BeatmapSetInfo BeatmapSet; + public Func<IEnumerable<BeatmapInfo>, BeatmapInfo> GetRecommendedBeatmap; + public CarouselBeatmapSet(BeatmapSetInfo beatmapSet) { BeatmapSet = beatmapSet ?? throw new ArgumentNullException(nameof(beatmapSet)); @@ -26,7 +43,16 @@ namespace osu.Game.Screens.Select.Carousel .ForEach(AddChild); } - protected override DrawableCarouselItem CreateDrawableRepresentation() => new DrawableCarouselBeatmapSet(this); + protected override CarouselItem GetNextToSelect() + { + if (LastSelected == null) + { + if (GetRecommendedBeatmap?.Invoke(Children.OfType<CarouselBeatmap>().Where(b => !b.Filtered.Value).Select(b => b.Beatmap)) is BeatmapInfo recommended) + return Children.OfType<CarouselBeatmap>().First(b => b.Beatmap == recommended); + } + + return base.GetNextToSelect(); + } public override int CompareTo(FilterCriteria criteria, CarouselItem other) { @@ -37,28 +63,45 @@ namespace osu.Game.Screens.Select.Carousel { default: case SortMode.Artist: - return string.Compare(BeatmapSet.Metadata.Artist, otherSet.BeatmapSet.Metadata.Artist, StringComparison.InvariantCultureIgnoreCase); + return string.Compare(BeatmapSet.Metadata.Artist, otherSet.BeatmapSet.Metadata.Artist, StringComparison.OrdinalIgnoreCase); case SortMode.Title: - return string.Compare(BeatmapSet.Metadata.Title, otherSet.BeatmapSet.Metadata.Title, StringComparison.InvariantCultureIgnoreCase); + return string.Compare(BeatmapSet.Metadata.Title, otherSet.BeatmapSet.Metadata.Title, StringComparison.OrdinalIgnoreCase); case SortMode.Author: - return string.Compare(BeatmapSet.Metadata.Author.Username, otherSet.BeatmapSet.Metadata.Author.Username, StringComparison.InvariantCultureIgnoreCase); + return string.Compare(BeatmapSet.Metadata.Author.Username, otherSet.BeatmapSet.Metadata.Author.Username, StringComparison.OrdinalIgnoreCase); case SortMode.DateAdded: return otherSet.BeatmapSet.DateAdded.CompareTo(BeatmapSet.DateAdded); case SortMode.BPM: - return BeatmapSet.MaxBPM.CompareTo(otherSet.BeatmapSet.MaxBPM); + return compareUsingAggregateMax(otherSet, b => b.BPM); case SortMode.Length: - return BeatmapSet.MaxLength.CompareTo(otherSet.BeatmapSet.MaxLength); + return compareUsingAggregateMax(otherSet, b => b.Length); case SortMode.Difficulty: - return BeatmapSet.MaxStarDifficulty.CompareTo(otherSet.BeatmapSet.MaxStarDifficulty); + return compareUsingAggregateMax(otherSet, b => b.StarDifficulty); } } + /// <summary> + /// All beatmaps which are not filtered and valid for display. + /// </summary> + protected IEnumerable<BeatmapInfo> ValidBeatmaps => Beatmaps.Where(b => !b.Filtered.Value || b.State.Value == CarouselItemState.Selected).Select(b => b.Beatmap); + + private int compareUsingAggregateMax(CarouselBeatmapSet other, Func<BeatmapInfo, double> func) + { + var ourBeatmaps = ValidBeatmaps.Any(); + var otherBeatmaps = other.ValidBeatmaps.Any(); + + if (!ourBeatmaps && !otherBeatmaps) return 0; + if (!ourBeatmaps) return -1; + if (!otherBeatmaps) return 1; + + return ValidBeatmaps.Max(func).CompareTo(other.ValidBeatmaps.Max(func)); + } + public override void Filter(FilterCriteria criteria) { base.Filter(criteria); diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs index 6ebd2d41cc..b85e868b89 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroup.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroup.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; namespace osu.Game.Screens.Select.Carousel { @@ -10,7 +11,7 @@ namespace osu.Game.Screens.Select.Carousel /// </summary> public class CarouselGroup : CarouselItem { - protected override DrawableCarouselItem CreateDrawableRepresentation() => null; + public override DrawableCarouselItem CreateDrawableRepresentation() => null; public IReadOnlyList<CarouselItem> Children => InternalChildren; @@ -22,22 +23,6 @@ namespace osu.Game.Screens.Select.Carousel /// </summary> private ulong currentChildID; - public override List<DrawableCarouselItem> Drawables - { - get - { - var drawables = base.Drawables; - - // if we are explicitly not present, don't ever present children. - // without this check, children drawables can potentially be presented without their group header. - if (DrawableRepresentation.Value?.IsPresent == false) return drawables; - - foreach (var c in InternalChildren) - drawables.AddRange(c.Drawables); - return drawables; - } - } - public virtual void RemoveChild(CarouselItem i) { InternalChildren.Remove(i); @@ -81,12 +66,10 @@ namespace osu.Game.Screens.Select.Carousel { base.Filter(criteria); - var children = new List<CarouselItem>(InternalChildren); - - children.Sort((x, y) => x.CompareTo(criteria, y)); - children.ForEach(c => c.Filter(criteria)); - - InternalChildren = children; + InternalChildren.ForEach(c => c.Filter(criteria)); + // IEnumerable<T>.OrderBy() is used instead of List<T>.Sort() to ensure sorting stability + var criteriaComparer = Comparer<CarouselItem>.Create((x, y) => x.CompareTo(criteria, y)); + InternalChildren = InternalChildren.OrderBy(c => c, criteriaComparer).ToList(); } protected virtual void ChildItemStateChanged(CarouselItem item, CarouselItemState value) diff --git a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs index 045c682dc3..9e8aad4b6f 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselGroupEagerSelect.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; namespace osu.Game.Screens.Select.Carousel @@ -54,6 +55,14 @@ namespace osu.Game.Screens.Select.Carousel updateSelectedIndex(); } + public void AddChildren(IEnumerable<CarouselItem> items) + { + foreach (var i in items) + base.AddChild(i); + + attemptSelection(); + } + public override void AddChild(CarouselItem i) { base.AddChild(i); @@ -90,11 +99,15 @@ namespace osu.Game.Screens.Select.Carousel PerformSelection(); } + protected virtual CarouselItem GetNextToSelect() + { + return Children.Skip(lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value) ?? + Children.Reverse().Skip(InternalChildren.Count - lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value); + } + protected virtual void PerformSelection() { - CarouselItem nextToSelect = - Children.Skip(lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value) ?? - Children.Reverse().Skip(InternalChildren.Count - lastSelectedIndex).FirstOrDefault(i => !i.Filtered.Value); + CarouselItem nextToSelect = GetNextToSelect(); if (nextToSelect != null) nextToSelect.State.Value = CarouselItemState.Selected; @@ -104,7 +117,8 @@ namespace osu.Game.Screens.Select.Carousel private void updateSelected(CarouselItem newSelection) { - LastSelected = newSelection; + if (newSelection != null) + LastSelected = newSelection; updateSelectedIndex(); } diff --git a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs new file mode 100644 index 0000000000..f1120f55a6 --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs @@ -0,0 +1,114 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Select.Carousel +{ + public class CarouselHeader : Container + { + private SampleChannel sampleHover; + + private readonly Box hoverLayer; + + public Container BorderContainer; + + public readonly Bindable<CarouselItemState> State = new Bindable<CarouselItemState>(CarouselItemState.NotSelected); + + protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + + public CarouselHeader() + { + RelativeSizeAxes = Axes.X; + Height = DrawableCarouselItem.MAX_HEIGHT; + + InternalChild = BorderContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + BorderColour = new Color4(221, 255, 255, 255), + Children = new Drawable[] + { + Content, + hoverLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Blending = BlendingParameters.Additive, + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio, OsuColour colours) + { + sampleHover = audio.Samples.Get($@"SongSelect/song-ping-variation-{RNG.Next(1, 5)}"); + hoverLayer.Colour = colours.Blue.Opacity(0.1f); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + State.BindValueChanged(updateState, true); + } + + private void updateState(ValueChangedEvent<CarouselItemState> state) + { + switch (state.NewValue) + { + case CarouselItemState.Collapsed: + case CarouselItemState.NotSelected: + BorderContainer.BorderThickness = 0; + BorderContainer.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(1), + Radius = 10, + Colour = Color4.Black.Opacity(100), + }; + break; + + case CarouselItemState.Selected: + BorderContainer.BorderThickness = 2.5f; + BorderContainer.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = new Color4(130, 204, 255, 150), + Radius = 20, + Roundness = 10, + }; + break; + } + } + + protected override bool OnHover(HoverEvent e) + { + sampleHover?.Play(); + + hoverLayer.FadeIn(100, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverLayer.FadeOut(1000, Easing.OutQuint); + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Screens/Select/Carousel/CarouselItem.cs b/osu.Game/Screens/Select/Carousel/CarouselItem.cs index 79c1a4cb6b..4bd477412d 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselItem.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselItem.cs @@ -2,13 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using osu.Framework.Bindables; namespace osu.Game.Screens.Select.Carousel { - public abstract class CarouselItem + public abstract class CarouselItem : IComparable<CarouselItem> { + public virtual float TotalHeight => 0; + + /// <summary> + /// An externally defined value used to determine this item's vertical display offset relative to the carousel. + /// </summary> + public float CarouselYPosition; + public readonly BindableBool Filtered = new BindableBool(); public readonly Bindable<CarouselItemState> State = new Bindable<CarouselItemState>(CarouselItemState.NotSelected); @@ -18,23 +24,8 @@ namespace osu.Game.Screens.Select.Carousel /// </summary> public bool Visible => State.Value != CarouselItemState.Collapsed && !Filtered.Value; - public virtual List<DrawableCarouselItem> Drawables - { - get - { - var items = new List<DrawableCarouselItem>(); - - var self = DrawableRepresentation.Value; - if (self?.IsPresent == true) items.Add(self); - - return items; - } - } - protected CarouselItem() { - DrawableRepresentation = new Lazy<DrawableCarouselItem>(CreateDrawableRepresentation); - Filtered.ValueChanged += filtered => { if (filtered.NewValue && State.Value == CarouselItemState.Selected) @@ -42,23 +33,23 @@ namespace osu.Game.Screens.Select.Carousel }; } - protected readonly Lazy<DrawableCarouselItem> DrawableRepresentation; - /// <summary> /// Used as a default sort method for <see cref="CarouselItem"/>s of differing types. /// </summary> internal ulong ChildID; /// <summary> - /// Create a fresh drawable version of this item. If you wish to consume the current representation, use <see cref="DrawableRepresentation"/> instead. + /// Create a fresh drawable version of this item. /// </summary> - protected abstract DrawableCarouselItem CreateDrawableRepresentation(); + public abstract DrawableCarouselItem CreateDrawableRepresentation(); public virtual void Filter(FilterCriteria criteria) { } public virtual int CompareTo(FilterCriteria criteria, CarouselItem other) => ChildID.CompareTo(other.ChildID); + + public int CompareTo(CarouselItem other) => CarouselYPosition.CompareTo(other.CarouselYPosition); } public enum CarouselItemState diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index 0a20f2aa6d..e66469ff8d 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -3,7 +3,11 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading; 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; @@ -14,6 +18,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Collections; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Sprites; @@ -26,6 +31,15 @@ namespace osu.Game.Screens.Select.Carousel { public class DrawableCarouselBeatmap : DrawableCarouselItem, IHasContextMenu { + public const float CAROUSEL_BEATMAP_SPACING = 5; + + /// <summary> + /// The height of a carousel beatmap, including vertical spacing. + /// </summary> + public const float HEIGHT = height + CAROUSEL_BEATMAP_SPACING; + + private const float height = MAX_HEIGHT * 0.6f; + private readonly BeatmapInfo beatmap; private Sprite background; @@ -37,30 +51,43 @@ namespace osu.Game.Screens.Select.Carousel private Triangles triangles; private StarCounter starCounter; - private BeatmapSetOverlay beatmapOverlay; + [Resolved(CanBeNull = true)] + private BeatmapSetOverlay beatmapOverlay { get; set; } + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + + [Resolved(CanBeNull = true)] + private ManageCollectionsDialog manageCollectionsDialog { get; set; } + + private IBindable<StarDifficulty> starDifficultyBindable; + private CancellationTokenSource starDifficultyCancellationSource; public DrawableCarouselBeatmap(CarouselBeatmap panel) - : base(panel) { beatmap = panel.Beatmap; - Height *= 0.60f; + Item = panel; } [BackgroundDependencyLoader(true)] - private void load(SongSelect songSelect, BeatmapManager manager, BeatmapSetOverlay beatmapOverlay) + private void load(BeatmapManager manager, SongSelect songSelect) { - this.beatmapOverlay = beatmapOverlay; + Header.Height = height; if (songSelect != null) { startRequested = b => songSelect.FinaliseSelection(b); - editRequested = songSelect.Edit; + if (songSelect.AllowEditing) + editRequested = songSelect.Edit; } if (manager != null) hideRequested = manager.Hide; - Children = new Drawable[] + Header.Children = new Drawable[] { background = new Box { @@ -70,8 +97,8 @@ namespace osu.Game.Screens.Select.Carousel { TriangleScale = 2, RelativeSizeAxes = Axes.Both, - ColourLight = OsuColour.FromHex(@"3a7285"), - ColourDark = OsuColour.FromHex(@"123744") + ColourLight = Color4Extensions.FromHex(@"3a7285"), + ColourDark = Color4Extensions.FromHex(@"123744") }, new FillFlowContainer { @@ -82,7 +109,7 @@ namespace osu.Game.Screens.Select.Carousel Origin = Anchor.CentreLeft, Children = new Drawable[] { - new DifficultyIcon(beatmap) + new DifficultyIcon(beatmap, shouldShowTooltip: false) { Scale = new Vector2(1.8f), }, @@ -122,10 +149,23 @@ namespace osu.Game.Screens.Select.Carousel }, } }, - starCounter = new StarCounter + new FillFlowContainer { - CountStars = (float)beatmap.StarDifficulty, - Scale = new Vector2(0.8f), + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new TopLocalRank(beatmap) + { + Scale = new Vector2(0.8f), + Size = new Vector2(40, 20) + }, + starCounter = new StarCounter + { + Scale = new Vector2(0.8f), + } + } } } } @@ -138,6 +178,8 @@ namespace osu.Game.Screens.Select.Carousel { base.Selected(); + MovementContainer.MoveToX(-50, 500, Easing.OutExpo); + background.Colour = ColourInfo.GradientVertical( new Color4(20, 43, 51, 255), new Color4(40, 86, 102, 255)); @@ -149,6 +191,8 @@ namespace osu.Game.Screens.Select.Carousel { base.Deselected(); + MovementContainer.MoveToX(0, 500, Easing.OutExpo); + background.Colour = new Color4(20, 43, 51, 255); triangles.Colour = OsuColour.Gray(0.5f); } @@ -166,6 +210,16 @@ namespace osu.Game.Screens.Select.Carousel if (Item.State.Value != CarouselItemState.Collapsed && Alpha == 0) starCounter.ReplayAnimation(); + starDifficultyCancellationSource?.Cancel(); + + // Only compute difficulty when the item is visible. + if (Item.State.Value != CarouselItemState.Collapsed) + { + // 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); + } + base.ApplyState(); } @@ -173,18 +227,51 @@ namespace osu.Game.Screens.Select.Carousel { get { - List<MenuItem> items = new List<MenuItem> - { - new OsuMenuItem("Play", MenuItemType.Highlighted, () => startRequested?.Invoke(beatmap)), - new OsuMenuItem("Edit", MenuItemType.Standard, () => editRequested?.Invoke(beatmap)), - new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested?.Invoke(beatmap)), - }; + List<MenuItem> items = new List<MenuItem>(); - if (beatmap.OnlineBeatmapID.HasValue) - items.Add(new OsuMenuItem("Details", MenuItemType.Standard, () => beatmapOverlay?.FetchAndShowBeatmap(beatmap.OnlineBeatmapID.Value))); + if (startRequested != null) + items.Add(new OsuMenuItem("Play", MenuItemType.Highlighted, () => startRequested(beatmap))); + + if (editRequested != null) + items.Add(new OsuMenuItem("Edit", MenuItemType.Standard, () => editRequested(beatmap))); + + if (beatmap.OnlineBeatmapID.HasValue && beatmapOverlay != null) + items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmap.OnlineBeatmapID.Value))); + + if (collectionManager != null) + { + var collectionItems = collectionManager.Collections.Select(createCollectionMenuItem).ToList(); + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); + } + + if (hideRequested != null) + items.Add(new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested(beatmap))); return items.ToArray(); } } + + private MenuItem createCollectionMenuItem(BeatmapCollection collection) + { + return new ToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s => + { + if (s) + collection.Beatmaps.Add(beatmap); + else + collection.Beatmaps.Remove(beatmap); + }) + { + State = { Value = collection.Beatmaps.Contains(beatmap) } + }; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + starDifficultyCancellationSource?.Cancel(); + } } } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 4ceb82d4cc..93f95e76cc 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -3,206 +3,232 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Localisation; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.Drawables; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Game.Collections; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; -using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.Select.Carousel { public class DrawableCarouselBeatmapSet : DrawableCarouselItem, IHasContextMenu { + public const float HEIGHT = MAX_HEIGHT; + private Action<BeatmapSetInfo> restoreHiddenRequested; private Action<int> viewDetails; - private DialogOverlay dialogOverlay; - private readonly BeatmapSetInfo beatmapSet; + [Resolved(CanBeNull = true)] + private DialogOverlay dialogOverlay { get; set; } - public DrawableCarouselBeatmapSet(CarouselBeatmapSet set) - : base(set) + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + + [Resolved(CanBeNull = true)] + private ManageCollectionsDialog manageCollectionsDialog { get; set; } + + public IEnumerable<DrawableCarouselItem> DrawableBeatmaps => beatmapContainer?.Children ?? Enumerable.Empty<DrawableCarouselItem>(); + + private Container<DrawableCarouselItem> beatmapContainer; + + private BeatmapSetInfo beatmapSet; + + [Resolved] + private BeatmapManager manager { get; set; } + + protected override void FreeAfterUse() { - beatmapSet = set.BeatmapSet; + base.FreeAfterUse(); + + Item = null; + + ClearTransforms(); } [BackgroundDependencyLoader(true)] - private void load(BeatmapManager manager, BeatmapSetOverlay beatmapOverlay, DialogOverlay overlay) + private void load(BeatmapSetOverlay beatmapOverlay) { restoreHiddenRequested = s => s.Beatmaps.ForEach(manager.Restore); - dialogOverlay = overlay; + if (beatmapOverlay != null) viewDetails = beatmapOverlay.FetchAndShowBeatmapSet; + } - Children = new Drawable[] + protected override void UpdateItem() + { + base.UpdateItem(); + + Content.Clear(); + beatmapContainer = null; + + if (Item == null) + return; + + beatmapSet = ((CarouselBeatmapSet)Item).BeatmapSet; + + DelayedLoadWrapper background; + DelayedLoadWrapper mainFlow; + + Header.Children = new Drawable[] { - new DelayedLoadUnloadWrapper(() => - { - var background = new PanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.FirstOrDefault())) - { - RelativeSizeAxes = Axes.Both, - }; - - background.OnLoadComplete += d => d.FadeInFromZero(1000, Easing.OutQuint); - - return background; - }, 300, 5000 - ), - new FillFlowContainer + background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.FirstOrDefault())) { - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Top = 5, Left = 18, Right = 10, Bottom = 10 }, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - new OsuSpriteText - { - Text = new LocalisedString((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)), - Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true), - Shadow = true, - }, - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 5 }, - Children = new Drawable[] - { - new BeatmapSetOnlineStatusPill - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Margin = new MarginPadding { Right = 5 }, - TextSize = 11, - TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, - Status = beatmapSet.Status - }, - new FillFlowContainer<FilterableDifficultyIcon> - { - AutoSizeAxes = Axes.Both, - Children = ((CarouselBeatmapSet)Item).Beatmaps.Select(b => new FilterableDifficultyIcon(b)).ToList() - }, - } - } - } - } + RelativeSizeAxes = Axes.Both, + }, 300), + mainFlow = new DelayedLoadWrapper(() => new SetPanelContent((CarouselBeatmapSet)Item), 100), }; + + background.DelayedLoadComplete += fadeContentIn; + mainFlow.DelayedLoadComplete += fadeContentIn; + } + + private void fadeContentIn(Drawable d) => d.FadeInFromZero(750, Easing.OutQuint); + + protected override void Deselected() + { + base.Deselected(); + + MovementContainer.MoveToX(0, 500, Easing.OutExpo); + + if (beatmapContainer != null) + { + foreach (var beatmap in beatmapContainer) + beatmap.MoveToY(0, 800, Easing.OutQuint); + } + } + + protected override void Selected() + { + base.Selected(); + + MovementContainer.MoveToX(-100, 500, Easing.OutExpo); + + updateBeatmapDifficulties(); + } + + private void updateBeatmapDifficulties() + { + var carouselBeatmapSet = (CarouselBeatmapSet)Item; + + var visibleBeatmaps = carouselBeatmapSet.Children.Where(c => c.Visible).ToArray(); + + // if we are already displaying all the correct beatmaps, only run animation updates. + // note that the displayed beatmaps may change due to the applied filter. + // a future optimisation could add/remove only changed difficulties rather than reinitialise. + if (beatmapContainer != null && visibleBeatmaps.Length == beatmapContainer.Count && visibleBeatmaps.All(b => beatmapContainer.Any(c => c.Item == b))) + { + updateBeatmapYPositions(); + } + else + { + // on selection we show our child beatmaps. + // for now this is a simple drawable construction each selection. + // can be improved in the future. + beatmapContainer = new Container<DrawableCarouselItem> + { + X = 100, + RelativeSizeAxes = Axes.Both, + ChildrenEnumerable = visibleBeatmaps.Select(c => c.CreateDrawableRepresentation()) + }; + + LoadComponentAsync(beatmapContainer, loaded => + { + // make sure the pooled target hasn't changed. + if (beatmapContainer != loaded) + return; + + Content.Child = loaded; + updateBeatmapYPositions(); + }); + } + + void updateBeatmapYPositions() + { + float yPos = DrawableCarouselBeatmap.CAROUSEL_BEATMAP_SPACING; + + foreach (var panel in beatmapContainer.Children) + { + panel.MoveToY(yPos, 800, Easing.OutQuint); + yPos += panel.Item.TotalHeight; + } + } } public MenuItem[] ContextMenuItems { get { + Debug.Assert(beatmapSet != null); + List<MenuItem> items = new List<MenuItem>(); if (Item.State.Value == CarouselItemState.NotSelected) items.Add(new OsuMenuItem("Expand", MenuItemType.Highlighted, () => Item.State.Value = CarouselItemState.Selected)); - if (beatmapSet.OnlineBeatmapSetID != null) - items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails?.Invoke(beatmapSet.OnlineBeatmapSetID.Value))); + if (beatmapSet.OnlineBeatmapSetID != null && viewDetails != null) + items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails(beatmapSet.OnlineBeatmapSetID.Value))); + + if (collectionManager != null) + { + var collectionItems = collectionManager.Collections.Select(createCollectionMenuItem).ToList(); + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); + } if (beatmapSet.Beatmaps.Any(b => b.Hidden)) - items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested?.Invoke(beatmapSet))); - - items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new BeatmapDeleteDialog(beatmapSet)))); + items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet))); + if (dialogOverlay != null) + items.Add(new OsuMenuItem("Delete...", MenuItemType.Destructive, () => dialogOverlay.Push(new BeatmapDeleteDialog(beatmapSet)))); return items.ToArray(); } } - private class PanelBackground : BufferedContainer + private MenuItem createCollectionMenuItem(BeatmapCollection collection) { - public PanelBackground(WorkingBeatmap working) - { - CacheDrawnFrameBuffer = true; + Debug.Assert(beatmapSet != null); - Children = new Drawable[] + TernaryState state; + + var countExisting = beatmapSet.Beatmaps.Count(b => collection.Beatmaps.Contains(b)); + + if (countExisting == beatmapSet.Beatmaps.Count) + state = TernaryState.True; + else if (countExisting > 0) + state = TernaryState.Indeterminate; + else + state = TernaryState.False; + + return new TernaryStateMenuItem(collection.Name.Value, MenuItemType.Standard, s => + { + foreach (var b in beatmapSet.Beatmaps) { - new BeatmapBackgroundSprite(working) + switch (s) { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - FillMode = FillMode.Fill, - }, - // Todo: This should be a fill flow, but has invalidation issues (see https://github.com/ppy/osu-framework/issues/223) - new Container - { - Depth = -1, - RelativeSizeAxes = Axes.Both, - // This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle - Shear = new Vector2(0.8f, 0), - Alpha = 0.5f, - Children = new[] - { - // The left half with no gradient applied - new Box - { - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Colour = Color4.Black, - Width = 0.4f, - }, - // Piecewise-linear gradient with 3 segments to make it appear smoother - new Box - { - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)), - Width = 0.05f, - X = 0.4f, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)), - Width = 0.2f, - X = 0.45f, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)), - Width = 0.05f, - X = 0.65f, - }, - } - }, - }; - } - } + case TernaryState.True: + if (collection.Beatmaps.Contains(b)) + continue; - public class FilterableDifficultyIcon : DifficultyIcon - { - private readonly BindableBool filtered = new BindableBool(); + collection.Beatmaps.Add(b); + break; - public FilterableDifficultyIcon(CarouselBeatmap item) - : base(item.Beatmap) + case TernaryState.False: + collection.Beatmaps.Remove(b); + break; + } + } + }) { - filtered.BindTo(item.Filtered); - filtered.ValueChanged += isFiltered => Schedule(() => this.FadeTo(isFiltered.NewValue ? 0.1f : 1, 100)); - filtered.TriggerChange(); - } + State = { Value = state } + }; } } } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs index b906bd935c..cde3edad39 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselItem.cs @@ -1,106 +1,133 @@ // 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. -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Audio.Sample; -using osu.Framework.Extensions.Color4Extensions; +using System.Diagnostics; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Pooling; using osu.Framework.Input.Events; -using osu.Framework.MathUtils; -using osu.Game.Graphics; using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.Select.Carousel { - public abstract class DrawableCarouselItem : Container + public abstract class DrawableCarouselItem : PoolableDrawable { public const float MAX_HEIGHT = 80; - public override bool RemoveWhenNotAlive => false; + public override bool IsPresent => base.IsPresent || Item?.Visible == true; - public override bool IsPresent => base.IsPresent || Item.Visible; + public readonly CarouselHeader Header; - public readonly CarouselItem Item; + /// <summary> + /// Optional content which sits below the header. + /// </summary> + protected readonly Container<Drawable> Content; - private Container nestedContainer; - private Container borderContainer; + protected readonly Container MovementContainer; - private Box hoverLayer; + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => + Header.ReceivePositionalInputAt(screenSpacePos); - protected override Container<Drawable> Content => nestedContainer; + private CarouselItem item; - protected DrawableCarouselItem(CarouselItem item) + public CarouselItem Item { - Item = item; - - Height = MAX_HEIGHT; - RelativeSizeAxes = Axes.X; - Alpha = 0; - } - - private SampleChannel sampleHover; - - [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuColour colours) - { - InternalChild = borderContainer = new Container + get => item; + set { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 10, - BorderColour = new Color4(221, 255, 255, 255), - Children = new Drawable[] + if (item == value) + return; + + if (item != null) { - nestedContainer = new Container + item.Filtered.ValueChanged -= onStateChange; + item.State.ValueChanged -= onStateChange; + + Header.State.UnbindFrom(item.State); + + if (item is CarouselGroup group) { - RelativeSizeAxes = Axes.Both, - }, - hoverLayer = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Blending = BlendingMode.Additive, - }, + foreach (var c in group.Children) + c.Filtered.ValueChanged -= onStateChange; + } } + + item = value; + + if (IsLoaded) + UpdateItem(); + } + } + + protected DrawableCarouselItem() + { + RelativeSizeAxes = Axes.X; + + Alpha = 0; + + InternalChildren = new Drawable[] + { + MovementContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + Header = new CarouselHeader(), + Content = new Container + { + RelativeSizeAxes = Axes.Both, + } + } + }, }; - - sampleHover = audio.Samples.Get($@"SongSelect/song-ping-variation-{RNG.Next(1, 5)}"); - hoverLayer.Colour = colours.Blue.Opacity(0.1f); } - protected override bool OnHover(HoverEvent e) - { - sampleHover?.Play(); - - hoverLayer.FadeIn(100, Easing.OutQuint); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - hoverLayer.FadeOut(1000, Easing.OutQuint); - base.OnHoverLost(e); - } - - public void SetMultiplicativeAlpha(float alpha) => borderContainer.Alpha = alpha; + public void SetMultiplicativeAlpha(float alpha) => Header.BorderContainer.Alpha = alpha; protected override void LoadComplete() { base.LoadComplete(); - ApplyState(); - Item.Filtered.ValueChanged += _ => Schedule(ApplyState); - Item.State.ValueChanged += _ => Schedule(ApplyState); + UpdateItem(); } + protected override void Update() + { + base.Update(); + Content.Y = Header.Height; + } + + protected virtual void UpdateItem() + { + if (item == null) + return; + + Scheduler.AddOnce(ApplyState); + + Item.Filtered.ValueChanged += onStateChange; + Item.State.ValueChanged += onStateChange; + + Header.State.BindTo(Item.State); + + if (Item is CarouselGroup group) + { + foreach (var c in group.Children) + c.Filtered.ValueChanged += onStateChange; + } + } + + private void onStateChange(ValueChangedEvent<CarouselItemState> obj) => Scheduler.AddOnce(ApplyState); + + private void onStateChange(ValueChangedEvent<bool> _) => Scheduler.AddOnce(ApplyState); + protected virtual void ApplyState() { - if (!IsLoaded) return; + // Use the fact that we know the precise height of the item from the model to avoid the need for AutoSize overhead. + // Additionally, AutoSize doesn't work well due to content starting off-screen and being masked away. + Height = Item.TotalHeight; + + Debug.Assert(Item != null); switch (Item.State.Value) { @@ -121,30 +148,11 @@ namespace osu.Game.Screens.Select.Carousel protected virtual void Selected() { - Item.State.Value = CarouselItemState.Selected; - - borderContainer.BorderThickness = 2.5f; - borderContainer.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = new Color4(130, 204, 255, 150), - Radius = 20, - Roundness = 10, - }; + Debug.Assert(Item != null); } protected virtual void Deselected() { - Item.State.Value = CarouselItemState.NotSelected; - - borderContainer.BorderThickness = 0; - borderContainer.EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Offset = new Vector2(1), - Radius = 10, - Colour = Color4.Black.Opacity(100), - }; } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs b/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs new file mode 100644 index 0000000000..51fe7796c7 --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/FilterableDifficultyIcon.cs @@ -0,0 +1,35 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Drawables; + +namespace osu.Game.Screens.Select.Carousel +{ + public class FilterableDifficultyIcon : DifficultyIcon + { + private readonly BindableBool filtered = new BindableBool(); + + public bool IsFiltered => filtered.Value; + + public readonly CarouselBeatmap Item; + + public FilterableDifficultyIcon(CarouselBeatmap item) + : base(item.Beatmap, performBackgroundDifficultyLookup: false) + { + filtered.BindTo(item.Filtered); + filtered.ValueChanged += isFiltered => Schedule(() => this.FadeTo(isFiltered.NewValue ? 0.1f : 1, 100)); + filtered.TriggerChange(); + + Item = item; + } + + protected override bool OnClick(ClickEvent e) + { + Item.State.Value = CarouselItemState.Selected; + return true; + } + } +} diff --git a/osu.Game/Screens/Select/Carousel/FilterableGroupedDifficultyIcon.cs b/osu.Game/Screens/Select/Carousel/FilterableGroupedDifficultyIcon.cs new file mode 100644 index 0000000000..d2f9ed3a6a --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/FilterableGroupedDifficultyIcon.cs @@ -0,0 +1,41 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Rulesets; +using osuTK.Graphics; + +namespace osu.Game.Screens.Select.Carousel +{ + public class FilterableGroupedDifficultyIcon : GroupedDifficultyIcon + { + public readonly List<CarouselBeatmap> Items; + + public FilterableGroupedDifficultyIcon(List<CarouselBeatmap> items, RulesetInfo ruleset) + : base(items.Select(i => i.Beatmap).ToList(), ruleset, Color4.White) + { + Items = items; + + foreach (var item in items) + item.Filtered.BindValueChanged(_ => Scheduler.AddOnce(updateFilteredDisplay)); + + updateFilteredDisplay(); + } + + protected override bool OnClick(ClickEvent e) + { + Items.First().State.Value = CarouselItemState.Selected; + return true; + } + + private void updateFilteredDisplay() + { + // for now, fade the whole group based on the ratio of hidden items. + this.FadeTo(1 - 0.9f * ((float)Items.Count(i => i.Filtered.Value) / Items.Count), 100); + } + } +} diff --git a/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs b/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs new file mode 100644 index 0000000000..25139b27db --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs @@ -0,0 +1,72 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Select.Carousel +{ + public class SetPanelBackground : BufferedContainer + { + public SetPanelBackground(WorkingBeatmap working) + { + CacheDrawnFrameBuffer = true; + RedrawOnScale = false; + + Children = new Drawable[] + { + new BeatmapBackgroundSprite(working) + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + FillMode = FillMode.Fill, + }, + new FillFlowContainer + { + Depth = -1, + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + // This makes the gradient not be perfectly horizontal, but diagonal at a ~40° angle + Shear = new Vector2(0.8f, 0), + Alpha = 0.5f, + Children = new[] + { + // The left half with no gradient applied + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Width = 0.4f, + }, + // Piecewise-linear gradient with 3 segments to make it appear smoother + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(Color4.Black, new Color4(0f, 0f, 0f, 0.9f)), + Width = 0.05f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.9f), new Color4(0f, 0f, 0f, 0.1f)), + Width = 0.2f, + }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(new Color4(0f, 0f, 0f, 0.1f), new Color4(0, 0, 0, 0)), + Width = 0.05f, + }, + } + }, + }; + } + } +} diff --git a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs new file mode 100644 index 0000000000..4e8d27f14d --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs @@ -0,0 +1,93 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Screens.Select.Carousel +{ + public class SetPanelContent : CompositeDrawable + { + private readonly CarouselBeatmapSet carouselSet; + + public SetPanelContent(CarouselBeatmapSet carouselSet) + { + this.carouselSet = carouselSet; + + // required to ensure we load as soon as any part of the panel comes on screen + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + var beatmapSet = carouselSet.BeatmapSet; + + InternalChild = new FillFlowContainer + { + // required to ensure we load as soon as any part of the panel comes on screen + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Top = 5, Left = 18, Right = 10, Bottom = 10 }, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = new LocalisedString((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)), + Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 17, italics: true), + Shadow = true, + }, + new FillFlowContainer + { + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = 5 }, + Children = new Drawable[] + { + new BeatmapSetOnlineStatusPill + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 5 }, + TextSize = 11, + TextPadding = new MarginPadding { Horizontal = 8, Vertical = 2 }, + Status = beatmapSet.Status + }, + new FillFlowContainer<DifficultyIcon> + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(3), + ChildrenEnumerable = getDifficultyIcons(), + }, + } + } + } + }; + } + + private const int maximum_difficulty_icons = 18; + + private IEnumerable<DifficultyIcon> getDifficultyIcons() + { + var beatmaps = carouselSet.Beatmaps.ToList(); + + return beatmaps.Count > maximum_difficulty_icons + ? (IEnumerable<DifficultyIcon>)beatmaps.GroupBy(b => b.Beatmap.Ruleset).Select(group => new FilterableGroupedDifficultyIcon(group.ToList(), group.Key)) + : beatmaps.Select(b => new FilterableDifficultyIcon(b)); + } + } +} diff --git a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs new file mode 100644 index 0000000000..3ad57c1cb0 --- /dev/null +++ b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs @@ -0,0 +1,90 @@ +// 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. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.Leaderboards; +using osu.Game.Rulesets; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Select.Carousel +{ + public class TopLocalRank : UpdateableRank + { + private readonly BeatmapInfo beatmap; + + [Resolved] + private ScoreManager scores { get; set; } + + [Resolved] + private IBindable<RulesetInfo> ruleset { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + private IBindable<WeakReference<ScoreInfo>> itemUpdated; + private IBindable<WeakReference<ScoreInfo>> itemRemoved; + + public TopLocalRank(BeatmapInfo beatmap) + : base(null) + { + this.beatmap = beatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + itemUpdated = scores.ItemUpdated.GetBoundCopy(); + itemUpdated.BindValueChanged(scoreChanged); + + itemRemoved = scores.ItemRemoved.GetBoundCopy(); + itemRemoved.BindValueChanged(scoreChanged); + + ruleset.ValueChanged += _ => fetchAndLoadTopScore(); + + fetchAndLoadTopScore(); + } + + private void scoreChanged(ValueChangedEvent<WeakReference<ScoreInfo>> weakScore) + { + if (weakScore.NewValue.TryGetTarget(out var score)) + { + if (score.BeatmapInfoID == beatmap.ID) + fetchAndLoadTopScore(); + } + } + + private ScheduledDelegate scheduledRankUpdate; + + private void fetchAndLoadTopScore() + { + var rank = fetchTopScore()?.Rank; + scheduledRankUpdate = Schedule(() => + { + Rank = rank; + + // Required since presence is changed via IsPresent override + Invalidate(Invalidation.Presence); + }); + } + + // We're present if a rank is set, or if there is a pending rank update (IsPresent = true is required for the scheduler to run). + public override bool IsPresent => base.IsPresent && (Rank != null || scheduledRankUpdate?.Completed == false); + + private ScoreInfo fetchTopScore() + { + if (scores == null || beatmap == null || ruleset?.Value == null || api?.LocalUser.Value == null) + return null; + + return scores.QueryScores(s => s.UserID == api.LocalUser.Value.Id && s.BeatmapInfoID == beatmap.ID && s.RulesetID == ruleset.Value.ID && !s.DeletePending) + .OrderByDescending(s => s.TotalScore) + .FirstOrDefault(); + } + } +} diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index 52a57dd506..44d908fc46 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -1,7 +1,6 @@ // 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. -using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; @@ -10,14 +9,33 @@ using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using System; using osu.Game.Beatmaps; +using osu.Framework.Bindables; +using System.Collections.Generic; +using osu.Game.Rulesets.Mods; +using System.Linq; +using System.Threading; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets; namespace osu.Game.Screens.Select.Details { public class AdvancedStats : Container { - private readonly StatisticRow firstValue, hpDrain, accuracy, approachRate, starDifficulty; + [Resolved] + private IBindable<IReadOnlyList<Mod>> mods { get; set; } + + [Resolved] + private IBindable<RulesetInfo> ruleset { get; set; } + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } + + protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate; + private readonly StatisticRow starDifficulty; private BeatmapInfo beatmap; @@ -30,22 +48,7 @@ namespace osu.Game.Screens.Select.Details beatmap = value; - //mania specific - if ((Beatmap?.Ruleset?.ID ?? 0) == 3) - { - firstValue.Title = "Key Amount"; - firstValue.Value = (int)Math.Round(Beatmap?.BaseDifficulty?.CircleSize ?? 0); - } - else - { - firstValue.Title = "Circle Size"; - firstValue.Value = Beatmap?.BaseDifficulty?.CircleSize ?? 0; - } - - hpDrain.Value = Beatmap?.BaseDifficulty?.DrainRate ?? 0; - accuracy.Value = Beatmap?.BaseDifficulty?.OverallDifficulty ?? 0; - approachRate.Value = Beatmap?.BaseDifficulty?.ApproachRate ?? 0; - starDifficulty.Value = (float)(Beatmap?.StarDifficulty ?? 0); + updateStatistics(); } } @@ -55,13 +58,12 @@ namespace osu.Game.Screens.Select.Details { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(4f), Children = new[] { - firstValue = new StatisticRow(), //circle size/key amount - hpDrain = new StatisticRow { Title = "HP Drain" }, - accuracy = new StatisticRow { Title = "Accuracy" }, - approachRate = new StatisticRow { Title = "Approach Rate" }, + FirstValue = new StatisticRow(), // circle size/key amount + HpDrain = new StatisticRow { Title = "HP Drain" }, + Accuracy = new StatisticRow { Title = "Accuracy" }, + ApproachRate = new StatisticRow { Title = "Approach Rate" }, starDifficulty = new StatisticRow(10, true) { Title = "Star Difficulty" }, }, }; @@ -73,15 +75,120 @@ namespace osu.Game.Screens.Select.Details starDifficulty.AccentColour = colours.Yellow; } - private class StatisticRow : Container, IHasAccentColour + protected override void LoadComplete() + { + base.LoadComplete(); + + ruleset.BindValueChanged(_ => updateStatistics()); + mods.BindValueChanged(modsChanged, true); + } + + private readonly List<ISettingsItem> references = new List<ISettingsItem>(); + + private void modsChanged(ValueChangedEvent<IReadOnlyList<Mod>> mods) + { + // TODO: find a more permanent solution for this if/when it is needed in other components. + // this is generating drawables for the only purpose of storing bindable references. + foreach (var r in references) + r.Dispose(); + + references.Clear(); + + ScheduledDelegate debounce = null; + + foreach (var mod in mods.NewValue.OfType<IApplicableToDifficulty>()) + { + foreach (var setting in mod.CreateSettingsControls().OfType<ISettingsItem>()) + { + setting.SettingChanged += () => + { + debounce?.Cancel(); + debounce = Scheduler.AddDelayed(updateStatistics, 100); + }; + + references.Add(setting); + } + } + + updateStatistics(); + } + + private void updateStatistics() + { + BeatmapDifficulty baseDifficulty = Beatmap?.BaseDifficulty; + BeatmapDifficulty adjustedDifficulty = null; + + if (baseDifficulty != null && mods.Value.Any(m => m is IApplicableToDifficulty)) + { + adjustedDifficulty = baseDifficulty.Clone(); + + foreach (var mod in mods.Value.OfType<IApplicableToDifficulty>()) + mod.ApplyToDifficulty(adjustedDifficulty); + } + + switch (Beatmap?.Ruleset?.ID ?? 0) + { + case 3: + // Account for mania differences locally for now + // Eventually this should be handled in a more modular way, allowing rulesets to return arbitrary difficulty attributes + FirstValue.Title = "Key Count"; + FirstValue.Value = (baseDifficulty?.CircleSize ?? 0, null); + break; + + default: + FirstValue.Title = "Circle Size"; + FirstValue.Value = (baseDifficulty?.CircleSize ?? 0, adjustedDifficulty?.CircleSize); + break; + } + + HpDrain.Value = (baseDifficulty?.DrainRate ?? 0, adjustedDifficulty?.DrainRate); + Accuracy.Value = (baseDifficulty?.OverallDifficulty ?? 0, adjustedDifficulty?.OverallDifficulty); + ApproachRate.Value = (baseDifficulty?.ApproachRate ?? 0, adjustedDifficulty?.ApproachRate); + + updateStarDifficulty(); + } + + private IBindable<StarDifficulty> normalStarDifficulty; + private IBindable<StarDifficulty> moddedStarDifficulty; + private CancellationTokenSource starDifficultyCancellationSource; + + private void updateStarDifficulty() + { + starDifficultyCancellationSource?.Cancel(); + + if (Beatmap == null) + return; + + starDifficultyCancellationSource = new CancellationTokenSource(); + + normalStarDifficulty = difficultyCache.GetBindableDifficulty(Beatmap, ruleset.Value, null, starDifficultyCancellationSource.Token); + moddedStarDifficulty = difficultyCache.GetBindableDifficulty(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); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + starDifficultyCancellationSource?.Cancel(); + } + + public class StatisticRow : Container, IHasAccentColour { private const float value_width = 25; private const float name_width = 70; private readonly float maxValue; private readonly bool forceDecimalPlaces; - private readonly OsuSpriteText name, value; + private readonly OsuSpriteText name, valueText; private readonly Bar bar; + public readonly Bar ModBar; + + [Resolved] + private OsuColour colours { get; set; } public string Title { @@ -89,16 +196,29 @@ namespace osu.Game.Screens.Select.Details set => name.Text = value; } - private float difficultyValue; + private (float baseValue, float? adjustedValue) value; - public float Value + public (float baseValue, float? adjustedValue) Value { - get => difficultyValue; + get => value; set { - difficultyValue = value; - bar.Length = value / maxValue; - this.value.Text = value.ToString(forceDecimalPlaces ? "0.00" : "0.##"); + if (value == this.value) + return; + + this.value = value; + + bar.Length = value.baseValue / maxValue; + + valueText.Text = (value.adjustedValue ?? value.baseValue).ToString(forceDecimalPlaces ? "0.00" : "0.##"); + ModBar.Length = (value.adjustedValue ?? 0) / maxValue; + + if (Precision.AlmostEquals(value.baseValue, value.adjustedValue ?? value.baseValue, 0.05f)) + ModBar.AccentColour = valueText.Colour = Color4.White; + else if (value.adjustedValue > value.baseValue) + ModBar.AccentColour = valueText.Colour = colours.Red; + else if (value.adjustedValue < value.baseValue) + ModBar.AccentColour = valueText.Colour = colours.BlueDark; } } @@ -114,6 +234,7 @@ namespace osu.Game.Screens.Select.Details this.forceDecimalPlaces = forceDecimalPlaces; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + Padding = new MarginPadding { Vertical = 2.5f }; Children = new Drawable[] { @@ -121,9 +242,11 @@ namespace osu.Game.Screens.Select.Details { Width = name_width, AutoSizeAxes = Axes.Y, + // osu-web uses 1.25 line-height, which at 12px font size makes the element 14px tall - this compentates that difference + Padding = new MarginPadding { Vertical = 1 }, Child = name = new OsuSpriteText { - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12) }, }, bar = new Bar @@ -135,17 +258,26 @@ namespace osu.Game.Screens.Select.Details BackgroundColour = Color4.White.Opacity(0.5f), Padding = new MarginPadding { Left = name_width + 10, Right = value_width + 10 }, }, + ModBar = new Bar + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + Alpha = 0.5f, + Height = 5, + Padding = new MarginPadding { Left = name_width + 10, Right = value_width + 10 }, + }, new Container { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, Width = value_width, RelativeSizeAxes = Axes.Y, - Child = value = new OsuSpriteText + Child = valueText = new OsuSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12) }, }, }; diff --git a/osu.Game/Screens/Select/Details/FailRetryGraph.cs b/osu.Game/Screens/Select/Details/FailRetryGraph.cs index 34297d89a4..7cc80acfd3 100644 --- a/osu.Game/Screens/Select/Details/FailRetryGraph.cs +++ b/osu.Game/Screens/Select/Details/FailRetryGraph.cs @@ -1,7 +1,7 @@ // 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. -using osuTK; +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -27,18 +27,32 @@ namespace osu.Game.Screens.Select.Details metrics = value; - var retries = Metrics?.Retries ?? new int[0]; - var fails = Metrics?.Fails ?? new int[0]; + var retries = Metrics?.Retries ?? Array.Empty<int>(); + var fails = Metrics?.Fails ?? Array.Empty<int>(); + var retriesAndFails = sumRetriesAndFails(retries, fails); - float maxValue = fails.Any() ? fails.Zip(retries, (fail, retry) => fail + retry).Max() : 0; + float maxValue = retriesAndFails.Any() ? retriesAndFails.Max() : 0; failGraph.MaxValue = maxValue; retryGraph.MaxValue = maxValue; - failGraph.Values = fails.Select(f => (float)f); - retryGraph.Values = retries.Zip(fails, (retry, fail) => retry + MathHelper.Clamp(fail, 0, maxValue)); + failGraph.Values = fails.Select(v => (float)v); + retryGraph.Values = retriesAndFails.Select(v => (float)v); } } + private int[] sumRetriesAndFails(int[] retries, int[] fails) + { + var result = new int[Math.Max(retries.Length, fails.Length)]; + + for (int i = 0; i < retries.Length; ++i) + result[i] = retries[i]; + + for (int i = 0; i < fails.Length; ++i) + result[i] += fails[i]; + + return result; + } + public FailRetryGraph() { Children = new[] diff --git a/osu.Game/Screens/Select/Details/UserRatings.cs b/osu.Game/Screens/Select/Details/UserRatings.cs index c1e01e3572..cf5e3ba1b3 100644 --- a/osu.Game/Screens/Select/Details/UserRatings.cs +++ b/osu.Game/Screens/Select/Details/UserRatings.cs @@ -71,31 +71,32 @@ namespace osu.Game.Screens.Select.Details Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = "User Rating", - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12), + Margin = new MarginPadding { Bottom = 5 }, }, ratingsBar = new Bar { RelativeSizeAxes = Axes.X, Height = 5, - Margin = new MarginPadding { Top = 5 }, }, new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Bottom = 10 }, Children = new[] { negativeRatings = new OsuSpriteText { Text = "0", - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12) }, positiveRatings = new OsuSpriteText { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, Text = @"0", - Font = OsuFont.GetFont(size: 13) + Font = OsuFont.GetFont(size: 12) }, }, }, @@ -104,8 +105,8 @@ namespace osu.Game.Screens.Select.Details Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = "Rating Spread", - Font = OsuFont.GetFont(size: 13), - Margin = new MarginPadding { Top = 10, Bottom = 5 }, + Font = OsuFont.GetFont(size: 12), + Margin = new MarginPadding { Bottom = 5 }, }, }, }, diff --git a/osu.Game/Screens/Select/DifficultyRecommender.cs b/osu.Game/Screens/Select/DifficultyRecommender.cs new file mode 100644 index 0000000000..ff54e0a8df --- /dev/null +++ b/osu.Game/Screens/Select/DifficultyRecommender.cs @@ -0,0 +1,88 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; + +namespace osu.Game.Screens.Select +{ + public class DifficultyRecommender : Component + { + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private RulesetStore rulesets { get; set; } + + [Resolved] + private Bindable<RulesetInfo> ruleset { get; set; } + + private readonly Dictionary<RulesetInfo, double> recommendedStarDifficulty = new Dictionary<RulesetInfo, double>(); + + private readonly IBindable<APIState> apiState = new Bindable<APIState>(); + + [BackgroundDependencyLoader] + private void load() + { + apiState.BindTo(api.State); + apiState.BindValueChanged(onlineStateChanged, true); + } + + /// <summary> + /// Find the recommended difficulty from a selection of available difficulties for the current local user. + /// </summary> + /// <remarks> + /// This requires the user to be online for now. + /// </remarks> + /// <param name="beatmaps">A collection of beatmaps to select a difficulty from.</param> + /// <returns>The recommended difficulty, or null if a recommendation could not be provided.</returns> + public BeatmapInfo GetRecommendedBeatmap(IEnumerable<BeatmapInfo> beatmaps) + { + if (recommendedStarDifficulty.TryGetValue(ruleset.Value, out var stars)) + { + return beatmaps.OrderBy(b => + { + var difference = b.StarDifficulty - stars; + return difference >= 0 ? difference * 2 : difference * -1; // prefer easier over harder + }).FirstOrDefault(); + } + + return null; + } + + private void calculateRecommendedDifficulties() + { + rulesets.AvailableRulesets.ForEach(rulesetInfo => + { + var req = new GetUserRequest(api.LocalUser.Value.Id, rulesetInfo); + + req.Success += result => + { + // algorithm taken from https://github.com/ppy/osu-web/blob/e6e2825516449e3d0f3f5e1852c6bdd3428c3437/app/Models/User.php#L1505 + recommendedStarDifficulty[rulesetInfo] = Math.Pow((double)(result.Statistics.PP ?? 0), 0.4) * 0.195; + }; + + api.Queue(req); + }); + } + + private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() => + { + switch (state.NewValue) + { + case APIState.Online: + calculateRecommendedDifficulties(); + break; + } + }); + } +} diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index 84e8e90f54..952a5d1eaa 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -2,163 +2,203 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; -using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Screens.Select.Filter; -using Container = osu.Framework.Graphics.Containers.Container; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Collections; using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; +using osu.Game.Screens.Select.Filter; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Select { public class FilterControl : Container { - public const float HEIGHT = 100; + public const float HEIGHT = 2 * side_margin + 85; + private const float side_margin = 20; public Action<FilterCriteria> FilterChanged; - private readonly OsuTabControl<SortMode> sortTabs; + private OsuTabControl<SortMode> sortTabs; - private readonly TabControl<GroupMode> groupTabs; + private Bindable<SortMode> sortMode; - private SortMode sort = SortMode.Title; + private Bindable<GroupMode> groupMode; - public SortMode Sort + public FilterCriteria CreateCriteria() { - get => sort; - set + var query = searchTextBox.Text; + + var criteria = new FilterCriteria { - if (sort != value) - { - sort = value; - FilterChanged?.Invoke(CreateCriteria()); - } - } + Group = groupMode.Value, + Sort = sortMode.Value, + AllowConvertedBeatmaps = showConverted.Value, + Ruleset = ruleset.Value, + Collection = collectionDropdown?.Current.Value.Collection + }; + + if (!minimumStars.IsDefault) + criteria.UserStarDifficulty.Min = minimumStars.Value; + + if (!maximumStars.IsDefault) + criteria.UserStarDifficulty.Max = maximumStars.Value; + + FilterQueryParser.ApplyQueries(criteria, query); + return criteria; } - private GroupMode group = GroupMode.All; - - public GroupMode Group - { - get => group; - set - { - if (group != value) - { - group = value; - FilterChanged?.Invoke(CreateCriteria()); - } - } - } - - public FilterCriteria CreateCriteria() => new FilterCriteria - { - Group = group, - Sort = sort, - SearchText = searchTextBox.Text, - AllowConvertedBeatmaps = showConverted.Value, - Ruleset = ruleset.Value - }; - - public Action Exit; - - private readonly SearchTextBox searchTextBox; + private SeekLimitedSearchTextBox searchTextBox; + private CollectionFilterDropdown collectionDropdown; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => - base.ReceivePositionalInputAt(screenSpacePos) || groupTabs.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); + base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); - public FilterControl() + [BackgroundDependencyLoader(permitNulls: true)] + private void load(OsuColour colours, IBindable<RulesetInfo> parentRuleset, OsuConfigManager config) { + sortMode = config.GetBindable<SortMode>(OsuSetting.SongSelectSortingMode); + groupMode = config.GetBindable<GroupMode>(OsuSetting.SongSelectGroupingMode); + Children = new Drawable[] { - Background = new Box + new Box { Colour = Color4.Black, Alpha = 0.8f, + Width = 2, RelativeSizeAxes = Axes.Both, }, new Container { - Padding = new MarginPadding(20), + Padding = new MarginPadding(side_margin), RelativeSizeAxes = Axes.Both, Width = 0.5f, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - Children = new Drawable[] + // Reverse ChildID so that dropdowns in the top section appear on top of the bottom section. + Child = new ReverseChildIDFillFlowContainer<Drawable> { - searchTextBox = new SearchTextBox + RelativeSizeAxes = Axes.Both, + Spacing = new Vector2(0, 5), + Children = new[] { - RelativeSizeAxes = Axes.X, - Exit = () => Exit?.Invoke(), - }, - new Box - { - RelativeSizeAxes = Axes.X, - Height = 1, - Colour = OsuColour.Gray(80), - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - }, - new FillFlowContainer - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Direction = FillDirection.Horizontal, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] + new Container { - groupTabs = new OsuTabControl<GroupMode> + RelativeSizeAxes = Axes.X, + Height = 60, + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - Height = 24, - Width = 0.5f, - AutoSort = true, - Current = { Value = GroupMode.Title } - }, - //spriteText = new OsuSpriteText - //{ - // Font = @"Exo2.0-Bold", - // Text = "Sort results by", - // Size = 14, - // Margin = new MarginPadding - // { - // Top = 5, - // Bottom = 5 - // }, - //}, - sortTabs = new OsuTabControl<SortMode> - { - RelativeSizeAxes = Axes.X, - Width = 0.5f, - Height = 24, - AutoSort = true, - Current = { Value = SortMode.Title } + searchTextBox = new SeekLimitedSearchTextBox { RelativeSizeAxes = Axes.X }, + new Box + { + RelativeSizeAxes = Axes.X, + Height = 1, + Colour = OsuColour.Gray(80), + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + }, + new FillFlowContainer + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Direction = FillDirection.Horizontal, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(OsuTabControl<SortMode>.HORIZONTAL_SPACING, 0), + Children = new Drawable[] + { + new OsuTabControlCheckbox + { + Text = "Show converted", + Current = config.GetBindable<bool>(OsuSetting.ShowConvertedBeatmaps), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + sortTabs = new OsuTabControl<SortMode> + { + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Height = 24, + AutoSort = true, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + AccentColour = colours.GreenLight, + Current = { BindTarget = sortMode } + }, + new OsuSpriteText + { + Text = "Sort by", + Font = OsuFont.GetFont(size: 14), + Margin = new MarginPadding(5), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + } + }, } - } - }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 20, + Children = new Drawable[] + { + collectionDropdown = new CollectionFilterDropdown + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + Width = 0.4f, + } + } + }, + } } } }; - searchTextBox.Current.ValueChanged += _ => FilterChanged?.Invoke(CreateCriteria()); + config.BindWith(OsuSetting.ShowConvertedBeatmaps, showConverted); + showConverted.ValueChanged += _ => updateCriteria(); - groupTabs.PinItem(GroupMode.All); - groupTabs.PinItem(GroupMode.RecentlyPlayed); - groupTabs.Current.ValueChanged += group => Group = group.NewValue; - sortTabs.Current.ValueChanged += sort => Sort = sort.NewValue; + config.BindWith(OsuSetting.DisplayStarsMinimum, minimumStars); + minimumStars.ValueChanged += _ => updateCriteria(); + + config.BindWith(OsuSetting.DisplayStarsMaximum, maximumStars); + maximumStars.ValueChanged += _ => updateCriteria(); + + ruleset.BindTo(parentRuleset); + ruleset.BindValueChanged(_ => updateCriteria()); + + groupMode.BindValueChanged(_ => updateCriteria()); + sortMode.BindValueChanged(_ => updateCriteria()); + + collectionDropdown.Current.ValueChanged += val => + { + if (val.NewValue == null) + // may be null briefly while menu is repopulated. + return; + + updateCriteria(); + }; + + searchTextBox.Current.ValueChanged += _ => updateCriteria(); + + updateCriteria(); } public void Deactivate() { + searchTextBox.ReadOnly = true; searchTextBox.HoldFocus = false; if (searchTextBox.HasFocus) GetContainingInputManager().ChangeFocus(searchTextBox); @@ -166,27 +206,20 @@ namespace osu.Game.Screens.Select public void Activate() { + searchTextBox.ReadOnly = false; searchTextBox.HoldFocus = true; } private readonly IBindable<RulesetInfo> ruleset = new Bindable<RulesetInfo>(); - private Bindable<bool> showConverted; - - public readonly Box Background; - - [BackgroundDependencyLoader(permitNulls: true)] - private void load(OsuColour colours, IBindable<RulesetInfo> parentRuleset, OsuConfigManager config) - { - sortTabs.AccentColour = colours.GreenLight; - - showConverted = config.GetBindable<bool>(OsuSetting.ShowConvertedBeatmaps); - showConverted.ValueChanged += _ => updateCriteria(); - - ruleset.BindTo(parentRuleset); - ruleset.BindValueChanged(_ => updateCriteria(), true); - } + private readonly Bindable<bool> showConverted = new Bindable<bool>(); + private readonly Bindable<double> minimumStars = new BindableDouble(); + private readonly Bindable<double> maximumStars = new BindableDouble(); private void updateCriteria() => FilterChanged?.Invoke(CreateCriteria()); + + protected override bool OnClick(ClickEvent e) => true; + + protected override bool OnHover(HoverEvent e) => true; } } diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 140010ff54..7bddb3e51b 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -2,7 +2,11 @@ // 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.Beatmaps; +using osu.Game.Collections; using osu.Game.Rulesets; using osu.Game.Screens.Select.Filter; @@ -13,6 +17,25 @@ namespace osu.Game.Screens.Select public GroupMode Group; public SortMode Sort; + public BeatmapSetInfo SelectedBeatmapSet; + + public OptionalRange<double> StarDifficulty; + public OptionalRange<float> ApproachRate; + public OptionalRange<float> DrainRate; + public OptionalRange<float> CircleSize; + public OptionalRange<double> Length; + public OptionalRange<double> BPM; + public OptionalRange<int> BeatDivisor; + public OptionalRange<BeatmapSetOnlineStatus> OnlineStatus; + public OptionalTextFilter Creator; + public OptionalTextFilter Artist; + + public OptionalRange<double> UserStarDifficulty = new OptionalRange<double> + { + IsLowerInclusive = true, + IsUpperInclusive = true + }; + public string[] SearchTerms = Array.Empty<string>(); public RulesetInfo Ruleset; @@ -20,14 +43,95 @@ namespace osu.Game.Screens.Select private string searchText; + /// <summary> + /// <see cref="SearchText"/> as a number (if it can be parsed as one). + /// </summary> + public int? SearchNumber { get; private set; } + public string SearchText { get => searchText; set { searchText = value; - SearchTerms = searchText.Split(',', ' ', '!').Where(s => !string.IsNullOrEmpty(s)).ToArray(); + SearchTerms = searchText.Split(new[] { ',', ' ', '!' }, StringSplitOptions.RemoveEmptyEntries).ToArray(); + + SearchNumber = null; + + if (SearchTerms.Length == 1 && int.TryParse(SearchTerms[0], out int parsed)) + SearchNumber = parsed; } } + + /// <summary> + /// The collection to filter beatmaps from. + /// </summary> + [CanBeNull] + public BeatmapCollection Collection; + + public struct OptionalRange<T> : IEquatable<OptionalRange<T>> + where T : struct + { + public bool HasFilter => Max != null || Min != null; + + public bool IsInRange(T value) + { + if (Min != null) + { + int comparison = Comparer<T>.Default.Compare(value, Min.Value); + + if (comparison < 0) + return false; + + if (comparison == 0 && !IsLowerInclusive) + return false; + } + + if (Max != null) + { + int comparison = Comparer<T>.Default.Compare(value, Max.Value); + + if (comparison > 0) + return false; + + if (comparison == 0 && !IsUpperInclusive) + return false; + } + + return true; + } + + public T? Min; + public T? Max; + public bool IsLowerInclusive; + public bool IsUpperInclusive; + + public bool Equals(OptionalRange<T> other) + => EqualityComparer<T?>.Default.Equals(Min, other.Min) + && EqualityComparer<T?>.Default.Equals(Max, other.Max) + && IsLowerInclusive.Equals(other.IsLowerInclusive) + && IsUpperInclusive.Equals(other.IsUpperInclusive); + } + + public struct OptionalTextFilter : IEquatable<OptionalTextFilter> + { + public bool HasFilter => !string.IsNullOrEmpty(SearchTerm); + + public bool Matches(string value) + { + if (!HasFilter) + return true; + + // search term is guaranteed to be non-empty, so if the string we're comparing is empty, it's not matching + if (string.IsNullOrEmpty(value)) + return false; + + return value.Contains(SearchTerm, StringComparison.InvariantCultureIgnoreCase); + } + + public string SearchTerm; + + public bool Equals(OptionalTextFilter other) => SearchTerm == other.SearchTerm; + } } } diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs new file mode 100644 index 0000000000..4b6b3be45c --- /dev/null +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -0,0 +1,212 @@ +// 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. + +using System; +using System.Globalization; +using System.Text.RegularExpressions; +using osu.Game.Beatmaps; + +namespace osu.Game.Screens.Select +{ + internal static class FilterQueryParser + { + private static readonly Regex query_syntax_regex = new Regex( + @"\b(?<key>stars|ar|dr|hp|cs|divisor|length|objects|bpm|status|creator|artist)(?<op>[=:><]+)(?<value>("".*"")|(\S*))", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + internal static void ApplyQueries(FilterCriteria criteria, string query) + { + foreach (Match match in query_syntax_regex.Matches(query)) + { + var key = match.Groups["key"].Value.ToLower(); + var op = match.Groups["op"].Value; + var value = match.Groups["value"].Value; + + parseKeywordCriteria(criteria, key, value, op); + + query = query.Replace(match.ToString(), ""); + } + + criteria.SearchText = query; + } + + private static void parseKeywordCriteria(FilterCriteria criteria, string key, string value, string op) + { + switch (key) + { + case "stars" when parseFloatWithPoint(value, out var stars): + updateCriteriaRange(ref criteria.StarDifficulty, op, stars, 0.01f / 2); + break; + + case "ar" when parseFloatWithPoint(value, out var ar): + updateCriteriaRange(ref criteria.ApproachRate, op, ar, 0.1f / 2); + break; + + 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 "cs" when parseFloatWithPoint(value, out var cs): + updateCriteriaRange(ref criteria.CircleSize, op, cs, 0.1f / 2); + break; + + case "bpm" when parseDoubleWithPoint(value, out var bpm): + updateCriteriaRange(ref criteria.BPM, op, bpm, 0.01d / 2); + break; + + 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 "divisor" when parseInt(value, out var divisor): + updateCriteriaRange(ref criteria.BeatDivisor, op, divisor); + break; + + case "status" when Enum.TryParse<BeatmapSetOnlineStatus>(value, true, out var statusValue): + updateCriteriaRange(ref criteria.OnlineStatus, op, statusValue); + break; + + case "creator": + updateCriteriaText(ref criteria.Creator, op, value); + break; + + case "artist": + updateCriteriaText(ref criteria.Artist, op, value); + break; + } + } + + private static int getLengthScale(string value) => + value.EndsWith("ms", StringComparison.Ordinal) ? 1 : + value.EndsWith('s') ? 1000 : + value.EndsWith('m') ? 60000 : + value.EndsWith('h') ? 3600000 : 1000; + + private static bool parseFloatWithPoint(string value, out float result) => + float.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result); + + private static bool parseDoubleWithPoint(string value, out double result) => + double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result); + + private static bool parseInt(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) + { + switch (op) + { + case "=": + case ":": + textFilter.SearchTerm = value.Trim('"'); + break; + } + } + + private static void updateCriteriaRange(ref FilterCriteria.OptionalRange<float> range, string op, float value, float tolerance = 0.05f) + { + switch (op) + { + default: + return; + + case "=": + case ":": + range.Min = value - tolerance; + range.Max = value + tolerance; + break; + + case ">": + range.Min = value + tolerance; + break; + + case ">=": + case ">:": + range.Min = value - tolerance; + break; + + case "<": + range.Max = value - tolerance; + break; + + case "<=": + case "<:": + range.Max = value + tolerance; + break; + } + } + + private static void updateCriteriaRange(ref FilterCriteria.OptionalRange<double> range, string op, double value, double tolerance = 0.05) + { + switch (op) + { + default: + return; + + case "=": + case ":": + range.Min = value - tolerance; + range.Max = value + tolerance; + break; + + case ">": + range.Min = value + tolerance; + break; + + case ">=": + case ">:": + range.Min = value - tolerance; + break; + + case "<": + range.Max = value - tolerance; + break; + + case "<=": + case "<:": + range.Max = value + tolerance; + break; + } + } + + private static void updateCriteriaRange<T>(ref FilterCriteria.OptionalRange<T> range, string op, T value) + where T : struct + { + switch (op) + { + default: + return; + + case "=": + case ":": + range.IsLowerInclusive = range.IsUpperInclusive = true; + range.Min = value; + range.Max = value; + break; + + case ">": + range.IsLowerInclusive = false; + range.Min = value; + break; + + case ">=": + case ">:": + range.IsLowerInclusive = true; + range.Min = value; + break; + + case "<": + range.IsUpperInclusive = false; + range.Max = value; + break; + + case "<=": + case "<:": + range.IsUpperInclusive = true; + range.Max = value; + break; + } + } + } +} diff --git a/osu.Game/Screens/Select/Footer.cs b/osu.Game/Screens/Select/Footer.cs index 0680711f1c..689a11166a 100644 --- a/osu.Game/Screens/Select/Footer.cs +++ b/osu.Game/Screens/Select/Footer.cs @@ -94,7 +94,7 @@ namespace osu.Game.Screens.Select buttons = new FillFlowContainer<FooterButton> { Direction = FillDirection.Horizontal, - Spacing = new Vector2(0.2f, 0), + Spacing = new Vector2(-FooterButton.SHEAR_WIDTH, 0), AutoSizeAxes = Axes.Both, } } @@ -107,5 +107,7 @@ namespace osu.Game.Screens.Select protected override bool OnMouseDown(MouseDownEvent e) => true; protected override bool OnClick(ClickEvent e) => true; + + protected override bool OnHover(HoverEvent e) => true; } } diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index e18a086a10..35970cd960 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -17,7 +17,9 @@ namespace osu.Game.Screens.Select { public class FooterButton : OsuClickableContainer { - private static readonly Vector2 shearing = new Vector2(0.15f, 0); + public const float SHEAR_WIDTH = 7.5f; + + protected static readonly Vector2 SHEAR = new Vector2(SHEAR_WIDTH / Footer.HEIGHT, 0); public string Text { @@ -54,42 +56,62 @@ namespace osu.Game.Screens.Select } } + protected FillFlowContainer ButtonContentContainer; protected readonly Container TextContainer; protected readonly SpriteText SpriteText; private readonly Box box; private readonly Box light; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); - public FooterButton() { AutoSizeAxes = Axes.Both; + Shear = SHEAR; Children = new Drawable[] { - TextContainer = new Container - { - Size = new Vector2(100, 50), - Child = SpriteText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } - }, box = new Box { RelativeSizeAxes = Axes.Both, - Shear = shearing, EdgeSmoothness = new Vector2(2, 0), Colour = Color4.White, Alpha = 0, }, light = new Box { - Shear = shearing, Height = 4, EdgeSmoothness = new Vector2(2, 0), RelativeSizeAxes = Axes.X, }, + new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + ButtonContentContainer = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Horizontal, + Shear = -SHEAR, + AutoSizeAxes = Axes.X, + Height = 50, + Spacing = new Vector2(15, 0), + Children = new Drawable[] + { + TextContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Child = SpriteText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }, + }, + }, + }, + }, }; } @@ -97,6 +119,19 @@ namespace osu.Game.Screens.Select public Action HoverLost; public Key? Hotkey; + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + float horizontalMargin = (100 - TextContainer.Width) / 2; + ButtonContentContainer.Padding = new MarginPadding + { + Left = horizontalMargin, + // right side margin offset to compensate for shear + Right = horizontalMargin - SHEAR_WIDTH / 2 + }; + } + protected override bool OnHover(HoverEvent e) { Hovered?.Invoke(); @@ -118,10 +153,10 @@ namespace osu.Game.Screens.Select return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { box.FadeOut(Footer.TRANSITION_LENGTH, Easing.OutQuint); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs index fce4d1b2e2..02333da0dc 100644 --- a/osu.Game/Screens/Select/FooterButtonMods.cs +++ b/osu.Game/Screens/Select/FooterButtonMods.cs @@ -3,15 +3,17 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Screens.Play.HUD; using osu.Game.Rulesets.Mods; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osuTK; +using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Screens.Select @@ -24,21 +26,26 @@ namespace osu.Game.Screens.Select set => modDisplay.Current = value; } - private readonly FooterModDisplay modDisplay; + protected readonly OsuSpriteText MultiplierText; + private readonly ModDisplay modDisplay; + private Color4 lowMultiplierColour; + private Color4 highMultiplierColour; public FooterButtonMods() { - Add(new Container + ButtonContentContainer.Add(modDisplay = new ModDisplay { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Child = modDisplay = new FooterModDisplay - { - DisplayUnrankedText = false, - Scale = new Vector2(0.8f) - }, - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Left = 70 } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + DisplayUnrankedText = false, + Scale = new Vector2(0.8f), + ExpansionMode = ExpansionMode.AlwaysContracted, + }); + ButtonContentContainer.Add(MultiplierText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.GetFont(weight: FontWeight.Bold), }); } @@ -47,18 +54,36 @@ namespace osu.Game.Screens.Select { SelectedColour = colours.Yellow; DeselectedColour = SelectedColour.Opacity(0.5f); + lowMultiplierColour = colours.Red; + highMultiplierColour = colours.Green; Text = @"mods"; Hotkey = Key.F1; } - private class FooterModDisplay : ModDisplay + protected override void LoadComplete() { - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent?.Parent?.ReceivePositionalInputAt(screenSpacePos) ?? false; + base.LoadComplete(); - public FooterModDisplay() - { - AllowExpand = false; - } + Current.BindValueChanged(_ => updateMultiplierText(), true); + } + + private void updateMultiplierText() + { + double multiplier = Current.Value?.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier) ?? 1; + + MultiplierText.Text = multiplier.Equals(1.0) ? string.Empty : $"{multiplier:N2}x"; + + if (multiplier > 1.0) + MultiplierText.FadeColour(highMultiplierColour, 200); + else if (multiplier < 1.0) + MultiplierText.FadeColour(lowMultiplierColour, 200); + else + MultiplierText.FadeColour(Color4.White, 200); + + if (Current.Value?.Count > 0) + modDisplay.FadeIn(); + else + modDisplay.FadeOut(); } } } diff --git a/osu.Game/Screens/Select/FooterButtonRandom.cs b/osu.Game/Screens/Select/FooterButtonRandom.cs index 14c9eb2035..a42e6721db 100644 --- a/osu.Game/Screens/Select/FooterButtonRandom.cs +++ b/osu.Game/Screens/Select/FooterButtonRandom.cs @@ -24,8 +24,11 @@ namespace osu.Game.Screens.Select Anchor = Anchor.Centre, Origin = Anchor.Centre, Text = @"rewind", - Alpha = 0 + 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; } [BackgroundDependencyLoader] @@ -44,11 +47,11 @@ namespace osu.Game.Screens.Select return base.OnKeyDown(e); } - protected override bool OnKeyUp(KeyUpEvent e) + protected override void OnKeyUp(KeyUpEvent e) { secondaryActive = e.ShiftPressed; updateText(); - return base.OnKeyUp(e); + base.OnKeyUp(e); } private void updateText() diff --git a/osu.Game/Screens/Select/ImportFromStablePopup.cs b/osu.Game/Screens/Select/ImportFromStablePopup.cs index 20494829ae..8dab83b24c 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 and scores?"; + 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."; Icon = FontAwesome.Solid.Plane; diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index cb45c00f66..8ddae67dba 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -20,6 +20,9 @@ namespace osu.Game.Screens.Select.Leaderboards { public Action<ScoreInfo> ScoreSelected; + [Resolved] + private RulesetStore rulesets { get; set; } + private BeatmapInfo beatmap; public BeatmapInfo Beatmap @@ -39,6 +42,8 @@ namespace osu.Game.Screens.Select.Leaderboards private bool filterMods; + private IBindable<WeakReference<ScoreInfo>> itemRemoved; + /// <summary> /// Whether to apply the game's currently selected mods as a filter when retrieving scores. /// </summary> @@ -77,12 +82,29 @@ namespace osu.Game.Screens.Select.Leaderboards if (filterMods) UpdateScores(); }; + + itemRemoved = scoreManager.ItemRemoved.GetBoundCopy(); + itemRemoved.BindValueChanged(onScoreRemoved); } + protected override void Reset() + { + base.Reset(); + TopScore = null; + } + + private void onScoreRemoved(ValueChangedEvent<WeakReference<ScoreInfo>> score) => Schedule(RefreshScores); + protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; protected override APIRequest FetchScores(Action<IEnumerable<ScoreInfo>> scoresCallback) { + if (Beatmap == null) + { + PlaceholderState = PlaceholderState.NoneSelected; + return null; + } + if (Scope == BeatmapLeaderboardScope.Local) { var scores = scoreManager @@ -113,7 +135,7 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } - if (Beatmap?.OnlineBeatmapID == null || Beatmap?.Status <= BeatmapSetOnlineStatus.Pending) + if (Beatmap.OnlineBeatmapID == null || Beatmap?.Status <= BeatmapSetOnlineStatus.Pending) { PlaceholderState = PlaceholderState.Unavailable; return null; @@ -135,12 +157,21 @@ namespace osu.Game.Screens.Select.Leaderboards var req = new GetScoresRequest(Beatmap, ruleset.Value ?? Beatmap.Ruleset, Scope, requestMods); - req.Success += r => scoresCallback?.Invoke(r.Scores); + req.Success += r => + { + scoresCallback?.Invoke(r.Scores.Select(s => s.CreateScoreInfo(rulesets))); + TopScore = r.UserScore?.CreateScoreInfo(rulesets); + }; return req; } - protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index) + protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope) + { + Action = () => ScoreSelected?.Invoke(model) + }; + + protected override LeaderboardScore CreateDrawableTopScore(ScoreInfo model) => new LeaderboardScore(model, model.Position, false) { Action = () => ScoreSelected?.Invoke(model) }; diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs index 9e480b61c6..dc4c2ba4e2 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboardScope.cs @@ -1,13 +1,22 @@ // 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. +using System.ComponentModel; + namespace osu.Game.Screens.Select.Leaderboards { public enum BeatmapLeaderboardScope { + [Description("Local Ranking")] Local, + + [Description("Country Ranking")] Country, + + [Description("Global Ranking")] Global, + + [Description("Friend Ranking")] Friend, } } diff --git a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs new file mode 100644 index 0000000000..085ea372c0 --- /dev/null +++ b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs @@ -0,0 +1,53 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Game.Overlays.Dialog; +using osu.Game.Scoring; +using System.Diagnostics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; + +namespace osu.Game.Screens.Select +{ + public class LocalScoreDeleteDialog : PopupDialog + { + private readonly ScoreInfo score; + + [Resolved] + private ScoreManager scoreManager { get; set; } + + [Resolved] + private BeatmapManager beatmapManager { get; set; } + + public LocalScoreDeleteDialog(ScoreInfo score) + { + this.score = score; + Debug.Assert(score != null); + } + + [BackgroundDependencyLoader] + private void load() + { + BeatmapInfo beatmap = beatmapManager.QueryBeatmap(b => b.ID == score.BeatmapInfoID); + Debug.Assert(beatmap != null); + + BodyText = $"{score.User} ({score.DisplayAccuracy}, {score.Rank})"; + + Icon = FontAwesome.Regular.TrashAlt; + HeaderText = "Confirm deletion of local score"; + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = "Yes. Please.", + Action = () => scoreManager?.Delete(score) + }, + new PopupDialogCancelButton + { + Text = "No, I'm still attached.", + }, + }; + } + } +} diff --git a/osu.Game/Screens/Select/MatchSongSelect.cs b/osu.Game/Screens/Select/MatchSongSelect.cs index c5fa9e2396..8692833a21 100644 --- a/osu.Game/Screens/Select/MatchSongSelect.cs +++ b/osu.Game/Screens/Select/MatchSongSelect.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,8 +10,8 @@ using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Online.Multiplayer; -using osu.Game.Rulesets.Mods; using osu.Game.Screens.Multi; +using osu.Game.Screens.Multi.Components; namespace osu.Game.Screens.Select { @@ -21,8 +22,10 @@ namespace osu.Game.Screens.Select public string ShortTitle => "song selection"; public override string Title => ShortTitle.Humanize(); - [Resolved(typeof(Room))] - protected Bindable<PlaylistItem> CurrentItem { get; private set; } + public override bool AllowEditing => false; + + [Resolved(typeof(Room), nameof(Room.Playlist))] + protected BindableList<PlaylistItem> Playlist { get; private set; } [Resolved] private BeatmapManager beatmaps { get; set; } @@ -32,51 +35,48 @@ namespace osu.Game.Screens.Select Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; } + protected override BeatmapDetailArea CreateBeatmapDetailArea() => new MatchBeatmapDetailArea + { + CreateNewItem = createNewItem + }; + protected override bool OnStart() { - var item = new PlaylistItem + switch (Playlist.Count) { - Beatmap = Beatmap.Value.BeatmapInfo, - Ruleset = Ruleset.Value, - RulesetID = Ruleset.Value.ID ?? 0 - }; + case 0: + createNewItem(); + break; - item.RequiredMods.AddRange(Mods.Value); + case 1: + populateItemFromCurrent(Playlist.Single()); + break; + } - Selected?.Invoke(item); - - if (this.IsCurrentScreen()) - this.Exit(); + this.Exit(); return true; } - public override bool OnExiting(IScreen next) + private void createNewItem() { - if (base.OnExiting(next)) - return true; - - if (CurrentItem.Value != null) + PlaylistItem item = new PlaylistItem { - Ruleset.Value = CurrentItem.Value.Ruleset; - Beatmap.Value = beatmaps.GetWorkingBeatmap(CurrentItem.Value.Beatmap); - Mods.Value = CurrentItem.Value.RequiredMods?.ToArray() ?? Array.Empty<Mod>(); - } + ID = Playlist.Count == 0 ? 0 : Playlist.Max(p => p.ID) + 1 + }; - Beatmap.Disabled = true; - Ruleset.Disabled = true; - Mods.Disabled = true; + populateItemFromCurrent(item); - return false; + Playlist.Add(item); } - public override void OnEntering(IScreen last) + private void populateItemFromCurrent(PlaylistItem item) { - base.OnEntering(last); + item.Beatmap.Value = Beatmap.Value.BeatmapInfo; + item.Ruleset.Value = Ruleset.Value; - Beatmap.Disabled = false; - Ruleset.Disabled = false; - Mods.Disabled = false; + item.RequiredMods.Clear(); + item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy())); } } } diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs index a8b5bbbd00..6e2f3cc9df 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs @@ -12,7 +12,6 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; -using osuTK.Input; using osu.Game.Graphics.Containers; namespace osu.Game.Screens.Select.Options @@ -52,18 +51,16 @@ namespace osu.Game.Screens.Select.Options set => secondLine.Text = value; } - public Key? HotKey; - protected override bool OnMouseDown(MouseDownEvent e) { flash.FadeTo(0.1f, 1000, Easing.OutQuint); return base.OnMouseDown(e); } - protected override bool OnMouseUp(MouseUpEvent e) + protected override void OnMouseUp(MouseUpEvent e) { flash.FadeTo(0, 1000, Easing.OutQuint); - return base.OnMouseUp(e); + base.OnMouseUp(e); } protected override bool OnClick(ClickEvent e) @@ -75,17 +72,6 @@ namespace osu.Game.Screens.Select.Options return base.OnClick(e); } - protected override bool OnKeyDown(KeyDownEvent e) - { - if (!e.Repeat && e.Key == HotKey) - { - Click(); - return true; - } - - return false; - } - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); public BeatmapOptionsButton() @@ -121,7 +107,7 @@ namespace osu.Game.Screens.Select.Options { RelativeSizeAxes = Axes.Both, EdgeSmoothness = new Vector2(1.5f, 0), - Blending = BlendingMode.Additive, + Blending = BlendingParameters.Additive, Colour = Color4.White, Alpha = 0, }, diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs index ede526f9da..2676635764 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs @@ -11,6 +11,8 @@ using osuTK; using osuTK.Graphics; using osuTK.Input; using osu.Game.Graphics.Containers; +using osu.Framework.Input.Events; +using System.Linq; namespace osu.Game.Screens.Select.Options { @@ -27,33 +29,6 @@ namespace osu.Game.Screens.Select.Options public override bool BlockScreenWideMouse => false; - protected override void PopIn() - { - base.PopIn(); - - this.FadeIn(transition_duration, Easing.OutQuint); - - if (buttonsContainer.Position.X == 1 || Alpha == 0) - buttonsContainer.MoveToX(x_position - x_movement); - - holder.ScaleTo(new Vector2(1, 1), transition_duration / 2, Easing.OutQuint); - - buttonsContainer.MoveToX(x_position, transition_duration, Easing.OutQuint); - buttonsContainer.TransformSpacingTo(Vector2.Zero, transition_duration, Easing.OutQuint); - } - - protected override void PopOut() - { - base.PopOut(); - - holder.ScaleTo(new Vector2(1, 0), transition_duration / 2, Easing.InSine); - - buttonsContainer.MoveToX(x_position + x_movement, transition_duration, Easing.InSine); - buttonsContainer.TransformSpacingTo(new Vector2(200f, 0f), transition_duration, Easing.InSine); - - this.FadeOut(transition_duration, Easing.InQuint); - } - public BeatmapOptionsOverlay() { AutoSizeAxes = Axes.Y; @@ -87,13 +62,8 @@ namespace osu.Game.Screens.Select.Options /// <param name="secondLine">Text in the second line.</param> /// <param name="colour">Colour of the button.</param> /// <param name="icon">Icon of the button.</param> - /// <param name="hotkey">Hotkey of the button.</param> /// <param name="action">Binding the button does.</param> - /// <param name="depth"> - /// <para>Lower depth to be put on the left, and higher to be put on the right.</para> - /// <para>Notice this is different to <see cref="Footer"/>!</para> - /// </param> - public void AddButton(string firstLine, string secondLine, IconUsage icon, Color4 colour, Action action, Key? hotkey = null, float depth = 0) + public void AddButton(string firstLine, string secondLine, IconUsage icon, Color4 colour, Action action) { var button = new BeatmapOptionsButton { @@ -101,16 +71,63 @@ namespace osu.Game.Screens.Select.Options SecondLineText = secondLine, Icon = icon, ButtonColour = colour, - Depth = depth, Action = () => { Hide(); action?.Invoke(); }, - HotKey = hotkey }; - buttonsContainer.Insert((int)depth, button); + buttonsContainer.Add(button); + } + + protected override void PopIn() + { + base.PopIn(); + + this.FadeIn(transition_duration, Easing.OutQuint); + + if (buttonsContainer.Position.X == 1 || Alpha == 0) + buttonsContainer.MoveToX(x_position - x_movement); + + holder.ScaleTo(new Vector2(1, 1), transition_duration / 2, Easing.OutQuint); + + buttonsContainer.MoveToX(x_position, transition_duration, Easing.OutQuint); + buttonsContainer.TransformSpacingTo(Vector2.Zero, transition_duration, Easing.OutQuint); + } + + protected override void PopOut() + { + base.PopOut(); + + holder.ScaleTo(new Vector2(1, 0), transition_duration / 2, Easing.InSine); + + buttonsContainer.MoveToX(x_position + x_movement, transition_duration, Easing.InSine); + buttonsContainer.TransformSpacingTo(new Vector2(200f, 0f), transition_duration, Easing.InSine); + + this.FadeOut(transition_duration, Easing.InQuint); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + // don't absorb control as ToolbarRulesetSelector uses control + number to navigate + if (e.ControlPressed) return false; + + if (!e.Repeat && e.Key >= Key.Number1 && e.Key <= Key.Number9) + { + int requested = e.Key - Key.Number1; + + // go reverse as buttonsContainer is a ReverseChildIDFillFlowContainer + BeatmapOptionsButton found = buttonsContainer.Children.ElementAtOrDefault((buttonsContainer.Children.Count - 1) - requested); + + if (found != null) + { + found.Click(); + return true; + } + } + + return base.OnKeyDown(e); } } } diff --git a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs new file mode 100644 index 0000000000..c87a4bbc54 --- /dev/null +++ b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs @@ -0,0 +1,150 @@ +// 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. + +using System; +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.Screens.Select.Leaderboards; + +namespace osu.Game.Screens.Select +{ + public class PlayBeatmapDetailArea : BeatmapDetailArea + { + public readonly BeatmapLeaderboard Leaderboard; + + public override WorkingBeatmap Beatmap + { + get => base.Beatmap; + set + { + base.Beatmap = value; + + Leaderboard.Beatmap = value is DummyWorkingBeatmap ? null : value?.BeatmapInfo; + } + } + + private Bindable<TabType> selectedTab; + + private Bindable<bool> selectedModsFilter; + + public PlayBeatmapDetailArea() + { + Add(Leaderboard = new BeatmapLeaderboard { RelativeSizeAxes = Axes.Both }); + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + selectedTab = config.GetBindable<TabType>(OsuSetting.BeatmapDetailTab); + selectedModsFilter = config.GetBindable<bool>(OsuSetting.BeatmapDetailModsFilter); + + selectedTab.BindValueChanged(tab => CurrentTab.Value = getTabItemFromTabType(tab.NewValue), true); + CurrentTab.BindValueChanged(tab => selectedTab.Value = getTabTypeFromTabItem(tab.NewValue)); + + selectedModsFilter.BindValueChanged(checkbox => CurrentModsFilter.Value = checkbox.NewValue, true); + CurrentModsFilter.BindValueChanged(checkbox => selectedModsFilter.Value = checkbox.NewValue); + } + + public override void Refresh() + { + base.Refresh(); + + Leaderboard.RefreshScores(); + } + + protected override void OnTabChanged(BeatmapDetailAreaTabItem tab, bool selectedMods) + { + base.OnTabChanged(tab, selectedMods); + + Leaderboard.FilterMods = selectedMods; + + switch (tab) + { + case BeatmapDetailAreaLeaderboardTabItem<BeatmapLeaderboardScope> leaderboard: + Leaderboard.Scope = leaderboard.Scope; + Leaderboard.Show(); + break; + + default: + Leaderboard.Hide(); + break; + } + } + + protected override BeatmapDetailAreaTabItem[] CreateTabItems() => base.CreateTabItems().Concat(new BeatmapDetailAreaTabItem[] + { + new BeatmapDetailAreaLeaderboardTabItem<BeatmapLeaderboardScope>(BeatmapLeaderboardScope.Local), + new BeatmapDetailAreaLeaderboardTabItem<BeatmapLeaderboardScope>(BeatmapLeaderboardScope.Country), + new BeatmapDetailAreaLeaderboardTabItem<BeatmapLeaderboardScope>(BeatmapLeaderboardScope.Global), + new BeatmapDetailAreaLeaderboardTabItem<BeatmapLeaderboardScope>(BeatmapLeaderboardScope.Friend), + }).ToArray(); + + private BeatmapDetailAreaTabItem getTabItemFromTabType(TabType type) + { + switch (type) + { + case TabType.Details: + return new BeatmapDetailAreaDetailTabItem(); + + case TabType.Local: + return new BeatmapDetailAreaLeaderboardTabItem<BeatmapLeaderboardScope>(BeatmapLeaderboardScope.Local); + + case TabType.Country: + return new BeatmapDetailAreaLeaderboardTabItem<BeatmapLeaderboardScope>(BeatmapLeaderboardScope.Country); + + case TabType.Global: + return new BeatmapDetailAreaLeaderboardTabItem<BeatmapLeaderboardScope>(BeatmapLeaderboardScope.Global); + + case TabType.Friends: + return new BeatmapDetailAreaLeaderboardTabItem<BeatmapLeaderboardScope>(BeatmapLeaderboardScope.Friend); + + default: + throw new ArgumentOutOfRangeException(nameof(type)); + } + } + + private TabType getTabTypeFromTabItem(BeatmapDetailAreaTabItem item) + { + switch (item) + { + case BeatmapDetailAreaDetailTabItem _: + return TabType.Details; + + case BeatmapDetailAreaLeaderboardTabItem<BeatmapLeaderboardScope> leaderboardTab: + switch (leaderboardTab.Scope) + { + case BeatmapLeaderboardScope.Local: + return TabType.Local; + + case BeatmapLeaderboardScope.Country: + return TabType.Country; + + case BeatmapLeaderboardScope.Global: + return TabType.Global; + + case BeatmapLeaderboardScope.Friend: + return TabType.Friends; + + default: + throw new ArgumentOutOfRangeException(nameof(item)); + } + + default: + throw new ArgumentOutOfRangeException(nameof(item)); + } + } + + public enum TabType + { + Details, + Local, + Country, + Global, + Friends + } + } +} diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index 4df6e6a3f3..ee8825640c 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -4,9 +4,14 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; using osu.Framework.Screens; using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Ranking; using osu.Game.Users; using osuTK.Input; @@ -17,6 +22,9 @@ namespace osu.Game.Screens.Select private bool removeAutoModOnResume; private OsuScreen player; + [Resolved(CanBeNull = true)] + private NotificationOverlay notifications { get; set; } + public override bool AllowExternalScreenChange => true; protected override UserActivity InitialActivity => new UserActivity.ChoosingBeatmap(); @@ -24,25 +32,46 @@ namespace osu.Game.Screens.Select [BackgroundDependencyLoader] private void load(OsuColour colours) { - BeatmapOptions.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, () => - { - ValidForResume = false; - Edit(); - }, Key.Number3); + BeatmapOptions.AddButton(@"Edit", @"beatmap", FontAwesome.Solid.PencilAlt, colours.Yellow, () => Edit()); + + ((PlayBeatmapDetailArea)BeatmapDetails).Leaderboard.ScoreSelected += PresentScore; } + protected void PresentScore(ScoreInfo score) => + FinaliseSelection(score.Beatmap, score.Ruleset, () => this.Push(new SoloResultsScreen(score))); + + protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea(); + public override void OnResuming(IScreen last) { + base.OnResuming(last); + player = null; if (removeAutoModOnResume) { - var autoType = Ruleset.Value.CreateInstance().GetAutoplayMod().GetType(); - ModSelect.DeselectTypes(new[] { autoType }, true); + var autoType = Ruleset.Value.CreateInstance().GetAutoplayMod()?.GetType(); + + if (autoType != null) + ModSelect.DeselectTypes(new[] { autoType }, true); + removeAutoModOnResume = false; } + } - base.OnResuming(last); + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Enter: + case Key.KeypadEnter: + // this is a special hard-coded case; we can't rely on OnPressed (of SongSelect) as GlobalActionContainer is + // matching with exact modifier consideration (so Ctrl+Enter would be ignored). + FinaliseSelection(); + return true; + } + + return base.OnKeyDown(e); } protected override bool OnStart() @@ -53,10 +82,19 @@ namespace osu.Game.Screens.Select if (GetContainingInputManager().CurrentState?.Keyboard.ControlPressed == true) { var auto = Ruleset.Value.CreateInstance().GetAutoplayMod(); - var autoType = auto.GetType(); + var autoType = auto?.GetType(); var mods = Mods.Value; + if (autoType == null) + { + notifications?.Post(new SimpleNotification + { + Text = "The current ruleset doesn't have an autoplay mod avalaible!" + }); + return false; + } + if (mods.All(m => m.GetType() != autoType)) { Mods.Value = mods.Append(auto).ToArray(); @@ -64,14 +102,9 @@ namespace osu.Game.Screens.Select } } - Beatmap.Value.Track.Looping = false; - SampleConfirm?.Play(); - LoadComponentAsync(player = new PlayerLoader(() => new Player()), l => - { - if (this.IsCurrentScreen()) this.Push(player); - }); + this.Push(player = new PlayerLoader(() => new Player())); return true; } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 7dd934f91a..b55c0694ef 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -4,7 +4,6 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; -using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -23,7 +22,6 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; using osu.Game.Screens.Menu; -using osu.Game.Screens.Play; using osu.Game.Screens.Select.Options; using osu.Game.Skinning; using osuTK; @@ -33,190 +31,238 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using osu.Framework.Audio.Track; 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; namespace osu.Game.Screens.Select { public abstract class SongSelect : OsuScreen, IKeyBindingHandler<GlobalAction> { - private static readonly Vector2 wedged_container_size = new Vector2(0.5f, 245); + public static readonly float WEDGE_HEIGHT = 245; protected const float BACKGROUND_BLUR = 20; private const float left_area_padding = 20; - public readonly FilterControl FilterControl; + public FilterControl FilterControl { get; private set; } protected virtual bool ShowFooter => true; /// <summary> /// Can be null if <see cref="ShowFooter"/> is false. /// </summary> - protected readonly BeatmapOptionsOverlay BeatmapOptions; + protected BeatmapOptionsOverlay BeatmapOptions { get; private set; } /// <summary> /// Can be null if <see cref="ShowFooter"/> is false. /// </summary> - protected readonly Footer Footer; + protected Footer Footer { get; private set; } /// <summary> /// Contains any panel which is triggered by a footer button. /// Helps keep them located beneath the footer itself. /// </summary> - protected readonly Container FooterPanels; + protected Container FooterPanels { get; private set; } - protected override BackgroundScreen CreateBackground() - { - var background = new BackgroundScreenBeatmap(); - return background; - } + /// <summary> + /// Whether entering editor mode should be allowed. + /// </summary> + public virtual bool AllowEditing => true; - protected readonly BeatmapCarousel Carousel; - private readonly BeatmapInfoWedge beatmapInfoWedge; + [Resolved] + private Bindable<IReadOnlyList<Mod>> selectedMods { get; set; } + + protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(Beatmap.Value); + + protected BeatmapCarousel Carousel { get; private set; } + + private readonly DifficultyRecommender recommender = new DifficultyRecommender(); + + private BeatmapInfoWedge beatmapInfoWedge; private DialogOverlay dialogOverlay; - private BeatmapManager beatmaps; - protected readonly ModSelectOverlay ModSelect; + [Resolved] + private BeatmapManager beatmaps { get; set; } + + protected ModSelectOverlay ModSelect { get; private set; } + + protected SampleChannel SampleConfirm { get; private set; } - protected SampleChannel SampleConfirm; private SampleChannel sampleChangeDifficulty; private SampleChannel sampleChangeBeatmap; - protected readonly BeatmapDetailArea BeatmapDetails; + private Container carouselContainer; + + protected BeatmapDetailArea BeatmapDetails { get; private set; } private readonly Bindable<RulesetInfo> decoupledRuleset = new Bindable<RulesetInfo>(); - [Resolved(canBeNull: true)] + [Resolved] private MusicController music { get; set; } - [Cached] - [Cached(Type = typeof(IBindable<IReadOnlyList<Mod>>))] - private readonly Bindable<IReadOnlyList<Mod>> mods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); // Bound to the game's mods, but is not reset on exiting - - protected SongSelect() + [BackgroundDependencyLoader(true)] + private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores, CollectionManager collections, ManageCollectionsDialog manageCollectionsDialog) { + // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). + transferRulesetValue(); + + LoadComponentAsync(Carousel = new BeatmapCarousel + { + AllowSelection = false, // delay any selection until our bindables are ready to make a good choice. + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Both, + BleedTop = FilterControl.HEIGHT, + BleedBottom = Footer.HEIGHT, + SelectionChanged = updateSelectedBeatmap, + BeatmapSetsChanged = carouselBeatmapsLoaded, + GetRecommendedBeatmap = recommender.GetRecommendedBeatmap, + }, c => carouselContainer.Child = c); + AddRangeInternal(new Drawable[] { - new ParallaxContainer - { - Masking = true, - ParallaxAmount = 0.005f, - RelativeSizeAxes = Axes.Both, - Children = new[] - { - new WedgeBackground - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = -150 }, - Size = new Vector2(wedged_container_size.X, 1), - } - } - }, - new Container - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(wedged_container_size.X, 1), - Padding = new MarginPadding - { - Bottom = Footer.HEIGHT, - Top = wedged_container_size.Y + left_area_padding, - Left = left_area_padding, - Right = left_area_padding * 2, - }, - Child = BeatmapDetails = new BeatmapDetailArea - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Top = 10, Right = 5 }, - } - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 2, //avoid horizontal masking so the panels don't clip when screen stack is pushed. - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 0.5f, - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Top = FilterControl.HEIGHT, - Bottom = Footer.HEIGHT - }, - Child = Carousel = new BeatmapCarousel - { - Masking = false, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(1 - wedged_container_size.X, 1), - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - SelectionChanged = updateSelectedBeatmap, - BeatmapSetsChanged = carouselBeatmapsLoaded, - }, - }, - FilterControl = new FilterControl - { - RelativeSizeAxes = Axes.X, - Height = FilterControl.HEIGHT, - FilterChanged = c => Carousel.Filter(c), - Background = { Width = 2 }, - Exit = () => - { - if (this.IsCurrentScreen()) - this.Exit(); - }, - }, - } - }, - }, - beatmapInfoWedge = new BeatmapInfoWedge - { - Size = wedged_container_size, - RelativeSizeAxes = Axes.X, - Margin = new MarginPadding - { - Top = left_area_padding, - Right = left_area_padding, - }, - }, + recommender, new ResetScrollContainer(() => Carousel.ScrollToSelected()) { RelativeSizeAxes = Axes.Y, Width = 250, - } + }, + new VerticalMaskingContainer + { + Children = new Drawable[] + { + new GridContainer // used for max width implementation + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 850), + }, + Content = new[] + { + new Drawable[] + { + new ParallaxContainer + { + ParallaxAmount = 0.005f, + RelativeSizeAxes = Axes.Both, + Child = new WedgeBackground + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = -150 }, + }, + }, + carouselContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Top = FilterControl.HEIGHT, + Bottom = Footer.HEIGHT + }, + Child = new LoadingSpinner(true) { State = { Value = Visibility.Visible } } + } + }, + } + }, + FilterControl = new FilterControl + { + RelativeSizeAxes = Axes.X, + Height = FilterControl.HEIGHT, + FilterChanged = ApplyFilterToCarousel, + }, + new GridContainer // used for max width implementation + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 0.5f, maxSize: 650), + }, + Content = new[] + { + new Drawable[] + { + new Container + { + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + + Children = new Drawable[] + { + beatmapInfoWedge = new BeatmapInfoWedge + { + Height = WEDGE_HEIGHT, + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding + { + Top = left_area_padding, + Right = left_area_padding, + }, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Bottom = Footer.HEIGHT, + Top = WEDGE_HEIGHT + left_area_padding, + Left = left_area_padding, + Right = left_area_padding * 2, + }, + Child = BeatmapDetails = CreateBeatmapDetailArea().With(d => + { + d.RelativeSizeAxes = Axes.Both; + d.Padding = new MarginPadding { Top = 10, Right = 5 }; + }) + }, + } + }, + }, + } + } + } + }, }); if (ShowFooter) { - AddRangeInternal(new[] + AddRangeInternal(new Drawable[] { - FooterPanels = new Container + new GridContainer // used for max height implementation { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Margin = new MarginPadding { Bottom = Footer.HEIGHT }, - Children = new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - BeatmapOptions = new BeatmapOptionsOverlay(), - ModSelect = new ModSelectOverlay + new Dimension(), + new Dimension(GridSizeMode.Relative, 1f, maxSize: ModSelectOverlay.HEIGHT + Footer.HEIGHT), + }, + Content = new[] + { + null, + new Drawable[] { - RelativeSizeAxes = Axes.X, - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, + FooterPanels = new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = Footer.HEIGHT }, + Children = new Drawable[] + { + BeatmapOptions = new BeatmapOptionsOverlay(), + ModSelect = new ModSelectOverlay + { + Origin = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + } + } + } } } }, @@ -224,31 +270,18 @@ namespace osu.Game.Screens.Select }); } - BeatmapDetails.Leaderboard.ScoreSelected += s => this.Push(new SoloResults(s)); - } - - [BackgroundDependencyLoader(true)] - private void load(BeatmapManager beatmaps, AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores) - { if (Footer != null) { - Footer.AddButton(new FooterButtonMods { Current = mods }, ModSelect); + Footer.AddButton(new FooterButtonMods { Current = Mods }, ModSelect); Footer.AddButton(new FooterButtonRandom { Action = triggerRandom }); Footer.AddButton(new FooterButtonOptions(), BeatmapOptions); - BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => delete(Beatmap.Value.BeatmapSetInfo), Key.Number4, float.MaxValue); - BeatmapOptions.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, colours.Purple, null, Key.Number1); - BeatmapOptions.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, () => clearScores(Beatmap.Value.BeatmapInfo), Key.Number2); + BeatmapOptions.AddButton(@"Manage", @"collections", FontAwesome.Solid.Book, colours.Green, () => manageCollectionsDialog?.Show()); + BeatmapOptions.AddButton(@"Delete", @"all difficulties", FontAwesome.Solid.Trash, colours.Pink, () => delete(Beatmap.Value.BeatmapSetInfo)); + BeatmapOptions.AddButton(@"Remove", @"from unplayed", FontAwesome.Regular.TimesCircle, colours.Purple, null); + BeatmapOptions.AddButton(@"Clear", @"local scores", FontAwesome.Solid.Eraser, colours.Purple, () => clearScores(Beatmap.Value.BeatmapInfo)); } - if (this.beatmaps == null) - this.beatmaps = beatmaps; - - this.beatmaps.ItemAdded += onBeatmapSetAdded; - this.beatmaps.ItemRemoved += onBeatmapSetRemoved; - this.beatmaps.BeatmapHidden += onBeatmapHidden; - this.beatmaps.BeatmapRestored += onBeatmapRestored; - dialogOverlay = dialog; sampleChangeDifficulty = audio.Samples.Get(@"SongSelect/select-difficulty"); @@ -260,21 +293,29 @@ 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().Any() && beatmaps.StableInstallationAvailable) + if (!beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.Minimal).Any() && beatmaps.StableInstallationAvailable) + { dialogOverlay.Push(new ImportFromStablePopup(() => { - Task.Run(beatmaps.ImportFromStableAsync).ContinueWith(_ => scores.ImportFromStableAsync(), TaskContinuationOptions.OnlyOnRanToCompletion); + Task.Run(beatmaps.ImportFromStableAsync) + .ContinueWith(_ => + { + Task.Run(scores.ImportFromStableAsync); + Task.Run(collections.ImportFromStableAsync); + }, TaskContinuationOptions.OnlyOnRanToCompletion); Task.Run(skins.ImportFromStableAsync); })); + } }); } } - protected override void LoadComplete() + protected virtual void ApplyFilterToCarousel(FilterCriteria criteria) { - base.LoadComplete(); + // if not the current screen, we want to get carousel in a good presentation state before displaying (resume or enter). + bool shouldDebounce = this.IsCurrentScreen(); - mods.BindTo(Mods); + Carousel.Filter(criteria, shouldDebounce); } private DependencyContainer dependencies; @@ -290,8 +331,16 @@ namespace osu.Game.Screens.Select return dependencies; } + /// <summary> + /// Creates the beatmap details to be displayed underneath the wedge. + /// </summary> + protected abstract BeatmapDetailArea CreateBeatmapDetailArea(); + public void Edit(BeatmapInfo beatmap = null) { + if (!AllowEditing) + throw new InvalidOperationException($"Attempted to edit when {nameof(AllowEditing)} is disabled"); + Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap ?? beatmapNoDebounce); this.Push(new Editor()); } @@ -300,15 +349,21 @@ namespace osu.Game.Screens.Select /// Call to make a selection and perform the default action for this SongSelect. /// </summary> /// <param name="beatmap">An optional beatmap to override the current carousel selection.</param> - /// <param name="performStartAction">Whether to trigger <see cref="OnStart"/>.</param> - public void FinaliseSelection(BeatmapInfo beatmap = null, bool performStartAction = true) + /// <param name="ruleset">An optional ruleset to override the current carousel selection.</param> + /// <param name="customStartAction">An optional custom action to perform instead of <see cref="OnStart"/>.</param> + public void FinaliseSelection(BeatmapInfo beatmap = null, RulesetInfo ruleset = null, Action customStartAction = null) { // This is very important as we have not yet bound to screen-level bindables before the carousel load is completed. if (!Carousel.BeatmapSetsLoaded) return; - // if we have a pending filter operation, we want to run it now. - // it could change selection (ie. if the ruleset has been changed). + if (ruleset != null) + Ruleset.Value = ruleset; + + transferRulesetValue(); + + // while transferRulesetValue will flush, it only does so if the ruleset changes. + // the user could have changed a filter, and we want to ensure we are 100% up-to-date and consistent here. Carousel.FlushPendingFilterOperations(); // avoid attempting to continue before a selection has been obtained. @@ -325,8 +380,13 @@ namespace osu.Game.Screens.Select selectionChangedDebounce = null; } - if (performStartAction) - OnStart(); + if (customStartAction != null) + { + customStartAction(); + Carousel.AllowSelection = false; + } + else if (OnStart()) + Carousel.AllowSelection = false; } /// <summary> @@ -339,15 +399,30 @@ namespace osu.Game.Screens.Select private void workingBeatmapChanged(ValueChangedEvent<WorkingBeatmap> e) { - if (e.NewValue is DummyWorkingBeatmap) return; + if (e.NewValue is DummyWorkingBeatmap || !this.IsCurrentScreen()) return; - if (this.IsCurrentScreen() && !Carousel.SelectBeatmap(e.NewValue?.BeatmapInfo, false)) - // If selecting new beatmap without bypassing filters failed, there's possibly a ruleset mismatch - if (e.NewValue?.BeatmapInfo?.Ruleset != null && !e.NewValue.BeatmapInfo.Ruleset.Equals(decoupledRuleset.Value)) + Logger.Log($"working beatmap updated to {e.NewValue}"); + + if (!Carousel.SelectBeatmap(e.NewValue.BeatmapInfo, false)) + { + // A selection may not have been possible with filters applied. + + // There was possibly a ruleset mismatch. This is a case we can help things along by updating the game-wide ruleset to match. + if (e.NewValue.BeatmapInfo.Ruleset != null && !e.NewValue.BeatmapInfo.Ruleset.Equals(decoupledRuleset.Value)) { Ruleset.Value = e.NewValue.BeatmapInfo.Ruleset; - Carousel.SelectBeatmap(e.NewValue.BeatmapInfo); + transferRulesetValue(); } + + // Even if a ruleset mismatch was not the cause (ie. a text filter is applied), + // we still want to temporarily show the new beatmap, bypassing filters. + // This will be undone the next time the user changes the filter. + var criteria = FilterControl.CreateCriteria(); + criteria.SelectedBeatmapSet = e.NewValue.BeatmapInfo.BeatmapSet; + Carousel.Filter(criteria); + + Carousel.SelectBeatmap(e.NewValue.BeatmapInfo); + } } // We need to keep track of the last selected beatmap ignoring debounce to play the correct selection sounds. @@ -360,6 +435,7 @@ namespace osu.Game.Screens.Select return; beatmapNoDebounce = beatmap; + performUpdateSelected(); } @@ -373,7 +449,7 @@ namespace osu.Game.Screens.Select } /// <summary> - /// selection has been changed as the result of a user interaction. + /// Selection has been changed as the result of a user interaction. /// </summary> private void performUpdateSelected() { @@ -382,7 +458,7 @@ namespace osu.Game.Screens.Select selectionChangedDebounce?.Cancel(); - if (beatmap == null) + if (beatmapNoDebounce == null) run(); else selectionChangedDebounce = Scheduler.AddDelayed(run, 200); @@ -391,30 +467,25 @@ namespace osu.Game.Screens.Select { Logger.Log($"updating selection with beatmap:{beatmap?.ID.ToString() ?? "null"} ruleset:{ruleset?.ID.ToString() ?? "null"}"); - if (ruleset?.Equals(decoupledRuleset.Value) == false) + if (transferRulesetValue()) { - Logger.Log($"ruleset changed from \"{decoupledRuleset.Value}\" to \"{ruleset}\""); + Mods.Value = Array.Empty<Mod>(); - mods.Value = Array.Empty<Mod>(); - decoupledRuleset.Value = ruleset; - - // force a filter before attempting to change the beatmap. - // we may still be in the wrong ruleset as there is a debounce delay on ruleset changes. - Carousel.Filter(null, false); - - // Filtering only completes after the carousel runs Update. - // If we also have a pending beatmap change we should delay it one frame. - selectionChangedDebounce = Schedule(run); - return; + // transferRulesetValue() may trigger a refilter. If the current selection does not match the new ruleset, we want to switch away from it. + // The default logic on WorkingBeatmap change is to switch to a matching ruleset (see workingBeatmapChanged()), but we don't want that here. + // We perform an early selection attempt and clear out the beatmap selection to avoid a second ruleset change (revert). + if (beatmap != null && !Carousel.SelectBeatmap(beatmap, false)) + beatmap = null; } // We may be arriving here due to another component changing the bindable Beatmap. // In these cases, the other component has already loaded the beatmap, so we don't need to do so again. - if (!Equals(beatmap, Beatmap.Value.BeatmapInfo)) + if (!EqualityComparer<BeatmapInfo>.Default.Equals(beatmap, Beatmap.Value.BeatmapInfo)) { Logger.Log($"beatmap changed from \"{Beatmap.Value.BeatmapInfo}\" to \"{beatmap}\""); - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap, Beatmap.Value); + WorkingBeatmap previous = Beatmap.Value; + Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap, previous); if (beatmap != null) { @@ -427,7 +498,8 @@ namespace osu.Game.Screens.Select if (this.IsCurrentScreen()) ensurePlayingSelected(); - UpdateBeatmap(Beatmap.Value); + + updateComponentFromBeatmap(Beatmap.Value); } } @@ -445,6 +517,10 @@ namespace osu.Game.Screens.Select this.FadeInFromZero(250); FilterControl.Activate(); + + ModSelect.SelectedMods.BindTo(selectedMods); + + beginLooping(); } private const double logo_transition = 250; @@ -485,17 +561,27 @@ namespace osu.Game.Screens.Select public override void OnResuming(IScreen last) { - BeatmapDetails.Leaderboard.RefreshScores(); + base.OnResuming(last); - Beatmap.Value.Track.Looping = true; + // required due to https://github.com/ppy/osu-framework/issues/3218 + ModSelect.SelectedMods.Disabled = false; + ModSelect.SelectedMods.BindTo(selectedMods); + + Carousel.AllowSelection = true; + + BeatmapDetails.Refresh(); + + beginLooping(); + music.ResetTrackAdjustments(); if (Beatmap != null && !Beatmap.Value.BeatmapSetInfo.DeletePending) { - UpdateBeatmap(Beatmap.Value); - ensurePlayingSelected(); - } + updateComponentFromBeatmap(Beatmap.Value); - base.OnResuming(last); + // restart playback on returning to song select, regardless. + // not sure this should be a permanent thing (we may want to leave a user pause paused even on returning) + music.Play(requestedByUser: true); + } this.FadeIn(250); @@ -506,10 +592,13 @@ namespace osu.Game.Screens.Select public override void OnSuspending(IScreen next) { + ModSelect.SelectedMods.UnbindFrom(selectedMods); ModSelect.Hide(); BeatmapOptions.Hide(); + endLooping(); + this.ScaleTo(1.1f, 250, Easing.InSine); this.FadeOut(250); @@ -520,12 +609,6 @@ namespace osu.Game.Screens.Select public override bool OnExiting(IScreen next) { - if (ModSelect.State.Value == Visibility.Visible) - { - ModSelect.Hide(); - return true; - } - if (base.OnExiting(next)) return true; @@ -535,11 +618,43 @@ namespace osu.Game.Screens.Select FilterControl.Deactivate(); - if (Beatmap.Value.Track != null) - Beatmap.Value.Track.Looping = false; + endLooping(); - mods.UnbindAll(); - Mods.Value = Array.Empty<Mod>(); + return false; + } + + private bool isHandlingLooping; + + private void beginLooping() + { + Debug.Assert(!isHandlingLooping); + + music.CurrentTrack.Looping = isHandlingLooping = true; + + music.TrackChanged += ensureTrackLooping; + } + + private void endLooping() + { + // may be called multiple times during screen exit process. + if (!isHandlingLooping) + return; + + music.CurrentTrack.Looping = isHandlingLooping = false; + + music.TrackChanged -= ensureTrackLooping; + } + + private void ensureTrackLooping(WorkingBeatmap beatmap, TrackChangeDirection changeDirection) + => music.CurrentTrack.Looping = true; + + public override bool OnBackButton() + { + if (ModSelect.State.Value == Visibility.Visible) + { + ModSelect.Hide(); + return true; + } return false; } @@ -550,13 +665,8 @@ namespace osu.Game.Screens.Select decoupledRuleset.UnbindAll(); - if (beatmaps != null) - { - beatmaps.ItemAdded -= onBeatmapSetAdded; - beatmaps.ItemRemoved -= onBeatmapSetRemoved; - beatmaps.BeatmapHidden -= onBeatmapHidden; - beatmaps.BeatmapRestored -= onBeatmapRestored; - } + if (music != null) + music.TrackChanged -= ensureTrackLooping; } /// <summary> @@ -564,10 +674,8 @@ namespace osu.Game.Screens.Select /// This is a debounced call (unlike directly binding to WorkingBeatmap.ValueChanged). /// </summary> /// <param name="beatmap">The working beatmap.</param> - protected virtual void UpdateBeatmap(WorkingBeatmap beatmap) + private void updateComponentFromBeatmap(WorkingBeatmap beatmap) { - Logger.Log($"working beatmap updated to {beatmap}"); - if (Background is BackgroundScreenBeatmap backgroundModeBeatmap) { backgroundModeBeatmap.Beatmap = beatmap; @@ -578,39 +686,50 @@ namespace osu.Game.Screens.Select beatmapInfoWedge.Beatmap = beatmap; BeatmapDetails.Beatmap = beatmap; - - if (beatmap.Track != null) - beatmap.Track.Looping = true; } - private void ensurePlayingSelected(bool restart = false) + private readonly WeakReference<ITrack> lastTrack = new WeakReference<ITrack>(null); + + /// <summary> + /// Ensures some music is playing for the current track. + /// Will resume playback from a manual user pause if the track has changed. + /// </summary> + private void ensurePlayingSelected() { - Track track = Beatmap.Value.Track; + ITrack track = music.CurrentTrack; - if ((!track.IsRunning || restart) && music?.IsUserPaused != true) - { - track.RestartPoint = Beatmap.Value.Metadata.PreviewTime; - track.Restart(); - } + bool isNewTrack = !lastTrack.TryGetTarget(out var last) || last != track; + + track.RestartPoint = Beatmap.Value.Metadata.PreviewTime; + + if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack)) + music.Play(true); + + lastTrack.SetTarget(track); } - private void onBeatmapSetAdded(BeatmapSetInfo s) => Carousel.UpdateBeatmapSet(s); - private void onBeatmapSetRemoved(BeatmapSetInfo s) => Carousel.RemoveBeatmapSet(s); - private void onBeatmapRestored(BeatmapInfo b) => Carousel.UpdateBeatmapSet(beatmaps.QueryBeatmapSet(s => s.ID == b.BeatmapSetInfoID)); - private void onBeatmapHidden(BeatmapInfo b) => Carousel.UpdateBeatmapSet(beatmaps.QueryBeatmapSet(s => s.ID == b.BeatmapSetInfoID)); - private void carouselBeatmapsLoaded() { bindBindables(); + Carousel.AllowSelection = true; + // If a selection was already obtained, do not attempt to update the selected beatmap. if (Carousel.SelectedBeatmapSet != null) return; // Attempt to select the current beatmap on the carousel, if it is valid to be selected. - if (!Beatmap.IsDefault && Beatmap.Value.BeatmapSetInfo?.DeletePending == false && Beatmap.Value.BeatmapSetInfo?.Protected == false - && Carousel.SelectBeatmap(Beatmap.Value.BeatmapInfo, false)) - return; + if (!Beatmap.IsDefault && Beatmap.Value.BeatmapSetInfo?.DeletePending == false && Beatmap.Value.BeatmapSetInfo?.Protected == false) + { + if (Carousel.SelectBeatmap(Beatmap.Value.BeatmapInfo, false)) + return; + + // prefer not changing ruleset at this point, so look for another difficulty in the currently playing beatmap + var found = Beatmap.Value.BeatmapSetInfo.Beatmaps.FirstOrDefault(b => b.Ruleset.Equals(decoupledRuleset.Value)); + + if (found != null && Carousel.SelectBeatmap(found, false)) + return; + } // If the current active beatmap could not be selected, select a new random beatmap. if (!Carousel.SelectNextRandom()) @@ -629,18 +748,37 @@ namespace osu.Game.Screens.Select return; // manual binding to parent ruleset to allow for delayed load in the incoming direction. - rulesetNoDebounce = decoupledRuleset.Value = Ruleset.Value; + transferRulesetValue(); + Ruleset.ValueChanged += r => updateSelectedRuleset(r.NewValue); decoupledRuleset.ValueChanged += r => Ruleset.Value = r.NewValue; decoupledRuleset.DisabledChanged += r => Ruleset.Disabled = r; - Beatmap.BindDisabledChanged(disabled => Carousel.AllowSelection = !disabled, true); Beatmap.BindValueChanged(workingBeatmapChanged); boundLocalBindables = true; } + /// <summary> + /// Transfer the game-wide ruleset to the local decoupled ruleset. + /// Will immediately run filter operations if required. + /// </summary> + /// <returns>Whether a transfer occurred.</returns> + private bool transferRulesetValue() + { + if (decoupledRuleset.Value?.Equals(Ruleset.Value) == true) + return false; + + Logger.Log($"decoupled ruleset transferred (\"{decoupledRuleset.Value}\" -> \"{Ruleset.Value}\")"); + rulesetNoDebounce = decoupledRuleset.Value = Ruleset.Value; + + // if we have a pending filter operation, we want to run it now. + // it could change selection (ie. if the ruleset has been changed). + Carousel?.FlushPendingFilterOperations(); + return true; + } + private void delete(BeatmapSetInfo beatmap) { if (beatmap == null || beatmap.ID <= 0) return; @@ -654,7 +792,7 @@ namespace osu.Game.Screens.Select dialogOverlay?.Push(new BeatmapClearScoresDialog(beatmap, () => // schedule done here rather than inside the dialog as the dialog may fade out and never callback. - Schedule(() => BeatmapDetails.Leaderboard.RefreshScores()))); + Schedule(() => BeatmapDetails.Refresh()))); } public virtual bool OnPressed(GlobalAction action) @@ -671,7 +809,9 @@ namespace osu.Game.Screens.Select return false; } - public bool OnReleased(GlobalAction action) => action == GlobalAction.Select; + public void OnReleased(GlobalAction action) + { + } protected override bool OnKeyDown(KeyDownEvent e) { @@ -693,6 +833,29 @@ namespace osu.Game.Screens.Select return base.OnKeyDown(e); } + private class VerticalMaskingContainer : Container + { + private const float panel_overflow = 1.2f; + + protected override Container<Drawable> Content { get; } + + public VerticalMaskingContainer() + { + RelativeSizeAxes = Axes.Both; + Masking = true; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Width = panel_overflow; // avoid horizontal masking so the panels don't clip when screen stack is pushed. + InternalChild = Content = new Container + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 1 / panel_overflow, + }; + } + } + private class ResetScrollContainer : Container { private readonly Action onHoverAction; diff --git a/osu.Game/Screens/StartupScreen.cs b/osu.Game/Screens/StartupScreen.cs index 797f185a37..e5e134fd39 100644 --- a/osu.Game/Screens/StartupScreen.cs +++ b/osu.Game/Screens/StartupScreen.cs @@ -16,6 +16,8 @@ namespace osu.Game.Screens public override bool CursorVisible => false; - public override OverlayActivation InitialOverlayActivationMode => OverlayActivation.Disabled; + public override bool AllowRateAdjustments => false; + + protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.Disabled; } } diff --git a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs new file mode 100644 index 0000000000..fc01f0bd31 --- /dev/null +++ b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs @@ -0,0 +1,77 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Audio; +using osu.Game.Configuration; + +namespace osu.Game.Skinning +{ + /// <summary> + /// A container which overrides existing skin options with beatmap-local values. + /// </summary> + public class BeatmapSkinProvidingContainer : SkinProvidingContainer + { + private Bindable<bool> beatmapSkins; + private Bindable<bool> beatmapHitsounds; + + protected override bool AllowConfigurationLookup + { + get + { + if (beatmapSkins == null) + throw new InvalidOperationException($"{nameof(BeatmapSkinProvidingContainer)} needs to be loaded before being consumed."); + + return beatmapSkins.Value; + } + } + + protected override bool AllowDrawableLookup(ISkinComponent component) + { + if (beatmapSkins == null) + throw new InvalidOperationException($"{nameof(BeatmapSkinProvidingContainer)} needs to be loaded before being consumed."); + + return beatmapSkins.Value; + } + + protected override bool AllowTextureLookup(string componentName) + { + if (beatmapSkins == null) + throw new InvalidOperationException($"{nameof(BeatmapSkinProvidingContainer)} needs to be loaded before being consumed."); + + return beatmapSkins.Value; + } + + protected override bool AllowSampleLookup(ISampleInfo componentName) + { + if (beatmapSkins == null) + throw new InvalidOperationException($"{nameof(BeatmapSkinProvidingContainer)} needs to be loaded before being consumed."); + + return beatmapHitsounds.Value; + } + + public BeatmapSkinProvidingContainer(ISkin skin) + : base(skin) + { + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var config = parent.Get<OsuConfigManager>(); + + beatmapSkins = config.GetBindable<bool>(OsuSetting.BeatmapSkins); + beatmapHitsounds = config.GetBindable<bool>(OsuSetting.BeatmapHitsounds); + + return base.CreateChildDependencies(parent); + } + + [BackgroundDependencyLoader] + private void load() + { + beatmapSkins.BindValueChanged(_ => TriggerSourceChanged()); + beatmapHitsounds.BindValueChanged(_ => TriggerSourceChanged()); + } + } +} diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs new file mode 100644 index 0000000000..78d3a37f7c --- /dev/null +++ b/osu.Game/Skinning/DefaultLegacySkin.cs @@ -0,0 +1,33 @@ +// 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. + +using osu.Framework.Audio; +using osu.Framework.IO.Stores; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public class DefaultLegacySkin : LegacySkin + { + public DefaultLegacySkin(IResourceStore<byte[]> storage, AudioManager audioManager) + : base(Info, storage, audioManager, string.Empty) + { + Configuration.CustomColours["SliderBall"] = new Color4(2, 170, 255, 255); + Configuration.AddComboColours( + new Color4(255, 192, 0, 255), + new Color4(0, 202, 0, 255), + new Color4(18, 124, 255, 255), + new Color4(242, 24, 57, 255) + ); + + Configuration.LegacyVersion = 2.7m; + } + + public static SkinInfo Info { get; } = new SkinInfo + { + ID = -1, // this is temporary until database storage is decided upon. + Name = "osu!classic", + Creator = "team osu!" + }; + } +} diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index c7556dddd5..61d0112c89 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -1,9 +1,14 @@ // 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. +using System.Collections.Generic; 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 osuTK.Graphics; namespace osu.Game.Skinning { @@ -12,13 +17,32 @@ namespace osu.Game.Skinning public DefaultSkin() : base(SkinInfo.Default) { - Configuration = new SkinConfiguration(); + Configuration = new DefaultSkinConfiguration(); } - public override Drawable GetDrawableComponent(string componentName) => null; + public override Drawable GetDrawableComponent(ISkinComponent component) => null; - public override Texture GetTexture(string componentName) => null; + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; - public override SampleChannel GetSample(string sampleName) => null; + public override SampleChannel GetSample(ISampleInfo sampleInfo) => null; + + public override IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup) + { + switch (lookup) + { + // todo: this code is pulled from LegacySkin and should not exist. + // will likely change based on how databased storage of skin configuration goes. + case GlobalSkinColours global: + switch (global) + { + case GlobalSkinColours.ComboColours: + return SkinUtils.As<TValue>(new Bindable<IReadOnlyList<Color4>>(Configuration.ComboColours)); + } + + break; + } + + return null; + } } } diff --git a/osu.Game/Skinning/DefaultSkinConfiguration.cs b/osu.Game/Skinning/DefaultSkinConfiguration.cs new file mode 100644 index 0000000000..5842ee82ee --- /dev/null +++ b/osu.Game/Skinning/DefaultSkinConfiguration.cs @@ -0,0 +1,12 @@ +// 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. + +namespace osu.Game.Skinning +{ + /// <summary> + /// A skin configuration pre-populated with sane defaults. + /// </summary> + public class DefaultSkinConfiguration : SkinConfiguration + { + } +} diff --git a/osu.Game/Skinning/GameplaySkinComponent.cs b/osu.Game/Skinning/GameplaySkinComponent.cs new file mode 100644 index 0000000000..80f6efc07a --- /dev/null +++ b/osu.Game/Skinning/GameplaySkinComponent.cs @@ -0,0 +1,23 @@ +// 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. + +using System.Linq; + +namespace osu.Game.Skinning +{ + public class GameplaySkinComponent<T> : ISkinComponent + { + public readonly T Component; + + public GameplaySkinComponent(T component) + { + Component = component; + } + + protected virtual string RulesetPrefix => string.Empty; + protected virtual string ComponentName => Component.ToString(); + + public string LookupName => + string.Join('/', new[] { "Gameplay", RulesetPrefix, ComponentName }.Where(s => !string.IsNullOrEmpty(s))); + } +} diff --git a/osu.Game/Skinning/GlobalSkinColours.cs b/osu.Game/Skinning/GlobalSkinColours.cs new file mode 100644 index 0000000000..f889371b98 --- /dev/null +++ b/osu.Game/Skinning/GlobalSkinColours.cs @@ -0,0 +1,11 @@ +// 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. + +namespace osu.Game.Skinning +{ + public enum GlobalSkinColours + { + ComboColours, + MenuGlow + } +} diff --git a/osu.Game/Skinning/HUDSkinComponent.cs b/osu.Game/Skinning/HUDSkinComponent.cs new file mode 100644 index 0000000000..cc053421b7 --- /dev/null +++ b/osu.Game/Skinning/HUDSkinComponent.cs @@ -0,0 +1,22 @@ +// 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. + +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 new file mode 100644 index 0000000000..b01be2d5a0 --- /dev/null +++ b/osu.Game/Skinning/HUDSkinComponents.cs @@ -0,0 +1,15 @@ +// 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. + +namespace osu.Game.Skinning +{ + public enum HUDSkinComponents + { + ComboCounter, + ScoreCounter, + AccuracyCounter, + HealthDisplay, + ScoreText, + ComboText, + } +} diff --git a/osu.Game/Skinning/IAnimationTimeReference.cs b/osu.Game/Skinning/IAnimationTimeReference.cs new file mode 100644 index 0000000000..7e52bb8176 --- /dev/null +++ b/osu.Game/Skinning/IAnimationTimeReference.cs @@ -0,0 +1,30 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Timing; + +namespace osu.Game.Skinning +{ + /// <summary> + /// Denotes an object which provides a reference time to start animations from. + /// </summary> + /// <remarks> + /// This should not be used to start an animation immediately at the current time. + /// To do so, use <see cref="LegacySkinExtensions.GetAnimation(ISkin, string, WrapMode, WrapMode, bool, bool, bool, string, bool, double?)"/> with <code>startAtCurrentTime = true</code> instead. + /// </remarks> + [Cached] + public interface IAnimationTimeReference + { + /// <summary> + /// The reference clock. + /// </summary> + IFrameBasedClock Clock { get; } + + /// <summary> + /// The time which animations should be started from, relative to <see cref="Clock"/>. + /// </summary> + double AnimationStartTime { get; } + } +} diff --git a/osu.Game/Skinning/ISkin.cs b/osu.Game/Skinning/ISkin.cs index 0e67a1897c..5abd963773 100644 --- a/osu.Game/Skinning/ISkin.cs +++ b/osu.Game/Skinning/ISkin.cs @@ -1,10 +1,13 @@ // 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. -using System; +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; namespace osu.Game.Skinning { @@ -13,12 +16,46 @@ namespace osu.Game.Skinning /// </summary> public interface ISkin { - Drawable GetDrawableComponent(string componentName); + /// <summary> + /// Retrieve a <see cref="Drawable"/> component implementation. + /// </summary> + /// <param name="component">The requested component.</param> + /// <returns>A drawable representation for the requested component, or null if unavailable.</returns> + [CanBeNull] + Drawable GetDrawableComponent(ISkinComponent component); - Texture GetTexture(string componentName); + /// <summary> + /// Retrieve a <see cref="Texture"/>. + /// </summary> + /// <param name="componentName">The requested texture.</param> + /// <returns>A matching texture, or null if unavailable.</returns> + [CanBeNull] + Texture GetTexture(string componentName) => GetTexture(componentName, default, default); - SampleChannel GetSample(string sampleName); + /// <summary> + /// Retrieve a <see cref="Texture"/>. + /// </summary> + /// <param name="componentName">The requested texture.</param> + /// <param name="wrapModeS">The texture wrap mode in horizontal direction.</param> + /// <param name="wrapModeT">The texture wrap mode in vertical direction.</param> + /// <returns>A matching texture, or null if unavailable.</returns> + [CanBeNull] + Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT); - TValue GetValue<TConfiguration, TValue>(Func<TConfiguration, TValue> query) where TConfiguration : SkinConfiguration; + /// <summary> + /// Retrieve a <see cref="SampleChannel"/>. + /// </summary> + /// <param name="sampleInfo">The requested sample.</param> + /// <returns>A matching sample channel, or null if unavailable.</returns> + [CanBeNull] + SampleChannel GetSample(ISampleInfo sampleInfo); + + /// <summary> + /// Retrieve a configuration value. + /// </summary> + /// <param name="lookup">The requested configuration value.</param> + /// <returns>A matching value boxed in an <see cref="IBindable{TValue}"/>, or null if unavailable.</returns> + [CanBeNull] + IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup); } } diff --git a/osu.Game/Skinning/ISkinComponent.cs b/osu.Game/Skinning/ISkinComponent.cs new file mode 100644 index 0000000000..4bd9f21b6b --- /dev/null +++ b/osu.Game/Skinning/ISkinComponent.cs @@ -0,0 +1,10 @@ +// 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. + +namespace osu.Game.Skinning +{ + public interface ISkinComponent + { + string LookupName { get; } + } +} diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs new file mode 100644 index 0000000000..5eda374337 --- /dev/null +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -0,0 +1,47 @@ +// 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. + +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 + { + private readonly ISkin skin; + + public LegacyAccuracyCounter(ISkin skin) + { + 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() + { + 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; + } + } + } +} diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index 6770da3c66..d647bc4a2d 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -2,16 +2,52 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.IO.Stores; +using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects.Legacy; namespace osu.Game.Skinning { public class LegacyBeatmapSkin : LegacySkin { + protected override bool AllowManiaSkin => false; + protected override bool UseCustomSampleBanks => true; + public LegacyBeatmapSkin(BeatmapInfo beatmap, IResourceStore<byte[]> storage, AudioManager audioManager) : base(createSkinInfo(beatmap), new LegacySkinResourceStore<BeatmapSetFileInfo>(beatmap.BeatmapSet, storage), audioManager, beatmap.Path) { + // Disallow default colours fallback on beatmap skins to allow using parent skin combo colours. (via SkinProvidingContainer) + Configuration.AllowDefaultComboColoursFallback = false; + } + + public override IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup) + { + switch (lookup) + { + case LegacySkinConfiguration.LegacySetting s when s == LegacySkinConfiguration.LegacySetting.Version: + // For lookup simplicity, ignore beatmap-level versioning completely. + + // If it is decided that we need this due to beatmaps somehow using it, the default (1.0 specified in LegacySkinDecoder.CreateTemplateObject) + // needs to be removed else it will cause incorrect skin behaviours. This is due to the config lookup having no context of which skin + // it should be returning the version for. + return null; + } + + return base.GetConfig<TLookup, TValue>(lookup); + } + + public override SampleChannel GetSample(ISampleInfo sampleInfo) + { + if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0) + { + // When no custom sample bank is provided, always fall-back to the default samples. + return null; + } + + return base.GetSample(sampleInfo); } private static SkinInfo createSkinInfo(BeatmapInfo beatmap) => diff --git a/osu.Game/Skinning/LegacyColourCompatibility.cs b/osu.Game/Skinning/LegacyColourCompatibility.cs new file mode 100644 index 0000000000..b842b50426 --- /dev/null +++ b/osu.Game/Skinning/LegacyColourCompatibility.cs @@ -0,0 +1,46 @@ +// 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. + +using osu.Framework.Graphics; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + /// <summary> + /// Compatibility methods to convert osu!stable colours to osu!lazer-compatible ones. Should be used for legacy skins only. + /// </summary> + public static class LegacyColourCompatibility + { + /// <summary> + /// Forces an alpha of 1 if a given <see cref="Color4"/> is fully transparent. + /// </summary> + /// <remarks> + /// This is equivalent to setting colour post-constructor in osu!stable. + /// </remarks> + /// <param name="colour">The <see cref="Color4"/> to disallow zero alpha on.</param> + /// <returns>The resultant <see cref="Color4"/>.</returns> + public static Color4 DisallowZeroAlpha(Color4 colour) + { + if (colour.A == 0) + colour.A = 1; + return colour; + } + + /// <summary> + /// Applies a <see cref="Color4"/> to a <see cref="Drawable"/>, doubling the alpha value into the <see cref="Drawable.Alpha"/> property. + /// </summary> + /// <remarks> + /// This is equivalent to setting colour in the constructor in osu!stable. + /// </remarks> + /// <param name="drawable">The <see cref="Drawable"/> to apply the colour to.</param> + /// <param name="colour">The <see cref="Color4"/> to apply.</param> + /// <returns>The given <paramref name="drawable"/>.</returns> + public static T ApplyWithDoubledAlpha<T>(T drawable, Color4 colour) + where T : Drawable + { + drawable.Alpha = colour.A; + drawable.Colour = DisallowZeroAlpha(colour); + return drawable; + } + } +} diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs new file mode 100644 index 0000000000..2921d46467 --- /dev/null +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -0,0 +1,266 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; +using osu.Game.Screens.Play.HUD; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public class LegacyHealthDisplay : CompositeDrawable, IHealthDisplay + { + private const double epic_cutoff = 0.5; + + private readonly Skin skin; + private LegacyHealthPiece fill; + private LegacyHealthPiece marker; + + private float maxFillWidth; + + private bool isNewStyle; + + public Bindable<double> Current { get; } = new BindableDouble(1) + { + MinValue = 0, + MaxValue = 1 + }; + + public LegacyHealthDisplay(Skin skin) + { + this.skin = skin; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + isNewStyle = getTexture(skin, "marker") != null; + + // background implementation is the same for both versions. + AddInternal(new Sprite { Texture = getTexture(skin, "bg") }); + + if (isNewStyle) + { + AddRangeInternal(new[] + { + fill = new LegacyNewStyleFill(skin), + marker = new LegacyNewStyleMarker(skin), + }); + } + else + { + AddRangeInternal(new[] + { + fill = new LegacyOldStyleFill(skin), + marker = new LegacyOldStyleMarker(skin), + }); + } + + fill.Current.BindTo(Current); + marker.Current.BindTo(Current); + + maxFillWidth = fill.Width; + } + + protected override void Update() + { + base.Update(); + + fill.Width = Interpolation.ValueAt( + Math.Clamp(Clock.ElapsedFrameTime, 0, 200), + fill.Width, (float)Current.Value * maxFillWidth, 0, 200, Easing.OutQuint); + + marker.Position = fill.Position + new Vector2(fill.DrawWidth, isNewStyle ? fill.DrawHeight / 2 : 0); + } + + public void Flash(JudgementResult result) => marker.Flash(result); + + private static Texture getTexture(Skin skin, string name) => skin.GetTexture($"scorebar-{name}"); + + private static Color4 getFillColour(double hp) + { + if (hp < 0.2) + return Interpolation.ValueAt(0.2 - hp, Color4.Black, Color4.Red, 0, 0.2); + + if (hp < epic_cutoff) + return Interpolation.ValueAt(0.5 - hp, Color4.White, Color4.Black, 0, 0.5); + + return Color4.White; + } + + public class LegacyOldStyleMarker : LegacyMarker + { + private readonly Texture normalTexture; + private readonly Texture dangerTexture; + private readonly Texture superDangerTexture; + + public LegacyOldStyleMarker(Skin skin) + { + normalTexture = getTexture(skin, "ki"); + dangerTexture = getTexture(skin, "kidanger"); + superDangerTexture = getTexture(skin, "kidanger2"); + } + + public override Sprite CreateSprite() => new Sprite + { + Texture = normalTexture, + Origin = Anchor.Centre, + }; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(hp => + { + if (hp.NewValue < 0.2f) + Main.Texture = superDangerTexture; + else if (hp.NewValue < epic_cutoff) + Main.Texture = dangerTexture; + else + Main.Texture = normalTexture; + }); + } + } + + public class LegacyNewStyleMarker : LegacyMarker + { + private readonly Skin skin; + + public LegacyNewStyleMarker(Skin skin) + { + this.skin = skin; + } + + public override Sprite CreateSprite() => new Sprite + { + Texture = getTexture(skin, "marker"), + Origin = Anchor.Centre, + }; + + protected override void Update() + { + base.Update(); + + Main.Colour = getFillColour(Current.Value); + Main.Blending = Current.Value < epic_cutoff ? BlendingParameters.Inherit : BlendingParameters.Additive; + } + } + + internal class LegacyOldStyleFill : LegacyHealthPiece + { + public LegacyOldStyleFill(Skin skin) + { + // required for sizing correctly.. + var firstFrame = getTexture(skin, "colour-0"); + + if (firstFrame == null) + { + InternalChild = new Sprite { Texture = getTexture(skin, "colour") }; + Size = InternalChild.Size; + } + else + { + InternalChild = skin.GetAnimation("scorebar-colour", true, true, startAtCurrentTime: false, applyConfigFrameRate: true) ?? Drawable.Empty(); + Size = new Vector2(firstFrame.DisplayWidth, firstFrame.DisplayHeight); + } + + Position = new Vector2(3, 10) * 1.6f; + Masking = true; + } + } + + internal class LegacyNewStyleFill : LegacyHealthPiece + { + public LegacyNewStyleFill(Skin skin) + { + InternalChild = new Sprite + { + Texture = getTexture(skin, "colour"), + }; + + Size = InternalChild.Size; + Position = new Vector2(7.5f, 7.8f) * 1.6f; + Masking = true; + } + + protected override void Update() + { + base.Update(); + Colour = getFillColour(Current.Value); + } + } + + public abstract class LegacyMarker : LegacyHealthPiece + { + protected Sprite Main; + + private Sprite explode; + + protected LegacyMarker() + { + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + Main = CreateSprite(), + explode = CreateSprite().With(s => + { + s.Alpha = 0; + s.Blending = BlendingParameters.Additive; + }), + }; + } + + public abstract Sprite CreateSprite(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(val => + { + if (val.NewValue > val.OldValue) + bulgeMain(); + }); + } + + public override void Flash(JudgementResult result) + { + bulgeMain(); + + bool isEpic = Current.Value >= epic_cutoff; + + explode.Blending = isEpic ? BlendingParameters.Additive : BlendingParameters.Inherit; + explode.ScaleTo(1).Then().ScaleTo(isEpic ? 2 : 1.6f, 120); + explode.FadeOutFromOne(120); + } + + private void bulgeMain() => + Main.ScaleTo(1.4f).Then().ScaleTo(1, 200, Easing.Out); + } + + public class LegacyHealthPiece : CompositeDrawable, IHealthDisplay + { + public Bindable<double> Current { get; } = new Bindable<double>(); + + public virtual void Flash(JudgementResult result) + { + } + } + } +} diff --git a/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs new file mode 100644 index 0000000000..35a6140cbc --- /dev/null +++ b/osu.Game/Skinning/LegacyManiaSkinConfiguration.cs @@ -0,0 +1,63 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps.Formats; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public class LegacyManiaSkinConfiguration : IHasCustomColours + { + /// <summary> + /// Conversion factor from converting legacy positioning values (based in x480 dimensions) to x768. + /// </summary> + public const float POSITION_SCALE_FACTOR = 1.6f; + + /// <summary> + /// Size of a legacy column in the default skin, used for determining relative scale factors. + /// </summary> + public const float DEFAULT_COLUMN_SIZE = 30 * POSITION_SCALE_FACTOR; + + public readonly int Keys; + + public Dictionary<string, Color4> CustomColours { get; } = new Dictionary<string, Color4>(); + + public Dictionary<string, string> ImageLookups = new Dictionary<string, string>(); + + public readonly float[] ColumnLineWidth; + public readonly float[] ColumnSpacing; + public readonly float[] ColumnWidth; + public readonly float[] ExplosionWidth; + public readonly float[] HoldNoteLightWidth; + + public float HitPosition = (480 - 402) * POSITION_SCALE_FACTOR; + public float LightPosition = (480 - 413) * POSITION_SCALE_FACTOR; + public bool ShowJudgementLine = true; + public bool KeysUnderNotes; + + public LegacyManiaSkinConfiguration(int keys) + { + Keys = keys; + + ColumnLineWidth = new float[keys + 1]; + ColumnSpacing = new float[keys - 1]; + ColumnWidth = new float[keys]; + ExplosionWidth = new float[keys]; + HoldNoteLightWidth = new float[keys]; + + ColumnLineWidth.AsSpan().Fill(2); + ColumnWidth.AsSpan().Fill(DEFAULT_COLUMN_SIZE); + } + + private float? minimumColumnWidth; + + public float MinimumColumnWidth + { + get => minimumColumnWidth ?? ColumnWidth.Min(); + set => minimumColumnWidth = value; + } + } +} diff --git a/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs new file mode 100644 index 0000000000..a99710ea96 --- /dev/null +++ b/osu.Game/Skinning/LegacyManiaSkinConfigurationLookup.cs @@ -0,0 +1,57 @@ +// 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. + +namespace osu.Game.Skinning +{ + public class LegacyManiaSkinConfigurationLookup + { + public readonly int Keys; + public readonly LegacyManiaSkinConfigurationLookups Lookup; + public readonly int? TargetColumn; + + public LegacyManiaSkinConfigurationLookup(int keys, LegacyManiaSkinConfigurationLookups lookup, int? targetColumn = null) + { + Keys = keys; + Lookup = lookup; + TargetColumn = targetColumn; + } + } + + public enum LegacyManiaSkinConfigurationLookups + { + ColumnWidth, + ColumnSpacing, + LightImage, + LeftLineWidth, + RightLineWidth, + HitPosition, + LightPosition, + HitTargetImage, + ShowJudgementLine, + KeyImage, + KeyImageDown, + NoteImage, + HoldNoteHeadImage, + HoldNoteTailImage, + HoldNoteBodyImage, + HoldNoteLightImage, + HoldNoteLightScale, + ExplosionImage, + ExplosionScale, + ColumnLineColour, + JudgementLineColour, + ColumnBackgroundColour, + ColumnLightColour, + MinimumColumnWidth, + LeftStageImage, + RightStageImage, + BottomStageImage, + Hit300g, + Hit300, + Hit200, + Hit100, + Hit50, + Hit0, + KeysUnderNotes, + } +} diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs new file mode 100644 index 0000000000..3dbec23194 --- /dev/null +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -0,0 +1,150 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using osu.Game.Beatmaps.Formats; + +namespace osu.Game.Skinning +{ + public class LegacyManiaSkinDecoder : LegacyDecoder<List<LegacyManiaSkinConfiguration>> + { + public LegacyManiaSkinDecoder() + : base(1) + { + } + + private readonly List<string> pendingLines = new List<string>(); + private LegacyManiaSkinConfiguration currentConfig; + + protected override void OnBeginNewSection(Section section) + { + base.OnBeginNewSection(section); + + // If a new section is reached with pending lines remaining, they can all be discarded as there isn't a valid configuration to parse them into. + pendingLines.Clear(); + currentConfig = null; + } + + protected override void ParseLine(List<LegacyManiaSkinConfiguration> output, Section section, string line) + { + line = StripComments(line); + + switch (section) + { + case Section.Mania: + var pair = SplitKeyVal(line); + + switch (pair.Key) + { + case "Keys": + currentConfig = new LegacyManiaSkinConfiguration(int.Parse(pair.Value, CultureInfo.InvariantCulture)); + + // Silently ignore duplicate configurations. + if (output.All(c => c.Keys != currentConfig.Keys)) + output.Add(currentConfig); + + // All existing lines can be flushed now that we have a valid configuration. + flushPendingLines(); + break; + + default: + pendingLines.Add(line); + + // Hold all lines until a "Keys" item is found. + if (currentConfig != null) + flushPendingLines(); + break; + } + + break; + } + } + + private void flushPendingLines() + { + Debug.Assert(currentConfig != null); + + foreach (var line in pendingLines) + { + var pair = SplitKeyVal(line); + + switch (pair.Key) + { + case "ColumnLineWidth": + parseArrayValue(pair.Value, currentConfig.ColumnLineWidth, false); + break; + + case "ColumnSpacing": + parseArrayValue(pair.Value, currentConfig.ColumnSpacing); + break; + + case "ColumnWidth": + parseArrayValue(pair.Value, currentConfig.ColumnWidth); + break; + + case "HitPosition": + currentConfig.HitPosition = (480 - float.Parse(pair.Value, CultureInfo.InvariantCulture)) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; + break; + + case "LightPosition": + currentConfig.LightPosition = (480 - float.Parse(pair.Value, CultureInfo.InvariantCulture)) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; + break; + + case "JudgementLine": + currentConfig.ShowJudgementLine = pair.Value == "1"; + break; + + case "KeysUnderNotes": + currentConfig.KeysUnderNotes = pair.Value == "1"; + break; + + case "LightingNWidth": + parseArrayValue(pair.Value, currentConfig.ExplosionWidth); + break; + + case "LightingLWidth": + parseArrayValue(pair.Value, currentConfig.HoldNoteLightWidth); + break; + + case "WidthForNoteHeightScale": + float minWidth = float.Parse(pair.Value, CultureInfo.InvariantCulture) * LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR; + if (minWidth > 0) + currentConfig.MinimumColumnWidth = minWidth; + break; + + case string _ when pair.Key.StartsWith("Colour", StringComparison.Ordinal): + HandleColours(currentConfig, line); + break; + + // Custom sprite paths + case string _ when pair.Key.StartsWith("NoteImage", StringComparison.Ordinal): + case string _ when pair.Key.StartsWith("KeyImage", StringComparison.Ordinal): + case string _ when pair.Key.StartsWith("Hit", StringComparison.Ordinal): + case string _ when pair.Key.StartsWith("Stage", StringComparison.Ordinal): + case string _ when pair.Key.StartsWith("Lighting", StringComparison.Ordinal): + currentConfig.ImageLookups[pair.Key] = pair.Value; + break; + } + } + + pendingLines.Clear(); + } + + private void parseArrayValue(string value, float[] output, bool applyScaleFactor = true) + { + string[] values = value.Split(','); + + for (int i = 0; i < values.Length; i++) + { + if (i >= output.Length) + break; + + output[i] = float.Parse(values[i], CultureInfo.InvariantCulture) * (applyScaleFactor ? LegacyManiaSkinConfiguration.POSITION_SCALE_FACTOR : 1); + } + } + } +} diff --git a/osu.Game/Skinning/LegacyRollingCounter.cs b/osu.Game/Skinning/LegacyRollingCounter.cs new file mode 100644 index 0000000000..8aa9d4e9af --- /dev/null +++ b/osu.Game/Skinning/LegacyRollingCounter.cs @@ -0,0 +1,51 @@ +// 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. + +using System; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Skinning +{ + /// <summary> + /// An integer <see cref="RollingCounter{T}"/> that uses number sprites from a legacy skin. + /// </summary> + public class LegacyRollingCounter : RollingCounter<int> + { + private readonly ISkin skin; + + private readonly string fontName; + private readonly float fontOverlap; + + protected override bool IsRollingProportional => true; + + /// <summary> + /// Creates a new <see cref="LegacyRollingCounter"/>. + /// </summary> + /// <param name="skin">The <see cref="ISkin"/> from which to get counter number sprites.</param> + /// <param name="fontName">The name of the legacy font to use.</param> + /// <param name="fontOverlap"> + /// 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. + /// </param> + public LegacyRollingCounter(ISkin skin, string fontName, float fontOverlap) + { + this.skin = skin; + this.fontName = fontName; + this.fontOverlap = fontOverlap; + } + + protected override double GetProportionalDuration(int currentValue, int newValue) + { + return Math.Abs(newValue - currentValue) * 75.0; + } + + protected sealed override OsuSpriteText CreateSpriteText() => + new LegacySpriteText(skin, fontName) + { + Spacing = new Vector2(-fontOverlap, 0f) + }; + } +} diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs new file mode 100644 index 0000000000..5bffeff5a8 --- /dev/null +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -0,0 +1,40 @@ +// 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. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Skinning +{ + public class LegacyScoreCounter : ScoreCounter + { + private readonly ISkin skin; + + protected override double RollingDuration => 1000; + protected override Easing RollingEasing => Easing.Out; + + public new Bindable<double> Current { get; } = new Bindable<double>(); + + public LegacyScoreCounter(ISkin skin) + : 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); + } +} diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 513a024a36..fb020f4e39 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -1,29 +1,59 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; -using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; -using osu.Game.Database; -using osu.Game.Graphics.Sprites; +using osu.Game.Audio; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; using osuTK; +using osuTK.Graphics; namespace osu.Game.Skinning { public class LegacySkin : Skin { + [CanBeNull] protected TextureStore Textures; + [CanBeNull] protected IResourceStore<SampleChannel> Samples; + /// <summary> + /// Whether texture for the keys exists. + /// Used to determine if the mania ruleset is skinned. + /// </summary> + private readonly Lazy<bool> hasKeyTexture; + + protected virtual bool AllowManiaSkin => hasKeyTexture.Value; + + /// <summary> + /// Whether this skin can use samples with a custom bank (custom sample set in stable terminology). + /// Added in order to match sample lookup logic from stable (in stable, only the beatmap skin could use samples with a custom sample bank). + /// </summary> + protected virtual bool UseCustomSampleBanks => false; + + public new LegacySkinConfiguration Configuration + { + get => base.Configuration as LegacySkinConfiguration; + set => base.Configuration = value; + } + + private readonly Dictionary<int, LegacyManiaSkinConfiguration> maniaConfigurations = new Dictionary<int, LegacyManiaSkinConfiguration>(); + public LegacySkin(SkinInfo skin, IResourceStore<byte[]> storage, AudioManager audioManager) : this(skin, new LegacySkinResourceStore<SkinFileInfo>(skin, storage), audioManager, "skin.ini") { @@ -32,15 +62,43 @@ namespace osu.Game.Skinning protected LegacySkin(SkinInfo skin, IResourceStore<byte[]> storage, AudioManager audioManager, string filename) : base(skin) { - Stream stream = storage.GetStream(filename); - if (stream != null) - using (StreamReader reader = new StreamReader(stream)) - Configuration = new LegacySkinDecoder().Decode(reader); - else - Configuration = new SkinConfiguration(); + using (var stream = storage?.GetStream(filename)) + { + if (stream != null) + { + using (LineBufferedReader reader = new LineBufferedReader(stream, true)) + Configuration = new LegacySkinDecoder().Decode(reader); - Samples = audioManager.GetSampleStore(storage); - Textures = new TextureStore(new TextureLoaderStore(storage)); + stream.Seek(0, SeekOrigin.Begin); + + using (LineBufferedReader reader = new LineBufferedReader(stream)) + { + var maniaList = new LegacyManiaSkinDecoder().Decode(reader); + + foreach (var config in maniaList) + maniaConfigurations[config.Keys] = config; + } + } + else + Configuration = new LegacySkinConfiguration(); + } + + if (storage != null) + { + var samples = audioManager?.GetSampleStore(storage); + if (samples != null) + samples.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; + + Samples = samples; + Textures = new TextureStore(new TextureLoaderStore(storage)); + + (storage as ResourceStore<byte[]>)?.AddExtension("ogg"); + } + + // todo: this shouldn't really be duplicated here (from ManiaLegacySkinTransformer). we need to come up with a better solution. + hasKeyTexture = new Lazy<bool>(() => this.GetAnimation( + lookupForMania<string>(new LegacyManiaSkinConfigurationLookup(4, LegacyManiaSkinConfigurationLookups.KeyImage, 0))?.Value ?? "mania-key1", true, + true) != null); } protected override void Dispose(bool isDisposing) @@ -50,170 +108,364 @@ namespace osu.Game.Skinning Samples?.Dispose(); } - public override Drawable GetDrawableComponent(string componentName) + public override IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup) { - switch (componentName) + switch (lookup) { - case "Play/Miss": - componentName = "hit0"; + case GlobalSkinColours colour: + switch (colour) + { + case GlobalSkinColours.ComboColours: + var comboColours = Configuration.ComboColours; + if (comboColours != null) + return SkinUtils.As<TValue>(new Bindable<IReadOnlyList<Color4>>(comboColours)); + + break; + + default: + return SkinUtils.As<TValue>(getCustomColour(Configuration, colour.ToString())); + } + break; - case "Play/Meh": - componentName = "hit50"; + case SkinCustomColourLookup customColour: + return SkinUtils.As<TValue>(getCustomColour(Configuration, customColour.Lookup.ToString())); + + case LegacyManiaSkinConfigurationLookup maniaLookup: + if (!AllowManiaSkin) + return null; + + var result = lookupForMania<TValue>(maniaLookup); + if (result != null) + return result; + break; - case "Play/Good": - componentName = "hit100"; - break; + case LegacySkinConfiguration.LegacySetting legacy: + return legacySettingLookup<TValue>(legacy); - case "Play/Great": - componentName = "hit300"; - break; - - case "Play/osu/number-text": - return !hasFont(Configuration.HitCircleFont) - ? null - : new LegacySpriteText(Textures, Configuration.HitCircleFont) - { - Scale = new Vector2(0.96f), - // Spacing value was reverse-engineered from the ratio of the rendered sprite size in the visual inspector vs the actual texture size - Spacing = new Vector2(-Configuration.HitCircleOverlap * 0.89f, 0) - }; + default: + return genericLookup<TLookup, TValue>(lookup); } - // temporary allowance is given for skins the fact that stable handles non-animatable items such as hitcircles (incorrectly) - // by (incorrectly) displaying the first frame of animation rather than the non-animated version. - // users have used this to "hide" certain elements like hit300. - var texture = GetTexture($"{componentName}-0") ?? GetTexture(componentName); - - if (texture == null) - return null; - - return new Sprite { Texture = texture }; + return null; } - public override Texture GetTexture(string componentName) + private IBindable<TValue> lookupForMania<TValue>(LegacyManiaSkinConfigurationLookup maniaLookup) { - float ratio = 2; + if (!maniaConfigurations.TryGetValue(maniaLookup.Keys, out var existing)) + maniaConfigurations[maniaLookup.Keys] = existing = new LegacyManiaSkinConfiguration(maniaLookup.Keys); - var texture = Textures.Get($"{componentName}@2x"); - - if (texture == null) + switch (maniaLookup.Lookup) { - ratio = 1; - texture = Textures.Get(componentName); + case LegacyManiaSkinConfigurationLookups.ColumnWidth: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(new Bindable<float>(existing.ColumnWidth[maniaLookup.TargetColumn.Value])); + + case LegacyManiaSkinConfigurationLookups.ColumnSpacing: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(new Bindable<float>(existing.ColumnSpacing[maniaLookup.TargetColumn.Value])); + + case LegacyManiaSkinConfigurationLookups.HitPosition: + return SkinUtils.As<TValue>(new Bindable<float>(existing.HitPosition)); + + case LegacyManiaSkinConfigurationLookups.LightPosition: + return SkinUtils.As<TValue>(new Bindable<float>(existing.LightPosition)); + + case LegacyManiaSkinConfigurationLookups.ShowJudgementLine: + return SkinUtils.As<TValue>(new Bindable<bool>(existing.ShowJudgementLine)); + + case LegacyManiaSkinConfigurationLookups.ExplosionImage: + return SkinUtils.As<TValue>(getManiaImage(existing, "LightingN")); + + case LegacyManiaSkinConfigurationLookups.ExplosionScale: + Debug.Assert(maniaLookup.TargetColumn != null); + + if (GetConfig<LegacySkinConfiguration.LegacySetting, decimal>(LegacySkinConfiguration.LegacySetting.Version)?.Value < 2.5m) + return SkinUtils.As<TValue>(new Bindable<float>(1)); + + if (existing.ExplosionWidth[maniaLookup.TargetColumn.Value] != 0) + return SkinUtils.As<TValue>(new Bindable<float>(existing.ExplosionWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + return SkinUtils.As<TValue>(new Bindable<float>(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + case LegacyManiaSkinConfigurationLookups.ColumnLineColour: + return SkinUtils.As<TValue>(getCustomColour(existing, "ColourColumnLine")); + + case LegacyManiaSkinConfigurationLookups.JudgementLineColour: + return SkinUtils.As<TValue>(getCustomColour(existing, "ColourJudgementLine")); + + case LegacyManiaSkinConfigurationLookups.ColumnBackgroundColour: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(getCustomColour(existing, $"Colour{maniaLookup.TargetColumn + 1}")); + + case LegacyManiaSkinConfigurationLookups.ColumnLightColour: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(getCustomColour(existing, $"ColourLight{maniaLookup.TargetColumn + 1}")); + + case LegacyManiaSkinConfigurationLookups.MinimumColumnWidth: + return SkinUtils.As<TValue>(new Bindable<float>(existing.MinimumColumnWidth)); + + case LegacyManiaSkinConfigurationLookups.NoteImage: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}")); + + case LegacyManiaSkinConfigurationLookups.HoldNoteHeadImage: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}H")); + + case LegacyManiaSkinConfigurationLookups.HoldNoteTailImage: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}T")); + + case LegacyManiaSkinConfigurationLookups.HoldNoteBodyImage: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(getManiaImage(existing, $"NoteImage{maniaLookup.TargetColumn}L")); + + case LegacyManiaSkinConfigurationLookups.HoldNoteLightImage: + return SkinUtils.As<TValue>(getManiaImage(existing, "LightingL")); + + case LegacyManiaSkinConfigurationLookups.HoldNoteLightScale: + Debug.Assert(maniaLookup.TargetColumn != null); + + if (GetConfig<LegacySkinConfiguration.LegacySetting, decimal>(LegacySkinConfiguration.LegacySetting.Version)?.Value < 2.5m) + return SkinUtils.As<TValue>(new Bindable<float>(1)); + + if (existing.HoldNoteLightWidth[maniaLookup.TargetColumn.Value] != 0) + return SkinUtils.As<TValue>(new Bindable<float>(existing.HoldNoteLightWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + return SkinUtils.As<TValue>(new Bindable<float>(existing.ColumnWidth[maniaLookup.TargetColumn.Value] / LegacyManiaSkinConfiguration.DEFAULT_COLUMN_SIZE)); + + case LegacyManiaSkinConfigurationLookups.KeyImage: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}")); + + case LegacyManiaSkinConfigurationLookups.KeyImageDown: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(getManiaImage(existing, $"KeyImage{maniaLookup.TargetColumn}D")); + + case LegacyManiaSkinConfigurationLookups.LeftStageImage: + return SkinUtils.As<TValue>(getManiaImage(existing, "StageLeft")); + + case LegacyManiaSkinConfigurationLookups.RightStageImage: + return SkinUtils.As<TValue>(getManiaImage(existing, "StageRight")); + + case LegacyManiaSkinConfigurationLookups.BottomStageImage: + return SkinUtils.As<TValue>(getManiaImage(existing, "StageBottom")); + + case LegacyManiaSkinConfigurationLookups.LightImage: + return SkinUtils.As<TValue>(getManiaImage(existing, "StageLight")); + + case LegacyManiaSkinConfigurationLookups.HitTargetImage: + return SkinUtils.As<TValue>(getManiaImage(existing, "StageHint")); + + case LegacyManiaSkinConfigurationLookups.LeftLineWidth: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(new Bindable<float>(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value])); + + case LegacyManiaSkinConfigurationLookups.RightLineWidth: + Debug.Assert(maniaLookup.TargetColumn != null); + return SkinUtils.As<TValue>(new Bindable<float>(existing.ColumnLineWidth[maniaLookup.TargetColumn.Value + 1])); + + case LegacyManiaSkinConfigurationLookups.Hit0: + case LegacyManiaSkinConfigurationLookups.Hit50: + case LegacyManiaSkinConfigurationLookups.Hit100: + case LegacyManiaSkinConfigurationLookups.Hit200: + case LegacyManiaSkinConfigurationLookups.Hit300: + case LegacyManiaSkinConfigurationLookups.Hit300g: + return SkinUtils.As<TValue>(getManiaImage(existing, maniaLookup.Lookup.ToString())); + + case LegacyManiaSkinConfigurationLookups.KeysUnderNotes: + return SkinUtils.As<TValue>(new Bindable<bool>(existing.KeysUnderNotes)); } - if (texture != null) - texture.ScaleAdjust = ratio / 0.72f; // brings sizing roughly in-line with stable - - return texture; + return null; } - public override SampleChannel GetSample(string sampleName) => Samples.Get(sampleName); + private IBindable<Color4> getCustomColour(IHasCustomColours source, string lookup) + => source.CustomColours.TryGetValue(lookup, out var col) ? new Bindable<Color4>(col) : null; - private bool hasFont(string fontName) => GetTexture($"{fontName}-0") != null; + private IBindable<string> getManiaImage(LegacyManiaSkinConfiguration source, string lookup) + => source.ImageLookups.TryGetValue(lookup, out var image) ? new Bindable<string>(image) : null; - protected class LegacySkinResourceStore<T> : IResourceStore<byte[]> - where T : INamedFileInfo + [CanBeNull] + private IBindable<TValue> legacySettingLookup<TValue>(LegacySkinConfiguration.LegacySetting legacySetting) { - private readonly IHasFiles<T> source; - private readonly IResourceStore<byte[]> underlyingStore; - - private string getPathForFile(string filename) + switch (legacySetting) { - bool hasExtension = filename.Contains('.'); + case LegacySkinConfiguration.LegacySetting.Version: + return SkinUtils.As<TValue>(new Bindable<decimal>(Configuration.LegacyVersion ?? LegacySkinConfiguration.LATEST_VERSION)); - string lastPiece = filename.Split('/').Last(); - var legacyName = filename.StartsWith("Gameplay/taiko/") ? "taiko-" + lastPiece : lastPiece; - - var file = source.Files.Find(f => - string.Equals(hasExtension ? f.Filename : Path.ChangeExtension(f.Filename, null), legacyName, StringComparison.InvariantCultureIgnoreCase)); - return file?.FileInfo.StoragePath; + default: + return genericLookup<LegacySkinConfiguration.LegacySetting, TValue>(legacySetting); } + } - public LegacySkinResourceStore(IHasFiles<T> source, IResourceStore<byte[]> underlyingStore) + [CanBeNull] + private IBindable<TValue> genericLookup<TLookup, TValue>(TLookup lookup) + { + try { - this.source = source; - this.underlyingStore = underlyingStore; - } - - public Stream GetStream(string name) - { - string path = getPathForFile(name); - return path == null ? null : underlyingStore.GetStream(path); - } - - public IEnumerable<string> GetAvailableResources() => source.Files.Select(f => f.Filename); - - byte[] IResourceStore<byte[]>.Get(string name) => GetAsync(name).Result; - - public Task<byte[]> GetAsync(string name) - { - string path = getPathForFile(name); - return path == null ? Task.FromResult<byte[]>(null) : underlyingStore.GetAsync(path); - } - - #region IDisposable Support - - private bool isDisposed; - - protected virtual void Dispose(bool disposing) - { - if (!isDisposed) + if (Configuration.ConfigDictionary.TryGetValue(lookup.ToString(), out var val)) { - isDisposed = true; + // special case for handling skins which use 1 or 0 to signify a boolean state. + if (typeof(TValue) == typeof(bool)) + val = val == "1" ? "true" : "false"; + + var bindable = new Bindable<TValue>(); + if (val != null) + bindable.Parse(val); + return bindable; } } - - ~LegacySkinResourceStore() + catch { - Dispose(false); } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - #endregion + return null; } - private class LegacySpriteText : OsuSpriteText + private string scorePrefix => GetConfig<LegacySkinConfiguration.LegacySetting, string>(LegacySkinConfiguration.LegacySetting.ScorePrefix)?.Value ?? "score"; + + private string comboPrefix => GetConfig<LegacySkinConfiguration.LegacySetting, string>(LegacySkinConfiguration.LegacySetting.ComboPrefix)?.Value ?? "score"; + + private bool hasScoreFont => this.HasFont(scorePrefix); + + public override Drawable GetDrawableComponent(ISkinComponent component) { - private readonly TextureStore textures; - private readonly string font; - - public LegacySpriteText(TextureStore textures, string font) + switch (component) { - this.textures = textures; - this.font = font; + case HUDSkinComponent hudComponent: + { + if (!hasScoreFont) + return null; - Shadow = false; - UseFullGlyphHeight = false; + switch (hudComponent.Component) + { + 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) + { + Spacing = new Vector2(-(GetConfig<LegacySkinConfiguration.LegacySetting, int>(LegacySkinConfiguration.LegacySetting.ComboOverlap)?.Value ?? -2), 0) + }; + + case HUDSkinComponents.ScoreText: + return new LegacySpriteText(this, scorePrefix) + { + Spacing = new Vector2(-(GetConfig<LegacySkinConfiguration.LegacySetting, int>(LegacySkinConfiguration.LegacySetting.ScoreOverlap)?.Value ?? -2), 0) + }; + } + + return null; + } + + case GameplaySkinComponent<HitResult> resultComponent: + switch (resultComponent.Component) + { + case HitResult.Miss: + return this.GetAnimation("hit0", true, false); + + case HitResult.Meh: + return this.GetAnimation("hit50", true, false); + + case HitResult.Ok: + return this.GetAnimation("hit100", true, false); + + case HitResult.Great: + return this.GetAnimation("hit300", true, false); + } + + break; } - protected override Texture GetTextureForCharacter(char c) + return this.GetAnimation(component.LookupName, false, false); + } + + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + foreach (var name in getFallbackNames(componentName)) { - string textureName = $"{font}-{c}"; - - // Approximate value that brings character sizing roughly in-line with stable - float ratio = 36; - - var texture = textures.Get($"{textureName}@2x"); + float ratio = 2; + var texture = Textures?.Get($"{name}@2x", wrapModeS, wrapModeT); if (texture == null) { - ratio = 18; - texture = textures.Get(textureName); + ratio = 1; + texture = Textures?.Get(name, wrapModeS, wrapModeT); } - if (texture != null) - texture.ScaleAdjust = ratio; + if (texture == null) + continue; + texture.ScaleAdjust = ratio; return texture; } + + return null; + } + + public override SampleChannel GetSample(ISampleInfo sampleInfo) + { + IEnumerable<string> lookupNames; + + if (sampleInfo is HitSampleInfo hitSample) + lookupNames = getLegacyLookupNames(hitSample); + else + { + lookupNames = sampleInfo.LookupNames.SelectMany(getFallbackNames); + } + + foreach (var lookup in lookupNames) + { + var sample = Samples?.Get(lookup); + + if (sample != null) + return sample; + } + + return null; + } + + private IEnumerable<string> getLegacyLookupNames(HitSampleInfo hitSample) + { + var lookupNames = hitSample.LookupNames.SelectMany(getFallbackNames); + + if (!UseCustomSampleBanks && !string.IsNullOrEmpty(hitSample.Suffix)) + { + // for compatibility with stable, exclude the lookup names with the custom sample bank suffix, if they are not valid for use in this skin. + // using .EndsWith() is intentional as it ensures parity in all edge cases + // (see LegacyTaikoSampleInfo for an example of one - prioritising the taiko prefix should still apply, but the sample bank should not). + lookupNames = lookupNames.Where(name => !name.EndsWith(hitSample.Suffix, StringComparison.Ordinal)); + } + + foreach (var l in lookupNames) + yield return l; + + // also for compatibility, try falling back to non-bank samples (so-called "universal" samples) as the last resort. + // going forward specifying banks shall always be required, even for elements that wouldn't require it on stable, + // which is why this is done locally here. + yield return hitSample.Name; + } + + private IEnumerable<string> getFallbackNames(string componentName) + { + // May be something like "Gameplay/osu/approachcircle" from lazer, or "Arrows/note1" from a user skin. + yield return componentName; + + // Fall back to using the last piece for components coming from lazer (e.g. "Gameplay/osu/approachcircle" -> "approachcircle"). + string lastPiece = componentName.Split('/').Last(); + yield return componentName.StartsWith("Gameplay/taiko/", StringComparison.Ordinal) ? "taiko-" + lastPiece : lastPiece; } } } diff --git a/osu.Game/Skinning/LegacySkinConfiguration.cs b/osu.Game/Skinning/LegacySkinConfiguration.cs new file mode 100644 index 0000000000..84a834ec22 --- /dev/null +++ b/osu.Game/Skinning/LegacySkinConfiguration.cs @@ -0,0 +1,26 @@ +// 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. + +namespace osu.Game.Skinning +{ + public class LegacySkinConfiguration : SkinConfiguration + { + public const decimal LATEST_VERSION = 2.7m; + + /// <summary> + /// Legacy version of this skin. + /// </summary> + public decimal? LegacyVersion { get; internal set; } + + public enum LegacySetting + { + Version, + ComboPrefix, + ComboOverlap, + ScorePrefix, + ScoreOverlap, + AnimationFramerate, + LayeredHitSounds + } + } +} diff --git a/osu.Game/Skinning/LegacySkinDecoder.cs b/osu.Game/Skinning/LegacySkinDecoder.cs index ecb112955c..75b7ba28b9 100644 --- a/osu.Game/Skinning/LegacySkinDecoder.cs +++ b/osu.Game/Skinning/LegacySkinDecoder.cs @@ -1,63 +1,69 @@ // 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. +using System.Globalization; using osu.Game.Beatmaps.Formats; namespace osu.Game.Skinning { - public class LegacySkinDecoder : LegacyDecoder<SkinConfiguration> + public class LegacySkinDecoder : LegacyDecoder<LegacySkinConfiguration> { public LegacySkinDecoder() : base(1) { } - protected override void ParseLine(SkinConfiguration skin, Section section, string line) + protected override void ParseLine(LegacySkinConfiguration skin, Section section, string line) { - line = StripComments(line); - - var pair = SplitKeyVal(line); - - switch (section) + if (section != Section.Colours) { - case Section.General: - switch (pair.Key) - { - case @"Name": - skin.SkinInfo.Name = pair.Value; - break; + line = StripComments(line); - case @"Author": - skin.SkinInfo.Creator = pair.Value; - break; + var pair = SplitKeyVal(line); - case @"CursorExpand": - skin.CursorExpand = pair.Value != "0"; - break; + switch (section) + { + case Section.General: + switch (pair.Key) + { + case @"Name": + skin.SkinInfo.Name = pair.Value; + return; - case @"SliderBorderSize": - skin.SliderBorderSize = Parsing.ParseFloat(pair.Value); - break; - } + case @"Author": + skin.SkinInfo.Creator = pair.Value; + return; - break; + case @"Version": + if (pair.Value == "latest") + skin.LegacyVersion = LegacySkinConfiguration.LATEST_VERSION; + else if (decimal.TryParse(pair.Value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var version)) + skin.LegacyVersion = version; - case Section.Fonts: - switch (pair.Key) - { - case "HitCirclePrefix": - skin.HitCircleFont = pair.Value; - break; + return; + } - case "HitCircleOverlap": - skin.HitCircleOverlap = int.Parse(pair.Value); - break; - } + break; - break; + // osu!catch section only has colour settings + // so no harm in handling the entire section + case Section.CatchTheBeat: + HandleColours(skin, line); + return; + } + + if (!string.IsNullOrEmpty(pair.Key)) + skin.ConfigDictionary[pair.Key] = pair.Value; } base.ParseLine(skin, section, line); } + + protected override LegacySkinConfiguration CreateTemplateObject() + { + var config = base.CreateTemplateObject(); + config.LegacyVersion = 1.0m; + return config; + } } } diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs new file mode 100644 index 0000000000..0ee02a2442 --- /dev/null +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -0,0 +1,107 @@ +// 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. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using static osu.Game.Skinning.LegacySkinConfiguration; + +namespace osu.Game.Skinning +{ + public static class LegacySkinExtensions + { + public static Drawable GetAnimation(this ISkin source, string componentName, bool animatable, bool looping, bool applyConfigFrameRate = false, string animationSeparator = "-", + bool startAtCurrentTime = true, double? frameLength = null) + => source.GetAnimation(componentName, default, default, animatable, looping, applyConfigFrameRate, animationSeparator, startAtCurrentTime, frameLength); + + public static Drawable GetAnimation(this ISkin source, string componentName, WrapMode wrapModeS, WrapMode wrapModeT, bool animatable, bool looping, bool applyConfigFrameRate = false, + string animationSeparator = "-", + bool startAtCurrentTime = true, double? frameLength = null) + { + Texture texture; + + if (animatable) + { + var textures = getTextures().ToArray(); + + if (textures.Length > 0) + { + var animation = new SkinnableTextureAnimation(startAtCurrentTime) + { + DefaultFrameLength = frameLength ?? getFrameLength(source, applyConfigFrameRate, textures), + Loop = looping, + }; + + foreach (var t in textures) + animation.AddFrame(t); + + return animation; + } + } + + // if an animation was not allowed or not found, fall back to a sprite retrieval. + if ((texture = source.GetTexture(componentName, wrapModeS, wrapModeT)) != null) + return new Sprite { Texture = texture }; + + return null; + + IEnumerable<Texture> getTextures() + { + for (int i = 0; true; i++) + { + if ((texture = source.GetTexture($"{componentName}{animationSeparator}{i}", wrapModeS, wrapModeT)) == null) + break; + + yield return texture; + } + } + } + + public static bool HasFont(this ISkin source, string fontPrefix) + => source.GetTexture($"{fontPrefix}-0") != null; + + public class SkinnableTextureAnimation : TextureAnimation + { + [Resolved(canBeNull: true)] + private IAnimationTimeReference timeReference { get; set; } + + public SkinnableTextureAnimation(bool startAtCurrentTime = true) + : base(startAtCurrentTime) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (timeReference != null) + { + Clock = timeReference.Clock; + PlaybackPosition = timeReference.Clock.CurrentTime - timeReference.AnimationStartTime; + } + } + } + + private const double default_frame_time = 1000 / 60d; + + private static double getFrameLength(ISkin source, bool applyConfigFrameRate, Texture[] textures) + { + if (applyConfigFrameRate) + { + var iniRate = source.GetConfig<LegacySetting, int>(LegacySetting.AnimationFramerate); + + if (iniRate?.Value > 0) + return 1000f / iniRate.Value; + + return 1000f / textures.Length; + } + + return default_frame_time; + } + } +} diff --git a/osu.Game/Skinning/LegacySkinResourceStore.cs b/osu.Game/Skinning/LegacySkinResourceStore.cs new file mode 100644 index 0000000000..05d0dee05f --- /dev/null +++ b/osu.Game/Skinning/LegacySkinResourceStore.cs @@ -0,0 +1,42 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Extensions; +using osu.Framework.IO.Stores; +using osu.Game.Database; + +namespace osu.Game.Skinning +{ + public class LegacySkinResourceStore<T> : ResourceStore<byte[]> + where T : INamedFileInfo + { + private readonly IHasFiles<T> source; + + public LegacySkinResourceStore(IHasFiles<T> source, IResourceStore<byte[]> underlyingStore) + : base(underlyingStore) + { + this.source = source; + } + + protected override IEnumerable<string> GetFilenames(string name) + { + if (source.Files == null) + yield break; + + foreach (var filename in base.GetFilenames(name)) + { + var path = getPathForFile(filename.ToStandardisedPath()); + if (path != null) + yield return path; + } + } + + private string getPathForFile(string filename) => + source.Files.Find(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; + + public override IEnumerable<string> GetAvailableResources() => source.Files.Select(f => f.Filename); + } +} diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs new file mode 100644 index 0000000000..ebc4757e75 --- /dev/null +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -0,0 +1,51 @@ +// 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. + +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.Rulesets.Objects.Legacy; +using static osu.Game.Skinning.LegacySkinConfiguration; + +namespace osu.Game.Skinning +{ + /// <summary> + /// Transformer used to handle support of legacy features for individual rulesets. + /// </summary> + public abstract class LegacySkinTransformer : ISkin + { + /// <summary> + /// Source of the <see cref="ISkin"/> which is being transformed. + /// </summary> + protected ISkinSource Source { get; } + + protected LegacySkinTransformer(ISkinSource source) + { + Source = source; + } + + public abstract Drawable GetDrawableComponent(ISkinComponent component); + + public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + => Source.GetTexture(componentName, wrapModeS, wrapModeT); + + public virtual SampleChannel GetSample(ISampleInfo sampleInfo) + { + if (!(sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample)) + return Source.GetSample(sampleInfo); + + var playLayeredHitSounds = GetConfig<LegacySetting, bool>(LegacySetting.LayeredHitSounds); + if (legacySample.IsLayered && playLayeredHitSounds?.Value == false) + return new SampleChannelVirtual(); + + return Source.GetSample(sampleInfo); + } + + public abstract IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup); + } +} diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs new file mode 100644 index 0000000000..5d0e312f7c --- /dev/null +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -0,0 +1,72 @@ +// 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. + +using System.Threading.Tasks; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Text; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Skinning +{ + public class LegacySpriteText : OsuSpriteText + { + private readonly LegacyGlyphStore glyphStore; + + protected override char FixedWidthReferenceCharacter => '5'; + + protected override char[] FixedWidthExcludeCharacters => new[] { ',', '.', '%', 'x' }; + + public LegacySpriteText(ISkin skin, string font = "score") + { + Shadow = false; + UseFullGlyphHeight = false; + + Font = new FontUsage(font, 1, fixedWidth: true); + glyphStore = new LegacyGlyphStore(skin); + } + + protected override TextBuilder CreateTextBuilder(ITexturedGlyphLookupStore store) => base.CreateTextBuilder(glyphStore); + + private class LegacyGlyphStore : ITexturedGlyphLookupStore + { + private readonly ISkin skin; + + public LegacyGlyphStore(ISkin skin) + { + this.skin = skin; + } + + public ITexturedCharacterGlyph Get(string fontName, char character) + { + var lookup = getLookupName(character); + + var texture = skin.GetTexture($"{fontName}-{lookup}"); + + if (texture == null) + return null; + + return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, null), texture, 1f / texture.ScaleAdjust); + } + + private static string getLookupName(char character) + { + switch (character) + { + case ',': + return "comma"; + + case '.': + return "dot"; + + case '%': + return "percent"; + + default: + return character.ToString(); + } + } + + public Task<ITexturedCharacterGlyph> GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); + } + } +} diff --git a/osu.Game/Skinning/LocalSkinOverrideContainer.cs b/osu.Game/Skinning/LocalSkinOverrideContainer.cs deleted file mode 100644 index 7882e0f31b..0000000000 --- a/osu.Game/Skinning/LocalSkinOverrideContainer.cs +++ /dev/null @@ -1,105 +0,0 @@ -// 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. - -using System; -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.Textures; -using osu.Game.Configuration; - -namespace osu.Game.Skinning -{ - /// <summary> - /// A container which overrides existing skin options with beatmap-local values. - /// </summary> - public class LocalSkinOverrideContainer : Container, ISkinSource - { - public event Action SourceChanged; - - private readonly Bindable<bool> beatmapSkins = new Bindable<bool>(); - private readonly Bindable<bool> beatmapHitsounds = new Bindable<bool>(); - - private readonly ISkin skin; - - private ISkinSource fallbackSource; - - public LocalSkinOverrideContainer(ISkin skin) - { - this.skin = skin; - } - - public Drawable GetDrawableComponent(string componentName) - { - Drawable sourceDrawable; - if (beatmapSkins.Value && (sourceDrawable = skin?.GetDrawableComponent(componentName)) != null) - return sourceDrawable; - - return fallbackSource?.GetDrawableComponent(componentName); - } - - public Texture GetTexture(string componentName) - { - Texture sourceTexture; - if (beatmapSkins.Value && (sourceTexture = skin?.GetTexture(componentName)) != null) - return sourceTexture; - - return fallbackSource.GetTexture(componentName); - } - - public SampleChannel GetSample(string sampleName) - { - SampleChannel sourceChannel; - if (beatmapHitsounds.Value && (sourceChannel = skin?.GetSample(sampleName)) != null) - return sourceChannel; - - return fallbackSource?.GetSample(sampleName); - } - - public TValue GetValue<TConfiguration, TValue>(Func<TConfiguration, TValue> query) where TConfiguration : SkinConfiguration - { - TValue val; - if ((skin as Skin)?.Configuration is TConfiguration conf) - if (beatmapSkins.Value && (val = query.Invoke(conf)) != null) - return val; - - return fallbackSource == null ? default : fallbackSource.GetValue(query); - } - - private void onSourceChanged() => SourceChanged?.Invoke(); - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - - fallbackSource = dependencies.Get<ISkinSource>(); - if (fallbackSource != null) - fallbackSource.SourceChanged += onSourceChanged; - - dependencies.CacheAs<ISkinSource>(this); - - var config = dependencies.Get<OsuConfigManager>(); - - config.BindWith(OsuSetting.BeatmapSkins, beatmapSkins); - config.BindWith(OsuSetting.BeatmapHitsounds, beatmapHitsounds); - - beatmapSkins.BindValueChanged(_ => onSourceChanged()); - beatmapHitsounds.BindValueChanged(_ => onSourceChanged()); - - return dependencies; - } - - protected override void Dispose(bool isDisposing) - { - // Must be done before base.Dispose() - SourceChanged = null; - - base.Dispose(isDisposing); - - if (fallbackSource != null) - fallbackSource.SourceChanged -= onSourceChanged; - } - } -} diff --git a/osu.Game/Skinning/PausableSkinnableSound.cs b/osu.Game/Skinning/PausableSkinnableSound.cs new file mode 100644 index 0000000000..4f09aec0b6 --- /dev/null +++ b/osu.Game/Skinning/PausableSkinnableSound.cs @@ -0,0 +1,86 @@ +// 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. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Threading; +using osu.Game.Audio; +using osu.Game.Screens.Play; + +namespace osu.Game.Skinning +{ + public class PausableSkinnableSound : SkinnableSound + { + protected bool RequestedPlaying { get; private set; } + + public PausableSkinnableSound(ISampleInfo hitSamples) + : base(hitSamples) + { + } + + public PausableSkinnableSound(IEnumerable<ISampleInfo> hitSamples) + : base(hitSamples) + { + } + + private readonly IBindable<bool> samplePlaybackDisabled = new Bindable<bool>(); + + private ScheduledDelegate scheduledStart; + + [BackgroundDependencyLoader(true)] + private void load(ISamplePlaybackDisabler samplePlaybackDisabler) + { + // if in a gameplay context, pause sample playback when gameplay is paused. + if (samplePlaybackDisabler != null) + { + samplePlaybackDisabled.BindTo(samplePlaybackDisabler.SamplePlaybackDisabled); + samplePlaybackDisabled.BindValueChanged(disabled => + { + if (!RequestedPlaying) return; + + // let non-looping samples that have already been started play out to completion (sounds better than abruptly cutting off). + if (!Looping) return; + + cancelPendingStart(); + + if (disabled.NewValue) + base.Stop(); + else + { + // schedule so we don't start playing a sample which is no longer alive. + scheduledStart = Schedule(() => + { + if (RequestedPlaying) + base.Play(); + }); + } + }); + } + } + + public override void Play() + { + cancelPendingStart(); + RequestedPlaying = true; + + if (samplePlaybackDisabled.Value) + return; + + base.Play(); + } + + public override void Stop() + { + cancelPendingStart(); + RequestedPlaying = false; + base.Stop(); + } + + private void cancelPendingStart() + { + scheduledStart?.Cancel(); + scheduledStart = null; + } + } +} diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 09c0d3d0bc..4b0cf02c0a 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -3,8 +3,11 @@ using System; 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; namespace osu.Game.Skinning { @@ -12,16 +15,17 @@ namespace osu.Game.Skinning { public readonly SkinInfo SkinInfo; - public virtual SkinConfiguration Configuration { get; protected set; } + public SkinConfiguration Configuration { get; protected set; } - public abstract Drawable GetDrawableComponent(string componentName); + public abstract Drawable GetDrawableComponent(ISkinComponent componentName); - public abstract SampleChannel GetSample(string sampleName); + public abstract SampleChannel GetSample(ISampleInfo sampleInfo); - public abstract Texture GetTexture(string componentName); + public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); - public TValue GetValue<TConfiguration, TValue>(Func<TConfiguration, TValue> query) where TConfiguration : SkinConfiguration - => Configuration is TConfiguration conf ? query.Invoke(conf) : default; + public abstract Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT); + + public abstract IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup); protected Skin(SkinInfo skin) { diff --git a/osu.Game/Skinning/SkinConfigManager.cs b/osu.Game/Skinning/SkinConfigManager.cs new file mode 100644 index 0000000000..682138a2e9 --- /dev/null +++ b/osu.Game/Skinning/SkinConfigManager.cs @@ -0,0 +1,17 @@ +// 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. + +using System; +using osu.Framework.Configuration; + +namespace osu.Game.Skinning +{ + public class SkinConfigManager<TLookup> : ConfigManager<TLookup> where TLookup : struct, Enum + { + protected override void PerformLoad() + { + } + + protected override bool PerformSave() => false; + } +} diff --git a/osu.Game/Skinning/SkinConfiguration.cs b/osu.Game/Skinning/SkinConfiguration.cs index 043622f8ce..25a924c929 100644 --- a/osu.Game/Skinning/SkinConfiguration.cs +++ b/osu.Game/Skinning/SkinConfiguration.cs @@ -7,26 +7,46 @@ using osuTK.Graphics; namespace osu.Game.Skinning { + /// <summary> + /// An empty skin configuration. + /// </summary> public class SkinConfiguration : IHasComboColours, IHasCustomColours { public readonly SkinInfo SkinInfo = new SkinInfo(); - public List<Color4> ComboColours { get; set; } = new List<Color4> + /// <summary> + /// Whether to allow <see cref="DefaultComboColours"/> as a fallback list for when no combo colours are provided. + /// </summary> + internal bool AllowDefaultComboColoursFallback = true; + + public static List<Color4> DefaultComboColours { get; } = new List<Color4> { - new Color4(17, 136, 170, 255), - new Color4(102, 136, 0, 255), - new Color4(204, 102, 0, 255), - new Color4(121, 9, 13, 255) + new Color4(255, 192, 0, 255), + new Color4(0, 202, 0, 255), + new Color4(18, 124, 255, 255), + new Color4(242, 24, 57, 255), }; - public Dictionary<string, Color4> CustomColours { get; set; } = new Dictionary<string, Color4>(); + private readonly List<Color4> comboColours = new List<Color4>(); - public string HitCircleFont { get; set; } = "default"; + public IReadOnlyList<Color4> ComboColours + { + get + { + if (comboColours.Count > 0) + return comboColours; - public int HitCircleOverlap { get; set; } + if (AllowDefaultComboColoursFallback) + return DefaultComboColours; - public float? SliderBorderSize { get; set; } + return null; + } + } - public bool? CursorExpand { get; set; } = true; + public void AddComboColours(params Color4[] colours) => comboColours.AddRange(colours); + + public Dictionary<string, Color4> CustomColours { get; } = new Dictionary<string, Color4>(); + + public readonly Dictionary<string, string> ConfigDictionary = new Dictionary<string, string>(); } } diff --git a/osu.Game/Skinning/SkinCustomColourLookup.cs b/osu.Game/Skinning/SkinCustomColourLookup.cs new file mode 100644 index 0000000000..b8e5ac9b53 --- /dev/null +++ b/osu.Game/Skinning/SkinCustomColourLookup.cs @@ -0,0 +1,15 @@ +// 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. + +namespace osu.Game.Skinning +{ + public class SkinCustomColourLookup + { + public readonly object Lookup; + + public SkinCustomColourLookup(object lookup) + { + Lookup = lookup; + } + } +} diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index 187ea910a7..b9fe44ef3b 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -24,12 +24,18 @@ namespace osu.Game.Skinning public bool DeletePending { get; set; } - public string FullName => $"\"{Name}\" by {Creator}"; - - public static SkinInfo Default { get; } = new SkinInfo { Name = "osu!lazer", Creator = "team osu!" }; + public static SkinInfo Default { get; } = new SkinInfo + { + Name = "osu!lazer", + Creator = "team osu!" + }; public bool Equals(SkinInfo other) => other != null && ID == other.ID; - public override string ToString() => FullName; + public override string ToString() + { + string author = Creator == null ? string.Empty : $"({Creator})"; + return $"{Name} {author}".Trim(); + } } } diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index d30bc32e0c..2e4c24a89e 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -12,40 +12,52 @@ using Microsoft.EntityFrameworkCore; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Audio; using osu.Game.Database; using osu.Game.IO.Archives; namespace osu.Game.Skinning { + [ExcludeFromDynamicCompile] public class SkinManager : ArchiveModelManager<SkinInfo, SkinFileInfo>, ISkinSource { private readonly AudioManager audio; + private readonly IResourceStore<byte[]> legacyDefaultResources; + public readonly Bindable<Skin> CurrentSkin = new Bindable<Skin>(new DefaultSkin()); public readonly Bindable<SkinInfo> CurrentSkinInfo = new Bindable<SkinInfo>(SkinInfo.Default) { Default = SkinInfo.Default }; - public override string[] HandledExtensions => new[] { ".osk" }; + public override IEnumerable<string> HandledExtensions => new[] { ".osk" }; protected override string[] HashableFileTypes => new[] { ".ini" }; protected override string ImportFromStablePath => "Skins"; - public SkinManager(Storage storage, DatabaseContextFactory contextFactory, IIpcHost importHost, AudioManager audio) + public SkinManager(Storage storage, DatabaseContextFactory contextFactory, IIpcHost importHost, AudioManager audio, IResourceStore<byte[]> legacyDefaultResources) : base(storage, contextFactory, new SkinStore(contextFactory, storage), importHost) { this.audio = audio; + this.legacyDefaultResources = legacyDefaultResources; - ItemRemoved += removedInfo => + ItemRemoved.BindValueChanged(weakRemovedInfo => { - // check the removed skin is not the current user choice. if it is, switch back to default. - if (removedInfo.ID == CurrentSkinInfo.Value.ID) - CurrentSkinInfo.Value = SkinInfo.Default; - }; + if (weakRemovedInfo.NewValue.TryGetTarget(out var removedInfo)) + { + // check the removed skin is not the current user choice. if it is, switch back to default. + if (removedInfo.ID == CurrentSkinInfo.Value.ID) + CurrentSkinInfo.Value = SkinInfo.Default; + } + }); - CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = getSkin(skin.NewValue); + CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue); CurrentSkin.ValueChanged += skin => { if (skin.NewValue.SkinInfo != CurrentSkinInfo.Value) @@ -65,6 +77,7 @@ namespace osu.Game.Skinning { var userSkins = GetAllUserSkins(); userSkins.Insert(0, SkinInfo.Default); + userSkins.Insert(1, DefaultLegacySkin.Info); return userSkins; } @@ -76,21 +89,46 @@ namespace osu.Game.Skinning protected override SkinInfo CreateModel(ArchiveReader archive) => new SkinInfo { Name = archive.Name }; + private const string unknown_creator_string = "Unknown"; + + 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.Creator != null && item.Creator != unknown_creator_string) + { + // this is the optimal way to hash legacy skins, but will need to be reconsidered when we move forward with skin implementation. + // likely, the skin should expose a real version (ie. the version of the skin, not the skin.ini version it's targeting). + return item.ToString().ComputeSHA2Hash(); + } + + // if there was no creator, the ToString above would give the filename, which alone isn't really enough to base any decisions on. + return base.ComputeHash(item, reader); + } + protected override async Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) { await base.Populate(model, archive, cancellationToken); - Skin reference = getSkin(model); + if (model.Name?.Contains(".osk") == true) + populateMetadata(model); + } + + private void populateMetadata(SkinInfo item) + { + Skin reference = GetSkin(item); if (!string.IsNullOrEmpty(reference.Configuration.SkinInfo.Name)) { - model.Name = reference.Configuration.SkinInfo.Name; - model.Creator = reference.Configuration.SkinInfo.Creator; + item.Name = reference.Configuration.SkinInfo.Name; + item.Creator = reference.Configuration.SkinInfo.Creator; } else { - model.Name = model.Name.Replace(".osk", ""); - model.Creator = "Unknown"; + item.Name = item.Name.Replace(".osk", ""); + item.Creator ??= unknown_creator_string; } } @@ -99,11 +137,14 @@ namespace osu.Game.Skinning /// </summary> /// <param name="skinInfo">The skin to lookup.</param> /// <returns>A <see cref="Skin"/> instance correlating to the provided <see cref="SkinInfo"/>.</returns> - private Skin getSkin(SkinInfo skinInfo) + public Skin GetSkin(SkinInfo skinInfo) { if (skinInfo == SkinInfo.Default) return new DefaultSkin(); + if (skinInfo == DefaultLegacySkin.Info) + return new DefaultLegacySkin(legacyDefaultResources, audio); + return new LegacySkin(skinInfo, Files.Store, audio); } @@ -116,12 +157,12 @@ namespace osu.Game.Skinning public event Action SourceChanged; - public Drawable GetDrawableComponent(string componentName) => CurrentSkin.Value.GetDrawableComponent(componentName); + public Drawable GetDrawableComponent(ISkinComponent component) => CurrentSkin.Value.GetDrawableComponent(component); - public Texture GetTexture(string componentName) => CurrentSkin.Value.GetTexture(componentName); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => CurrentSkin.Value.GetTexture(componentName, wrapModeS, wrapModeT); - public SampleChannel GetSample(string sampleName) => CurrentSkin.Value.GetSample(sampleName); + public SampleChannel GetSample(ISampleInfo sampleInfo) => CurrentSkin.Value.GetSample(sampleInfo); - public TValue GetValue<TConfiguration, TValue>(Func<TConfiguration, TValue> query) where TConfiguration : SkinConfiguration => CurrentSkin.Value.GetValue(query); + public IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup) => CurrentSkin.Value.GetConfig<TLookup, TValue>(lookup); } } diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs new file mode 100644 index 0000000000..adf62ed452 --- /dev/null +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -0,0 +1,107 @@ +// 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. + +using System; +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.Textures; +using osu.Game.Audio; + +namespace osu.Game.Skinning +{ + /// <summary> + /// A container which adds a local <see cref="ISkinSource"/> to the hierarchy. + /// </summary> + public class SkinProvidingContainer : Container, ISkinSource + { + public event Action SourceChanged; + + private readonly ISkin skin; + + private ISkinSource fallbackSource; + + protected virtual bool AllowDrawableLookup(ISkinComponent component) => true; + + protected virtual bool AllowTextureLookup(string componentName) => true; + + protected virtual bool AllowSampleLookup(ISampleInfo componentName) => true; + + protected virtual bool AllowConfigurationLookup => true; + + public SkinProvidingContainer(ISkin skin) + { + this.skin = skin; + + RelativeSizeAxes = Axes.Both; + } + + public Drawable GetDrawableComponent(ISkinComponent component) + { + Drawable sourceDrawable; + if (AllowDrawableLookup(component) && (sourceDrawable = skin?.GetDrawableComponent(component)) != null) + return sourceDrawable; + + return fallbackSource?.GetDrawableComponent(component); + } + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + Texture sourceTexture; + if (AllowTextureLookup(componentName) && (sourceTexture = skin?.GetTexture(componentName, wrapModeS, wrapModeT)) != null) + return sourceTexture; + + return fallbackSource?.GetTexture(componentName, wrapModeS, wrapModeT); + } + + public SampleChannel GetSample(ISampleInfo sampleInfo) + { + SampleChannel sourceChannel; + if (AllowSampleLookup(sampleInfo) && (sourceChannel = skin?.GetSample(sampleInfo)) != null) + return sourceChannel; + + return fallbackSource?.GetSample(sampleInfo); + } + + public IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup) + { + if (AllowConfigurationLookup && skin != null) + { + var bindable = skin.GetConfig<TLookup, TValue>(lookup); + if (bindable != null) + return bindable; + } + + return fallbackSource?.GetConfig<TLookup, TValue>(lookup); + } + + protected virtual void TriggerSourceChanged() => SourceChanged?.Invoke(); + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + fallbackSource = dependencies.Get<ISkinSource>(); + if (fallbackSource != null) + fallbackSource.SourceChanged += TriggerSourceChanged; + + dependencies.CacheAs<ISkinSource>(this); + + return dependencies; + } + + protected override void Dispose(bool isDisposing) + { + // Must be done before base.Dispose() + SourceChanged = null; + + base.Dispose(isDisposing); + + if (fallbackSource != null) + fallbackSource.SourceChanged -= TriggerSourceChanged; + } + } +} diff --git a/osu.Game/Skinning/SkinReloadableDrawable.cs b/osu.Game/Skinning/SkinReloadableDrawable.cs index c09d5b1f92..cc9cbf7b59 100644 --- a/osu.Game/Skinning/SkinReloadableDrawable.cs +++ b/osu.Game/Skinning/SkinReloadableDrawable.cs @@ -1,24 +1,33 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. using System; using osu.Framework.Allocation; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; namespace osu.Game.Skinning { /// <summary> /// A drawable which has a callback when the skin changes. /// </summary> - public abstract class SkinReloadableDrawable : CompositeDrawable + public abstract class SkinReloadableDrawable : PoolableDrawable { + /// <summary> + /// Invoked when <see cref="CurrentSkin"/> has changed. + /// </summary> + public event Action OnSkinChanged; + + /// <summary> + /// The current skin source. + /// </summary> + protected ISkinSource CurrentSkin { get; private set; } + private readonly Func<ISkinSource, bool> allowFallback; - private ISkinSource skin; /// <summary> /// Whether fallback to default skin should be allowed if the custom skin is missing this resource. /// </summary> - private bool allowDefaultFallback => allowFallback == null || allowFallback.Invoke(skin); + private bool allowDefaultFallback => allowFallback == null || allowFallback.Invoke(CurrentSkin); /// <summary> /// Create a new <see cref="SkinReloadableDrawable"/> @@ -32,16 +41,25 @@ namespace osu.Game.Skinning [BackgroundDependencyLoader] private void load(ISkinSource source) { - skin = source; - skin.SourceChanged += onChange; + CurrentSkin = source; + CurrentSkin.SourceChanged += onChange; } - private void onChange() => SkinChanged(skin, allowDefaultFallback); + private void onChange() => + // schedule required to avoid calls after disposed. + // note that this has the side-effect of components only performing a skin change when they are alive. + Scheduler.AddOnce(skinChanged); protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); - onChange(); + skinChanged(); + } + + private void skinChanged() + { + SkinChanged(CurrentSkin, allowDefaultFallback); + OnSkinChanged?.Invoke(); } /// <summary> @@ -57,8 +75,10 @@ namespace osu.Game.Skinning { base.Dispose(isDisposing); - if (skin != null) - skin.SourceChanged -= onChange; + if (CurrentSkin != null) + CurrentSkin.SourceChanged -= onChange; + + OnSkinChanged = null; } } } diff --git a/osu.Game/Skinning/SkinUtils.cs b/osu.Game/Skinning/SkinUtils.cs new file mode 100644 index 0000000000..e3bc5e28b8 --- /dev/null +++ b/osu.Game/Skinning/SkinUtils.cs @@ -0,0 +1,21 @@ +// 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. + +using osu.Framework.Bindables; + +namespace osu.Game.Skinning +{ + /// <summary> + /// Contains helper methods to assist in implementing <see cref="ISkin"/>s. + /// </summary> + public static class SkinUtils + { + /// <summary> + /// Converts an <see cref="object"/> to a <see cref="Bindable{TValue}"/>. Used for returning configuration values of specific types. + /// </summary> + /// <param name="value">The value.</param> + /// <typeparam name="TValue">The type of value <paramref name="value"/>, and the type of the resulting bindable.</typeparam> + /// <returns>The resulting bindable.</returns> + public static Bindable<TValue> As<TValue>(object value) => (Bindable<TValue>)value; + } +} diff --git a/osu.Game/Skinning/SkinnableDrawable.cs b/osu.Game/Skinning/SkinnableDrawable.cs index 995cb15136..5a48bc4baf 100644 --- a/osu.Game/Skinning/SkinnableDrawable.cs +++ b/osu.Game/Skinning/SkinnableDrawable.cs @@ -1,96 +1,142 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. using System; +using osu.Framework.Caching; using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; using osuTK; namespace osu.Game.Skinning { - public class SkinnableDrawable : SkinnableDrawable<Drawable> - { - public SkinnableDrawable(string name, Func<string, Drawable> defaultImplementation, Func<ISkinSource, bool> allowFallback = null, bool restrictSize = true) - : base(name, defaultImplementation, allowFallback, restrictSize) - { - } - } - /// <summary> /// A drawable which can be skinned via an <see cref="ISkinSource"/>. /// </summary> - /// <typeparam name="T">The type of drawable.</typeparam> - public class SkinnableDrawable<T> : SkinReloadableDrawable - where T : Drawable + public class SkinnableDrawable : SkinReloadableDrawable { /// <summary> - /// The displayed component. May or may not be a type-<typeparamref name="T"/> member. + /// The displayed component. /// </summary> - protected Drawable Drawable { get; private set; } + public Drawable Drawable { get; private set; } - private readonly string componentName; + /// <summary> + /// Whether the drawable component should be centered in available space. + /// Defaults to true. + /// </summary> + public bool CentreComponent { get; set; } = true; - private readonly bool restrictSize; + public new Axes AutoSizeAxes + { + get => base.AutoSizeAxes; + set => base.AutoSizeAxes = value; + } + + private readonly ISkinComponent component; + + private readonly ConfineMode confineMode; /// <summary> /// Create a new skinnable drawable. /// </summary> - /// <param name="name">The namespace-complete resource name for this skinnable element.</param> + /// <param name="component">The namespace-complete resource name for this skinnable element.</param> /// <param name="defaultImplementation">A function to create the default skin implementation of this element.</param> /// <param name="allowFallback">A conditional to decide whether to allow fallback to the default implementation if a skinned element is not present.</param> - /// <param name="restrictSize">Whether a user-skin drawable should be limited to the size of our parent.</param> - public SkinnableDrawable(string name, Func<string, T> defaultImplementation, Func<ISkinSource, bool> allowFallback = null, bool restrictSize = true) - : this(name, allowFallback, restrictSize) + /// <param name="confineMode">How (if at all) the <see cref="Drawable"/> should be resize to fit within our own bounds.</param> + public SkinnableDrawable(ISkinComponent component, Func<ISkinComponent, Drawable> defaultImplementation, Func<ISkinSource, bool> allowFallback = null, ConfineMode confineMode = ConfineMode.NoScaling) + : this(component, allowFallback, confineMode) { createDefault = defaultImplementation; } - protected SkinnableDrawable(string name, Func<ISkinSource, bool> allowFallback = null, bool restrictSize = true) + protected SkinnableDrawable(ISkinComponent component, Func<ISkinSource, bool> allowFallback = null, ConfineMode confineMode = ConfineMode.NoScaling) : base(allowFallback) { - componentName = name; - this.restrictSize = restrictSize; + this.component = component; + this.confineMode = confineMode; RelativeSizeAxes = Axes.Both; } - private readonly Func<string, T> createDefault; + /// <summary> + /// Seeks to the 0-th frame if the content of this <see cref="SkinnableDrawable"/> is an <see cref="IFramedAnimation"/>. + /// </summary> + public void ResetAnimation() => (Drawable as IFramedAnimation)?.GotoFrame(0); - protected virtual T CreateDefault(string name) => createDefault(name); + private readonly Func<ISkinComponent, Drawable> createDefault; + + private readonly Cached scaling = new Cached(); + + private bool isDefault; + + protected virtual Drawable CreateDefault(ISkinComponent component) => createDefault(component); /// <summary> - /// Whether to apply size restrictions (specified via <see cref="restrictSize"/>) to the default implementation. + /// Whether to apply size restrictions (specified via <see cref="confineMode"/>) to the default implementation. /// </summary> protected virtual bool ApplySizeRestrictionsToDefault => false; protected override void SkinChanged(ISkinSource skin, bool allowFallback) { - Drawable = skin.GetDrawableComponent(componentName); + Drawable = skin.GetDrawableComponent(component); - bool isDefault = false; + isDefault = false; if (Drawable == null && allowFallback) { - Drawable = CreateDefault(componentName); + Drawable = CreateDefault(component); isDefault = true; } if (Drawable != null) { - if (restrictSize && (!isDefault || ApplySizeRestrictionsToDefault)) - { - Drawable.RelativeSizeAxes = Axes.Both; - Drawable.Size = Vector2.One; - Drawable.Scale = Vector2.One; - Drawable.FillMode = FillMode.Fit; - } + scaling.Invalidate(); - Drawable.Origin = Anchor.Centre; - Drawable.Anchor = Anchor.Centre; + if (CentreComponent) + { + Drawable.Origin = Anchor.Centre; + Drawable.Anchor = Anchor.Centre; + } InternalChild = Drawable; } else ClearInternal(); } + + protected override void Update() + { + base.Update(); + + if (!scaling.IsValid) + { + try + { + if (Drawable == null || (isDefault && !ApplySizeRestrictionsToDefault)) return; + + switch (confineMode) + { + case ConfineMode.ScaleToFit: + Drawable.RelativeSizeAxes = Axes.Both; + Drawable.Size = Vector2.One; + Drawable.Scale = Vector2.One; + Drawable.FillMode = FillMode.Fit; + break; + } + } + finally + { + scaling.Validate(); + } + } + } + } + + public enum ConfineMode + { + /// <summary> + /// Don't apply any scaling. This allows the user element to be of any size, exceeding specified bounds. + /// </summary> + NoScaling, + ScaleToFit, } } diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index 8e2b5cec98..ffa0a963ce 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -1,76 +1,135 @@ // 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. -using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics.Audio; +using osu.Framework.Graphics.Containers; using osu.Game.Audio; namespace osu.Game.Skinning { - public class SkinnableSound : SkinReloadableDrawable + public class SkinnableSound : SkinReloadableDrawable, IAdjustableAudioComponent { private readonly ISampleInfo[] hitSamples; - private SampleChannel[] channels; - private AudioManager audio; + [Resolved] + private ISampleStore samples { get; set; } + + public override bool RemoveWhenNotAlive => false; + public override bool RemoveCompletedTransforms => false; + + /// <summary> + /// Whether to play the underlying sample when aggregate volume is zero. + /// Note that this is checked at the point of calling <see cref="Play"/>; changing the volume post-play will not begin playback. + /// Defaults to false unless <see cref="Looping"/>. + /// </summary> + /// <remarks> + /// Can serve as an optimisation if it is known ahead-of-time that this behaviour is allowed in a given use case. + /// </remarks> + protected bool PlayWhenZeroVolume => Looping; + + protected readonly AudioContainer<DrawableSample> SamplesContainer; + + public SkinnableSound(ISampleInfo hitSamples) + : this(new[] { hitSamples }) + { + } public SkinnableSound(IEnumerable<ISampleInfo> hitSamples) { this.hitSamples = hitSamples.ToArray(); + InternalChild = SamplesContainer = new AudioContainer<DrawableSample>(); } - public SkinnableSound(ISampleInfo hitSamples) + private bool looping; + + public bool Looping { - this.hitSamples = new[] { hitSamples }; + get => looping; + set + { + if (value == looping) return; + + looping = value; + + SamplesContainer.ForEach(c => c.Looping = looping); + } } - [BackgroundDependencyLoader] - private void load(AudioManager audio) + public virtual void Play() { - this.audio = audio; + SamplesContainer.ForEach(c => + { + if (PlayWhenZeroVolume || c.AggregateVolume.Value > 0) + c.Play(); + }); } - public void Play() => channels?.ForEach(c => c.Play()); - - public override bool IsPresent => false; // We don't need to receive updates. + public virtual void Stop() + { + SamplesContainer.ForEach(c => c.Stop()); + } protected override void SkinChanged(ISkinSource skin, bool allowFallback) { - channels = hitSamples.Select(s => + bool wasPlaying = IsPlaying; + + var channels = hitSamples.Select(s => { - var ch = loadChannel(s, skin.GetSample); + var ch = skin.GetSample(s); + if (ch == null && allowFallback) - ch = loadChannel(s, audio.Samples.Get); + { + foreach (var lookup in s.LookupNames) + { + if ((ch = samples.Get(lookup)) != null) + break; + } + } + + if (ch != null) + { + ch.Looping = looping; + ch.Volume.Value = s.Volume / 100.0; + } + return ch; - }).Where(c => c != null).ToArray(); + }).Where(c => c != null); + + SamplesContainer.ChildrenEnumerable = channels.Select(c => new DrawableSample(c)); + + // Start playback internally for the new samples if the previous ones were playing beforehand. + if (wasPlaying) + Play(); } - private SampleChannel loadChannel(ISampleInfo info, Func<string, SampleChannel> getSampleFunction) - { - foreach (var lookup in info.LookupNames) - { - var ch = getSampleFunction($"Gameplay/{lookup}"); - if (ch == null) - continue; + #region Re-expose AudioContainer - ch.Volume.Value = info.Volume / 100.0; - return ch; - } + public BindableNumber<double> Volume => SamplesContainer.Volume; - return null; - } + public BindableNumber<double> Balance => SamplesContainer.Balance; - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); + public BindableNumber<double> Frequency => SamplesContainer.Frequency; - foreach (var c in channels) - c.Dispose(); - } + public BindableNumber<double> Tempo => SamplesContainer.Tempo; + + public void AddAdjustment(AdjustableProperty type, BindableNumber<double> adjustBindable) + => SamplesContainer.AddAdjustment(type, adjustBindable); + + public void RemoveAdjustment(AdjustableProperty type, BindableNumber<double> adjustBindable) + => SamplesContainer.RemoveAdjustment(type, adjustBindable); + + public void RemoveAllAdjustments(AdjustableProperty type) + => SamplesContainer.RemoveAllAdjustments(type); + + public bool IsPlaying => SamplesContainer.Any(s => s.Playing); + + #endregion } } diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index ceb1ed0f70..5352928ec6 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; @@ -11,18 +12,28 @@ namespace osu.Game.Skinning /// <summary> /// A skinnable element which uses a stable sprite and can therefore share implementation logic. /// </summary> - public class SkinnableSprite : SkinnableDrawable<Sprite> + public class SkinnableSprite : SkinnableDrawable { protected override bool ApplySizeRestrictionsToDefault => true; [Resolved] private TextureStore textures { get; set; } - public SkinnableSprite(string name, Func<ISkinSource, bool> allowFallback = null, bool restrictSize = true) - : base(name, allowFallback, restrictSize) + public SkinnableSprite(string textureName, Func<ISkinSource, bool> allowFallback = null, ConfineMode confineMode = ConfineMode.NoScaling) + : base(new SpriteComponent(textureName), allowFallback, confineMode) { } - protected override Sprite CreateDefault(string name) => new Sprite { Texture = textures.Get(name) }; + protected override Drawable CreateDefault(ISkinComponent component) => new Sprite { Texture = textures.Get(component.LookupName) }; + + private class SpriteComponent : ISkinComponent + { + public SpriteComponent(string textureName) + { + LookupName = textureName; + } + + public string LookupName { get; } + } } } diff --git a/osu.Game/Skinning/SkinnableSpriteText.cs b/osu.Game/Skinning/SkinnableSpriteText.cs index 36e646d743..567dd348e1 100644 --- a/osu.Game/Skinning/SkinnableSpriteText.cs +++ b/osu.Game/Skinning/SkinnableSpriteText.cs @@ -6,10 +6,10 @@ using osu.Framework.Graphics.Sprites; namespace osu.Game.Skinning { - public class SkinnableSpriteText : SkinnableDrawable<SpriteText>, IHasText + public class SkinnableSpriteText : SkinnableDrawable, IHasText { - public SkinnableSpriteText(string name, Func<string, SpriteText> defaultImplementation, Func<ISkinSource, bool> allowFallback = null, bool restrictSize = true) - : base(name, defaultImplementation, allowFallback, restrictSize) + public SkinnableSpriteText(ISkinComponent component, Func<ISkinComponent, SpriteText> defaultImplementation, Func<ISkinSource, bool> allowFallback = null, ConfineMode confineMode = ConfineMode.NoScaling) + : base(component, defaultImplementation, allowFallback, confineMode) { } diff --git a/osu.Game/Storyboards/CommandTimeline.cs b/osu.Game/Storyboards/CommandTimeline.cs index aa1f137cf3..c71806352d 100644 --- a/osu.Game/Storyboards/CommandTimeline.cs +++ b/osu.Game/Storyboards/CommandTimeline.cs @@ -1,7 +1,6 @@ // 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. -using osu.Framework.Caching; using osu.Framework.Graphics; using System; using System.Collections.Generic; @@ -12,27 +11,35 @@ namespace osu.Game.Storyboards public class CommandTimeline<T> : ICommandTimeline { private readonly List<TypedCommand> commands = new List<TypedCommand>(); + public IEnumerable<TypedCommand> Commands => commands.OrderBy(c => c.StartTime); + public bool HasCommands => commands.Count > 0; - private Cached<double> startTimeBacking; - public double StartTime => startTimeBacking.IsValid ? startTimeBacking : startTimeBacking.Value = HasCommands ? commands.Min(c => c.StartTime) : double.MinValue; + public double StartTime { get; private set; } = double.MaxValue; + public double EndTime { get; private set; } = double.MinValue; - private Cached<double> endTimeBacking; - public double EndTime => endTimeBacking.IsValid ? endTimeBacking : endTimeBacking.Value = HasCommands ? commands.Max(c => c.EndTime) : double.MaxValue; - - public T StartValue => HasCommands ? commands.OrderBy(c => c.StartTime).First().StartValue : default; - public T EndValue => HasCommands ? commands.OrderByDescending(c => c.EndTime).First().EndValue : default; + public T StartValue { get; private set; } + public T EndValue { get; private set; } public void Add(Easing easing, double startTime, double endTime, T startValue, T endValue) { if (endTime < startTime) return; - commands.Add(new TypedCommand { Easing = easing, StartTime = startTime, EndTime = endTime, StartValue = startValue, EndValue = endValue, }); + commands.Add(new TypedCommand { Easing = easing, StartTime = startTime, EndTime = endTime, StartValue = startValue, EndValue = endValue }); - startTimeBacking.Invalidate(); - endTimeBacking.Invalidate(); + if (startTime < StartTime) + { + StartValue = startValue; + StartTime = startTime; + } + + if (endTime > EndTime) + { + EndValue = endValue; + EndTime = endTime; + } } public override string ToString() diff --git a/osu.Game/Storyboards/CommandTimelineGroup.cs b/osu.Game/Storyboards/CommandTimelineGroup.cs index b1cc0436de..6ce3b617e9 100644 --- a/osu.Game/Storyboards/CommandTimelineGroup.cs +++ b/osu.Game/Storyboards/CommandTimelineGroup.cs @@ -1,6 +1,7 @@ // 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. +using System; using osuTK; using osuTK.Graphics; using osu.Framework.Graphics; @@ -16,36 +17,61 @@ namespace osu.Game.Storyboards { public CommandTimeline<float> X = new CommandTimeline<float>(); public CommandTimeline<float> Y = new CommandTimeline<float>(); - public CommandTimeline<Vector2> Scale = new CommandTimeline<Vector2>(); + public CommandTimeline<float> Scale = new CommandTimeline<float>(); + public CommandTimeline<Vector2> VectorScale = new CommandTimeline<Vector2>(); public CommandTimeline<float> Rotation = new CommandTimeline<float>(); public CommandTimeline<Color4> Colour = new CommandTimeline<Color4>(); public CommandTimeline<float> Alpha = new CommandTimeline<float>(); - public CommandTimeline<BlendingMode> BlendingMode = new CommandTimeline<BlendingMode>(); + public CommandTimeline<BlendingParameters> BlendingParameters = new CommandTimeline<BlendingParameters>(); public CommandTimeline<bool> FlipH = new CommandTimeline<bool>(); public CommandTimeline<bool> FlipV = new CommandTimeline<bool>(); + private readonly ICommandTimeline[] timelines; + + public CommandTimelineGroup() + { + timelines = new ICommandTimeline[] + { + X, + Y, + Scale, + VectorScale, + Rotation, + Colour, + Alpha, + BlendingParameters, + FlipH, + FlipV + }; + } + [JsonIgnore] - public IEnumerable<ICommandTimeline> Timelines + public double CommandsStartTime { get { - yield return X; - yield return Y; - yield return Scale; - yield return Rotation; - yield return Colour; - yield return Alpha; - yield return BlendingMode; - yield return FlipH; - yield return FlipV; + double min = double.MaxValue; + + for (int i = 0; i < timelines.Length; i++) + min = Math.Min(min, timelines[i].StartTime); + + return min; } } [JsonIgnore] - public double CommandsStartTime => Timelines.Where(t => t.HasCommands).Min(t => t.StartTime); + public double CommandsEndTime + { + get + { + double max = double.MinValue; - [JsonIgnore] - public double CommandsEndTime => Timelines.Where(t => t.HasCommands).Max(t => t.EndTime); + for (int i = 0; i < timelines.Length; i++) + max = Math.Max(max, timelines[i].EndTime); + + return max; + } + } [JsonIgnore] public double CommandsDuration => CommandsEndTime - CommandsStartTime; @@ -60,11 +86,24 @@ namespace osu.Game.Storyboards public double Duration => EndTime - StartTime; [JsonIgnore] - public bool HasCommands => Timelines.Any(t => t.HasCommands); + public bool HasCommands + { + get + { + for (int i = 0; i < timelines.Length; i++) + { + if (timelines[i].HasCommands) + return true; + } + + return false; + } + } public virtual IEnumerable<CommandTimeline<T>.TypedCommand> GetCommands<T>(CommandTimelineSelector<T> timelineSelector, double offset = 0) { if (offset != 0) + { return timelineSelector(this).Commands.Select(command => new CommandTimeline<T>.TypedCommand { @@ -74,6 +113,7 @@ namespace osu.Game.Storyboards StartValue = command.StartValue, EndValue = command.EndValue, }); + } return timelineSelector(this).Commands; } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index 2b27a56844..4bc28e6cef 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -1,6 +1,7 @@ // 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. +using System.Linq; using System.Threading; using osuTK; using osu.Framework.Allocation; @@ -14,10 +15,10 @@ namespace osu.Game.Storyboards.Drawables { public class DrawableStoryboard : Container<DrawableStoryboardLayer> { - public Storyboard Storyboard { get; private set; } + [Cached] + public Storyboard Storyboard { get; } - private readonly Container<DrawableStoryboardLayer> content; - protected override Container<DrawableStoryboardLayer> Content => content; + protected override Container<DrawableStoryboardLayer> Content { get; } protected override Vector2 DrawScale => new Vector2(Parent.DrawHeight / 480); @@ -49,9 +50,9 @@ namespace osu.Game.Storyboards.Drawables Anchor = Anchor.Centre; Origin = Anchor.Centre; - AddInternal(content = new Container<DrawableStoryboardLayer> + AddInternal(Content = new Container<DrawableStoryboardLayer> { - Size = new Vector2(640, 480), + RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, }); @@ -73,10 +74,12 @@ namespace osu.Game.Storyboards.Drawables } } + public DrawableStoryboardLayer OverlayLayer => Children.Single(layer => layer.Name == "Overlay"); + private void updateLayerVisibility() { foreach (var layer in Children) - layer.Enabled = passing ? layer.Layer.EnabledWhenPassing : layer.Layer.EnabledWhenFailing; + layer.Enabled = passing ? layer.Layer.VisibleWhenPassing : layer.Layer.VisibleWhenFailing; } } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs index de3077c025..7eac994e07 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardAnimation.cs @@ -2,27 +2,75 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Textures; -using osu.Game.Beatmaps; +using osu.Framework.Utils; +using osuTK; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardAnimation : TextureAnimation, IFlippable + public class DrawableStoryboardAnimation : DrawableAnimation, IFlippable, IVectorScalable { - public StoryboardAnimation Animation { get; private set; } + public StoryboardAnimation Animation { get; } - public bool FlipH { get; set; } - public bool FlipV { get; set; } + private bool flipH; + + public bool FlipH + { + get => flipH; + set + { + if (flipH == value) + return; + + flipH = value; + Invalidate(Invalidation.MiscGeometry); + } + } + + private bool flipV; + + public bool FlipV + { + get => flipV; + set + { + if (flipV == value) + return; + + flipV = value; + Invalidate(Invalidation.MiscGeometry); + } + } + + private Vector2 vectorScale = Vector2.One; + + public Vector2 VectorScale + { + get => vectorScale; + set + { + if (Math.Abs(value.X) < Precision.FLOAT_EPSILON) + value.X = Precision.FLOAT_EPSILON; + if (Math.Abs(value.Y) < Precision.FLOAT_EPSILON) + value.Y = Precision.FLOAT_EPSILON; + + if (vectorScale == value) + return; + + if (!Validation.IsFinite(value)) throw new ArgumentException($@"{nameof(VectorScale)} must be finite, but is {value}."); + + vectorScale = value; + Invalidate(Invalidation.MiscGeometry); + } + } public override bool RemoveWhenNotAlive => false; protected override Vector2 DrawScale - => new Vector2(FlipH ? -base.DrawScale.X : base.DrawScale.X, FlipV ? -base.DrawScale.Y : base.DrawScale.Y); + => new Vector2(FlipH ? -base.DrawScale.X : base.DrawScale.X, FlipV ? -base.DrawScale.Y : base.DrawScale.Y) * VectorScale; public override Anchor Origin { @@ -58,25 +106,20 @@ namespace osu.Game.Storyboards.Drawables Animation = animation; Origin = animation.Origin; Position = animation.InitialPosition; - Repeat = animation.LoopType == AnimationLoopType.LoopForever; + Loop = animation.LoopType == AnimationLoopType.LoopForever; LifetimeStart = animation.StartTime; LifetimeEnd = animation.EndTime; } [BackgroundDependencyLoader] - private void load(IBindable<WorkingBeatmap> beatmap, TextureStore textureStore) + private void load(TextureStore textureStore, Storyboard storyboard) { - for (var frame = 0; frame < Animation.FrameCount; frame++) + for (int frameIndex = 0; frameIndex < Animation.FrameCount; frameIndex++) { - var framePath = Animation.Path.Replace(".", frame + "."); - - var path = beatmap.Value.BeatmapSetInfo.Files.Find(f => f.Filename.Equals(framePath, StringComparison.InvariantCultureIgnoreCase))?.FileInfo.StoragePath; - if (path == null) - continue; - - var texture = textureStore.Get(path); - AddFrame(texture, Animation.FrameDelay); + string framePath = Animation.Path.Replace(".", frameIndex + "."); + Drawable frame = storyboard.CreateSpriteFromResourcePath(framePath, textureStore) ?? Empty(); + AddFrame(frame, Animation.FrameDelay); } Animation.ApplyTransforms(this); diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs index fd2d441f34..2ada83c3b4 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardLayer.cs @@ -8,9 +8,9 @@ using osu.Framework.Graphics.Containers; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardLayer : LifetimeManagementContainer + public class DrawableStoryboardLayer : CompositeDrawable { - public StoryboardLayer Layer { get; private set; } + public StoryboardLayer Layer { get; } public bool Enabled; public override bool IsPresent => Enabled && base.IsPresent; @@ -21,18 +21,36 @@ namespace osu.Game.Storyboards.Drawables RelativeSizeAxes = Axes.Both; Anchor = Anchor.Centre; Origin = Anchor.Centre; - Enabled = layer.EnabledWhenPassing; + Enabled = layer.VisibleWhenPassing; + Masking = layer.Masking; + + InternalChild = new LayerElementContainer(layer); } - [BackgroundDependencyLoader] - private void load(CancellationToken? cancellationToken) + private class LayerElementContainer : LifetimeManagementContainer { - foreach (var element in Layer.Elements) - { - cancellationToken?.ThrowIfCancellationRequested(); + private readonly StoryboardLayer storyboardLayer; - if (element.IsDrawable) - AddInternal(element.CreateDrawable()); + public LayerElementContainer(StoryboardLayer layer) + { + storyboardLayer = layer; + + Width = 640; + Height = 480; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(CancellationToken? cancellationToken) + { + foreach (var element in storyboardLayer.Elements) + { + cancellationToken?.ThrowIfCancellationRequested(); + + if (element.IsDrawable) + AddInternal(element.CreateDrawable()); + } } } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs index ffd238d4e1..08811b9b8c 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs @@ -1,69 +1,72 @@ // 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. -using System.IO; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Audio.Sample; using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Skinning; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardSample : Component + public class DrawableStoryboardSample : PausableSkinnableSound { /// <summary> /// The amount of time allowable beyond the start time of the sample, for the sample to start. /// </summary> private const double allowable_late_start = 100; - private readonly StoryboardSample sample; - private SampleChannel channel; + private readonly StoryboardSampleInfo sampleInfo; public override bool RemoveWhenNotAlive => false; - public DrawableStoryboardSample(StoryboardSample sample) + public DrawableStoryboardSample(StoryboardSampleInfo sampleInfo) + : base(sampleInfo) { - this.sample = sample; - LifetimeStart = sample.StartTime; + this.sampleInfo = sampleInfo; + LifetimeStart = sampleInfo.StartTime; } - [BackgroundDependencyLoader] - private void load(IBindable<WorkingBeatmap> beatmap) - { - // Try first with the full name, then attempt with no path - channel = beatmap.Value.Skin.GetSample(sample.Path) ?? beatmap.Value.Skin.GetSample(Path.ChangeExtension(sample.Path, null)); + [Resolved] + private IBindable<IReadOnlyList<Mod>> mods { get; set; } - if (channel != null) - channel.Volume.Value = sample.Volume / 100; + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + + foreach (var mod in mods.Value.OfType<IApplicableToSample>()) + { + foreach (var sample in SamplesContainer) + mod.ApplyToSample(sample); + } } protected override void Update() { base.Update(); - // TODO: this logic will need to be consolidated with other game samples like hit sounds. - if (Time.Current < sample.StartTime) + if (Time.Current < sampleInfo.StartTime) { // We've rewound before the start time of the sample - channel?.Stop(); + 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) - LifetimeStart = sample.StartTime; + LifetimeStart = sampleInfo.StartTime; LifetimeEnd = double.MaxValue; } - else if (Time.Current - Time.Elapsed < sample.StartTime) + else 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 (Time.Current - sample.StartTime < allowable_late_start) - channel?.Play(); + 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 = sample.StartTime; + LifetimeEnd = sampleInfo.StartTime; } } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs index 5f1f5ddacb..7b1a6d54da 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSprite.cs @@ -2,27 +2,75 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; -using osu.Game.Beatmaps; +using osu.Framework.Utils; +using osuTK; namespace osu.Game.Storyboards.Drawables { - public class DrawableStoryboardSprite : Sprite, IFlippable + public class DrawableStoryboardSprite : CompositeDrawable, IFlippable, IVectorScalable { - public StoryboardSprite Sprite { get; private set; } + public StoryboardSprite Sprite { get; } - public bool FlipH { get; set; } - public bool FlipV { get; set; } + private bool flipH; + + public bool FlipH + { + get => flipH; + set + { + if (flipH == value) + return; + + flipH = value; + Invalidate(Invalidation.MiscGeometry); + } + } + + private bool flipV; + + public bool FlipV + { + get => flipV; + set + { + if (flipV == value) + return; + + flipV = value; + Invalidate(Invalidation.MiscGeometry); + } + } + + private Vector2 vectorScale = Vector2.One; + + public Vector2 VectorScale + { + get => vectorScale; + set + { + if (Math.Abs(value.X) < Precision.FLOAT_EPSILON) + value.X = Precision.FLOAT_EPSILON; + if (Math.Abs(value.Y) < Precision.FLOAT_EPSILON) + value.Y = Precision.FLOAT_EPSILON; + + if (vectorScale == value) + return; + + if (!Validation.IsFinite(value)) throw new ArgumentException($@"{nameof(VectorScale)} must be finite, but is {value}."); + + vectorScale = value; + Invalidate(Invalidation.MiscGeometry); + } + } public override bool RemoveWhenNotAlive => false; protected override Vector2 DrawScale - => new Vector2(FlipH ? -base.DrawScale.X : base.DrawScale.X, FlipV ? -base.DrawScale.Y : base.DrawScale.Y); + => new Vector2(FlipH ? -base.DrawScale.X : base.DrawScale.X, FlipV ? -base.DrawScale.Y : base.DrawScale.Y) * VectorScale; public override Anchor Origin { @@ -61,16 +109,18 @@ namespace osu.Game.Storyboards.Drawables LifetimeStart = sprite.StartTime; LifetimeEnd = sprite.EndTime; + + AutoSizeAxes = Axes.Both; } [BackgroundDependencyLoader] - private void load(IBindable<WorkingBeatmap> beatmap, TextureStore textureStore) + private void load(TextureStore textureStore, Storyboard storyboard) { - var path = beatmap.Value.BeatmapSetInfo.Files.Find(f => f.Filename.Equals(Sprite.Path, StringComparison.InvariantCultureIgnoreCase))?.FileInfo.StoragePath; - if (path == null) - return; + var drawable = storyboard.CreateSpriteFromResourcePath(Sprite.Path, textureStore); + + if (drawable != null) + InternalChild = drawable; - Texture = textureStore.Get(path); Sprite.ApplyTransforms(this); } } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs new file mode 100644 index 0000000000..4ea582ca4a --- /dev/null +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardVideo.cs @@ -0,0 +1,65 @@ +// 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. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics.Video; +using osu.Game.Beatmaps; + +namespace osu.Game.Storyboards.Drawables +{ + public class DrawableStoryboardVideo : CompositeDrawable + { + public readonly StoryboardVideo Video; + private Video video; + + public override bool RemoveWhenNotAlive => false; + + public DrawableStoryboardVideo(StoryboardVideo video) + { + Video = video; + + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader(true)] + private void load(IBindable<WorkingBeatmap> beatmap, TextureStore textureStore) + { + var path = beatmap.Value.BeatmapSetInfo?.Files?.Find(f => f.Filename.Equals(Video.Path, StringComparison.OrdinalIgnoreCase))?.FileInfo.StoragePath; + + if (path == null) + return; + + var stream = textureStore.GetStream(path); + + if (stream == null) + return; + + InternalChild = video = new Video(stream, false) + { + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fill, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (video == null) return; + + using (video.BeginAbsoluteSequence(Video.StartTime)) + { + Schedule(() => video.PlaybackPosition = Time.Current - Video.StartTime); + video.FadeIn(500); + } + } + } +} diff --git a/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs b/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs index 7e31e1135e..bbc55a336d 100644 --- a/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs +++ b/osu.Game/Storyboards/Drawables/DrawablesExtensions.cs @@ -12,19 +12,19 @@ namespace osu.Game.Storyboards.Drawables /// Adjusts <see cref="Drawable.Blending"/> after a delay. /// </summary> /// <returns>A <see cref="TransformSequence{T}"/> to which further transforms can be added.</returns> - public static TransformSequence<T> TransformBlendingMode<T>(this T drawable, BlendingMode newValue, double delay = 0) + public static TransformSequence<T> TransformBlendingMode<T>(this T drawable, BlendingParameters newValue, double delay = 0) where T : Drawable - => drawable.TransformTo(drawable.PopulateTransform(new TransformBlendingMode(), newValue, delay)); + => drawable.TransformTo(drawable.PopulateTransform(new TransformBlendingParameters(), newValue, delay)); } - public class TransformBlendingMode : Transform<BlendingMode, Drawable> + public class TransformBlendingParameters : Transform<BlendingParameters, Drawable> { - private BlendingMode valueAt(double time) + private BlendingParameters valueAt(double time) => time < EndTime ? StartValue : EndValue; public override string TargetMember => nameof(Drawable.Blending); protected override void Apply(Drawable d, double time) => d.Blending = valueAt(time); - protected override void ReadIntoStartValue(Drawable d) => StartValue = d.Blending.Mode; + protected override void ReadIntoStartValue(Drawable d) => StartValue = d.Blending; } } diff --git a/osu.Game/Storyboards/Drawables/IFlippable.cs b/osu.Game/Storyboards/Drawables/IFlippable.cs index 9e12de5833..165b3d97cc 100644 --- a/osu.Game/Storyboards/Drawables/IFlippable.cs +++ b/osu.Game/Storyboards/Drawables/IFlippable.cs @@ -6,13 +6,13 @@ using osu.Framework.Graphics.Transforms; namespace osu.Game.Storyboards.Drawables { - public interface IFlippable : ITransformable + internal interface IFlippable : ITransformable { bool FlipH { get; set; } bool FlipV { get; set; } } - public class TransformFlipH : Transform<bool, IFlippable> + internal class TransformFlipH : Transform<bool, IFlippable> { private bool valueAt(double time) => time < EndTime ? StartValue : EndValue; @@ -23,7 +23,7 @@ namespace osu.Game.Storyboards.Drawables protected override void ReadIntoStartValue(IFlippable d) => StartValue = d.FlipH; } - public class TransformFlipV : Transform<bool, IFlippable> + internal class TransformFlipV : Transform<bool, IFlippable> { private bool valueAt(double time) => time < EndTime ? StartValue : EndValue; @@ -34,14 +34,14 @@ namespace osu.Game.Storyboards.Drawables protected override void ReadIntoStartValue(IFlippable d) => StartValue = d.FlipV; } - public static class FlippableExtensions + internal static class FlippableExtensions { /// <summary> /// Adjusts <see cref="IFlippable.FlipH"/> after a delay. /// </summary> /// <returns>A <see cref="TransformSequence{T}"/> to which further transforms can be added.</returns> public static TransformSequence<T> TransformFlipH<T>(this T flippable, bool newValue, double delay = 0) - where T : IFlippable + where T : class, IFlippable => flippable.TransformTo(flippable.PopulateTransform(new TransformFlipH(), newValue, delay)); /// <summary> @@ -49,7 +49,7 @@ namespace osu.Game.Storyboards.Drawables /// </summary> /// <returns>A <see cref="TransformSequence{T}"/> to which further transforms can be added.</returns> public static TransformSequence<T> TransformFlipV<T>(this T flippable, bool newValue, double delay = 0) - where T : IFlippable + where T : class, IFlippable => flippable.TransformTo(flippable.PopulateTransform(new TransformFlipV(), newValue, delay)); } } diff --git a/osu.Game/Storyboards/Drawables/IVectorScalable.cs b/osu.Game/Storyboards/Drawables/IVectorScalable.cs new file mode 100644 index 0000000000..fcc407d460 --- /dev/null +++ b/osu.Game/Storyboards/Drawables/IVectorScalable.cs @@ -0,0 +1,21 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osuTK; + +namespace osu.Game.Storyboards.Drawables +{ + internal interface IVectorScalable : ITransformable + { + Vector2 VectorScale { get; set; } + } + + internal static class VectorScalableExtensions + { + public static TransformSequence<T> VectorScaleTo<T>(this T target, Vector2 newVectorScale, double duration = 0, Easing easing = Easing.None) + where T : class, IVectorScalable + => target.TransformTo(nameof(IVectorScalable.VectorScale), newVectorScale, duration, easing); + } +} diff --git a/osu.Game/Storyboards/Storyboard.cs b/osu.Game/Storyboards/Storyboard.cs index 3d988c5fe3..e0d18eab00 100644 --- a/osu.Game/Storyboards/Storyboard.cs +++ b/osu.Game/Storyboards/Storyboard.cs @@ -1,10 +1,15 @@ // 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. -using osu.Game.Beatmaps; -using osu.Game.Storyboards.Drawables; +using System; using System.Collections.Generic; using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps; +using osu.Game.Skinning; +using osu.Game.Storyboards.Drawables; namespace osu.Game.Storyboards { @@ -15,21 +20,35 @@ namespace osu.Game.Storyboards public BeatmapInfo BeatmapInfo = new BeatmapInfo(); + /// <summary> + /// Whether the storyboard can fall back to skin sprites in case no matching storyboard sprites are found. + /// </summary> + public bool UseSkinSprites { get; set; } + public bool HasDrawable => Layers.Any(l => l.Elements.Any(e => e.IsDrawable)); + public double FirstEventTime => Layers.Min(l => l.Elements.FirstOrDefault()?.StartTime ?? 0); + + /// <summary> + /// Depth of the currently front-most storyboard layer, excluding the overlay layer. + /// </summary> + private int minimumLayerDepth; + public Storyboard() { + layers.Add("Video", new StoryboardLayer("Video", 4, false)); layers.Add("Background", new StoryboardLayer("Background", 3)); - layers.Add("Fail", new StoryboardLayer("Fail", 2) { EnabledWhenPassing = false, }); - layers.Add("Pass", new StoryboardLayer("Pass", 1) { EnabledWhenFailing = false, }); - layers.Add("Foreground", new StoryboardLayer("Foreground", 0)); + layers.Add("Fail", new StoryboardLayer("Fail", 2) { VisibleWhenPassing = false, }); + layers.Add("Pass", new StoryboardLayer("Pass", 1) { VisibleWhenFailing = false, }); + layers.Add("Foreground", new StoryboardLayer("Foreground", minimumLayerDepth = 0)); + + layers.Add("Overlay", new StoryboardLayer("Overlay", int.MinValue)); } public StoryboardLayer GetLayer(string name) { - StoryboardLayer layer; - if (!layers.TryGetValue(name, out layer)) - layers[name] = layer = new StoryboardLayer(name, layers.Values.Min(l => l.Depth) - 1); + if (!layers.TryGetValue(name, out var layer)) + layers[name] = layer = new StoryboardLayer(name, --minimumLayerDepth); return layer; } @@ -55,5 +74,19 @@ namespace osu.Game.Storyboards drawable.Width = drawable.Height * (BeatmapInfo.WidescreenStoryboard ? 16 / 9f : 4 / 3f); return drawable; } + + 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; + + if (storyboardPath != null) + drawable = new Sprite { Texture = textureStore.Get(storyboardPath) }; + // if the texture isn't available locally in the beatmap, some storyboards choose to source from the underlying skin lookup hierarchy. + else if (UseSkinSprites) + drawable = new SkinnableSprite(path); + + return drawable; + } } } diff --git a/osu.Game/Storyboards/StoryboardLayer.cs b/osu.Game/Storyboards/StoryboardLayer.cs index d15f771534..1cde7cf67a 100644 --- a/osu.Game/Storyboards/StoryboardLayer.cs +++ b/osu.Game/Storyboards/StoryboardLayer.cs @@ -8,17 +8,23 @@ namespace osu.Game.Storyboards { public class StoryboardLayer { - public string Name; - public int Depth; - public bool EnabledWhenPassing = true; - public bool EnabledWhenFailing = true; + public readonly string Name; + + public readonly int Depth; + + public readonly bool Masking; + + public bool VisibleWhenPassing = true; + + public bool VisibleWhenFailing = true; public List<IStoryboardElement> Elements = new List<IStoryboardElement>(); - public StoryboardLayer(string name, int depth) + public StoryboardLayer(string name, int depth, bool masking = true) { Name = name; Depth = depth; + Masking = masking; } public void Add(IStoryboardElement element) @@ -27,6 +33,6 @@ namespace osu.Game.Storyboards } public DrawableStoryboardLayer CreateDrawable() - => new DrawableStoryboardLayer(this) { Depth = Depth, }; + => new DrawableStoryboardLayer(this) { Depth = Depth, Name = Name }; } } diff --git a/osu.Game/Storyboards/StoryboardSample.cs b/osu.Game/Storyboards/StoryboardSample.cs index 24231cdca6..5d6ce215f5 100644 --- a/osu.Game/Storyboards/StoryboardSample.cs +++ b/osu.Game/Storyboards/StoryboardSample.cs @@ -1,21 +1,30 @@ // 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. +using System.Collections.Generic; using osu.Framework.Graphics; +using osu.Game.Audio; using osu.Game.Storyboards.Drawables; namespace osu.Game.Storyboards { - public class StoryboardSample : IStoryboardElement + public class StoryboardSampleInfo : IStoryboardElement, ISampleInfo { - public string Path { get; set; } + public string Path { get; } public bool IsDrawable => true; public double StartTime { get; } - public float Volume; + public int Volume { get; } - public StoryboardSample(string path, double time, float volume) + public IEnumerable<string> LookupNames => new[] + { + // Try first with the full name, then attempt with no path + Path, + System.IO.Path.ChangeExtension(Path, null), + }; + + public StoryboardSampleInfo(string path, double time, int volume) { Path = path; StartTime = time; diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index 8f8ec22aae..f411ad04f3 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -7,6 +7,7 @@ using osu.Game.Storyboards.Drawables; using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; namespace osu.Game.Storyboards { @@ -15,7 +16,7 @@ namespace osu.Game.Storyboards private readonly List<CommandLoop> loops = new List<CommandLoop>(); private readonly List<CommandTrigger> triggers = new List<CommandTrigger>(); - public string Path { get; set; } + public string Path { get; } public bool IsDrawable => HasCommands; public Anchor Origin; @@ -63,40 +64,56 @@ namespace osu.Game.Storyboards public void ApplyTransforms(Drawable drawable, IEnumerable<Tuple<CommandTimelineGroup, double>> triggeredGroups = null) { - applyCommands(drawable, getCommands(g => g.X, triggeredGroups), (d, value) => d.X = value, (d, value, duration, easing) => d.MoveToX(value, duration, easing)); - applyCommands(drawable, getCommands(g => g.Y, triggeredGroups), (d, value) => d.Y = value, (d, value, duration, easing) => d.MoveToY(value, duration, easing)); - applyCommands(drawable, getCommands(g => g.Scale, triggeredGroups), (d, value) => d.Scale = value, (d, value, duration, easing) => d.ScaleTo(value, duration, easing)); - applyCommands(drawable, getCommands(g => g.Rotation, triggeredGroups), (d, value) => d.Rotation = value, (d, value, duration, easing) => d.RotateTo(value, duration, easing)); - applyCommands(drawable, getCommands(g => g.Colour, triggeredGroups), (d, value) => d.Colour = value, (d, value, duration, easing) => d.FadeColour(value, duration, easing)); - applyCommands(drawable, getCommands(g => g.Alpha, triggeredGroups), (d, value) => d.Alpha = value, (d, value, duration, easing) => d.FadeTo(value, duration, easing)); - applyCommands(drawable, getCommands(g => g.BlendingMode, triggeredGroups), (d, value) => d.Blending = value, (d, value, duration, easing) => d.TransformBlendingMode(value, duration), false); + // For performance reasons, we need to apply the commands in order by start time. Not doing so will cause many functions to be interleaved, resulting in O(n^2) complexity. + // To achieve this, commands are "generated" as pairs of (command, initFunc, transformFunc) and batched into a contiguous list + // The list is then stably-sorted (to preserve command order), and applied to the drawable sequentially. + + List<IGeneratedCommand> generated = new List<IGeneratedCommand>(); + + generateCommands(generated, getCommands(g => g.X, triggeredGroups), (d, value) => d.X = value, (d, value, duration, easing) => d.MoveToX(value, duration, easing)); + generateCommands(generated, getCommands(g => g.Y, triggeredGroups), (d, value) => d.Y = value, (d, value, duration, easing) => d.MoveToY(value, duration, easing)); + generateCommands(generated, getCommands(g => g.Scale, triggeredGroups), (d, value) => d.Scale = new Vector2(value), (d, value, duration, easing) => d.ScaleTo(value, duration, easing)); + generateCommands(generated, getCommands(g => g.Rotation, triggeredGroups), (d, value) => d.Rotation = value, (d, value, duration, easing) => d.RotateTo(value, duration, easing)); + generateCommands(generated, getCommands(g => g.Colour, triggeredGroups), (d, value) => d.Colour = value, (d, value, duration, easing) => d.FadeColour(value, duration, easing)); + generateCommands(generated, getCommands(g => g.Alpha, triggeredGroups), (d, value) => d.Alpha = value, (d, value, duration, easing) => d.FadeTo(value, duration, easing)); + generateCommands(generated, getCommands(g => g.BlendingParameters, triggeredGroups), (d, value) => d.Blending = value, (d, value, duration, easing) => d.TransformBlendingMode(value, duration), + false); + + if (drawable is IVectorScalable vectorScalable) + { + generateCommands(generated, getCommands(g => g.VectorScale, triggeredGroups), (d, value) => vectorScalable.VectorScale = value, + (d, value, duration, easing) => vectorScalable.VectorScaleTo(value, duration, easing)); + } if (drawable is IFlippable flippable) { - applyCommands(drawable, getCommands(g => g.FlipH, triggeredGroups), (d, value) => flippable.FlipH = value, (d, value, duration, easing) => flippable.TransformFlipH(value, duration), false); - applyCommands(drawable, getCommands(g => g.FlipV, triggeredGroups), (d, value) => flippable.FlipV = value, (d, value, duration, easing) => flippable.TransformFlipV(value, duration), false); + generateCommands(generated, getCommands(g => g.FlipH, triggeredGroups), (d, value) => flippable.FlipH = value, (d, value, duration, easing) => flippable.TransformFlipH(value, duration), + false); + generateCommands(generated, getCommands(g => g.FlipV, triggeredGroups), (d, value) => flippable.FlipV = value, (d, value, duration, easing) => flippable.TransformFlipV(value, duration), + false); } + + foreach (var command in generated.OrderBy(g => g.StartTime)) + command.ApplyTo(drawable); } - private void applyCommands<T>(Drawable drawable, IEnumerable<CommandTimeline<T>.TypedCommand> commands, DrawablePropertyInitializer<T> initializeProperty, DrawableTransformer<T> transform, bool alwaysInitialize = true) - where T : struct + private void generateCommands<T>(List<IGeneratedCommand> resultList, IEnumerable<CommandTimeline<T>.TypedCommand> commands, + DrawablePropertyInitializer<T> initializeProperty, DrawableTransformer<T> transform, bool alwaysInitialize = true) { - var initialized = false; + bool initialized = false; - foreach (var command in commands.OrderBy(l => l)) + foreach (var command in commands) { + DrawablePropertyInitializer<T> initFunc = null; + if (!initialized) { if (alwaysInitialize || command.StartTime == command.EndTime) - initializeProperty.Invoke(drawable, command.StartValue); + initFunc = initializeProperty; initialized = true; } - using (drawable.BeginAbsoluteSequence(command.StartTime)) - { - transform(drawable, command.StartValue, 0, Easing.None); - transform(drawable, command.EndValue, command.Duration, command.Easing); - } + resultList.Add(new GeneratedCommand<T>(command, initFunc, transform)); } } @@ -105,13 +122,51 @@ namespace osu.Game.Storyboards var commands = TimelineGroup.GetCommands(timelineSelector); foreach (var loop in loops) commands = commands.Concat(loop.GetCommands(timelineSelector)); + if (triggeredGroups != null) + { foreach (var pair in triggeredGroups) commands = commands.Concat(pair.Item1.GetCommands(timelineSelector, pair.Item2)); + } + return commands; } public override string ToString() => $"{Path}, {Origin}, {InitialPosition}"; + + private interface IGeneratedCommand + { + double StartTime { get; } + + void ApplyTo(Drawable drawable); + } + + private readonly struct GeneratedCommand<T> : IGeneratedCommand + { + public double StartTime => command.StartTime; + + private readonly DrawablePropertyInitializer<T> initializeProperty; + private readonly DrawableTransformer<T> transform; + private readonly CommandTimeline<T>.TypedCommand command; + + public GeneratedCommand([NotNull] CommandTimeline<T>.TypedCommand command, [CanBeNull] DrawablePropertyInitializer<T> initializeProperty, [NotNull] DrawableTransformer<T> transform) + { + this.command = command; + this.initializeProperty = initializeProperty; + this.transform = transform; + } + + public void ApplyTo(Drawable drawable) + { + initializeProperty?.Invoke(drawable, command.StartValue); + + using (drawable.BeginAbsoluteSequence(command.StartTime)) + { + transform(drawable, command.StartValue, 0, Easing.None); + transform(drawable, command.EndValue, command.Duration, command.Easing); + } + } + } } } diff --git a/osu.Game/Storyboards/StoryboardVideo.cs b/osu.Game/Storyboards/StoryboardVideo.cs new file mode 100644 index 0000000000..4652e45852 --- /dev/null +++ b/osu.Game/Storyboards/StoryboardVideo.cs @@ -0,0 +1,25 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Game.Storyboards.Drawables; + +namespace osu.Game.Storyboards +{ + public class StoryboardVideo : IStoryboardElement + { + public string Path { get; } + + public bool IsDrawable => true; + + public double StartTime { get; } + + public StoryboardVideo(string path, int offset) + { + Path = path; + StartTime = offset; + } + + public Drawable CreateDrawable() => new DrawableStoryboardVideo(this); + } +} diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index 6a5e17eb38..fcf20a2eb2 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -4,13 +4,17 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Reflection; using Newtonsoft.Json; using NUnit.Framework; -using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; +using osu.Game.IO; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; namespace osu.Game.Tests.Beatmaps @@ -25,13 +29,17 @@ namespace osu.Game.Tests.Beatmaps protected abstract string ResourceAssembly { get; } - protected IBeatmapConverter Converter { get; private set; } - - protected void Test(string name) + protected void Test(string name, params Type[] mods) { - var ourResult = convert(name); + var ourResult = convert(name, mods.Select(m => (Mod)Activator.CreateInstance(m)).ToArray()); var expectedResult = read(name); + foreach (var m in ourResult.Mappings) + m.PostProcess(); + + foreach (var m in expectedResult.Mappings) + m.PostProcess(); + Assert.Multiple(() => { int mappingCounter = 0; @@ -69,11 +77,15 @@ namespace osu.Game.Tests.Beatmaps break; if (objectCounter >= ourMapping.Objects.Count) + { Assert.Fail($"The conversion did not generate a hitobject, but should have, for hitobject at time: {expectedMapping.StartTime}:\n" + $"Expected: {JsonConvert.SerializeObject(expectedMapping.Objects[objectCounter])}\n"); + } else if (objectCounter >= expectedMapping.Objects.Count) + { Assert.Fail($"The conversion generated a hitobject, but should not have, for hitobject at time: {ourMapping.StartTime}:\n" + $"Received: {JsonConvert.SerializeObject(ourMapping.Objects[objectCounter])}\n"); + } else if (!expectedMapping.Objects[objectCounter].Equals(ourMapping.Objects[objectCounter])) { Assert.Fail($"The conversion generated differing hitobjects for object at time: {expectedMapping.StartTime}:\n" @@ -91,33 +103,37 @@ namespace osu.Game.Tests.Beatmaps }); } - private ConvertResult convert(string name) + private ConvertResult convert(string name, Mod[] mods) { - var beatmap = getBeatmap(name); + var beatmap = GetBeatmap(name); - var rulesetInstance = CreateRuleset(); - beatmap.BeatmapInfo.Ruleset = beatmap.BeatmapInfo.RulesetID == rulesetInstance.RulesetInfo.ID ? rulesetInstance.RulesetInfo : new RulesetInfo(); + var converterResult = new Dictionary<HitObject, IEnumerable<HitObject>>(); - Converter = rulesetInstance.CreateBeatmapConverter(beatmap); - - var result = new ConvertResult(); - - Converter.ObjectConverted += (orig, converted) => + var working = new ConversionWorkingBeatmap(beatmap) { - converted.ForEach(h => h.ApplyDefaults(beatmap.ControlPointInfo, beatmap.BeatmapInfo.BaseDifficulty)); - - var mapping = CreateConvertMapping(); - mapping.StartTime = orig.StartTime; - - foreach (var obj in converted) - mapping.Objects.AddRange(CreateConvertValue(obj)); - result.Mappings.Add(mapping); + ConversionGenerated = (o, r, c) => + { + converterResult[o] = r; + OnConversionGenerated(o, r, c); + } }; - IBeatmap convertedBeatmap = Converter.Convert(); - rulesetInstance.CreateBeatmapProcessor(convertedBeatmap)?.PostProcess(); + working.GetPlayableBeatmap(CreateRuleset().RulesetInfo, mods); - return result; + return new ConvertResult + { + Mappings = converterResult.Select(r => + { + var mapping = CreateConvertMapping(r.Key); + mapping.StartTime = r.Key.StartTime; + mapping.Objects.AddRange(r.Value.SelectMany(CreateConvertValue)); + return mapping; + }).ToList() + }; + } + + protected virtual void OnConversionGenerated(HitObject original, IEnumerable<HitObject> result, IBeatmapConverter beatmapConverter) + { } private ConvertResult read(string name) @@ -130,14 +146,19 @@ namespace osu.Game.Tests.Beatmaps } } - private IBeatmap getBeatmap(string name) + public IBeatmap GetBeatmap(string name) { using (var resStream = openResource($"{resource_namespace}.{name}.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var decoder = Decoder.GetDecoder<Beatmap>(stream); ((LegacyBeatmapDecoder)decoder).ApplyOffsets = false; - return decoder.Decode(stream); + var beatmap = decoder.Decode(stream); + + var rulesetInstance = CreateRuleset(); + beatmap.BeatmapInfo.Ruleset = beatmap.BeatmapInfo.RulesetID == rulesetInstance.RulesetInfo.ID ? rulesetInstance.RulesetInfo : new RulesetInfo(); + + return beatmap; } } @@ -154,7 +175,7 @@ namespace osu.Game.Tests.Beatmaps /// This should be used to validate the integrity of the conversion process after a conversion has occurred. /// </para> /// </summary> - protected virtual TConvertMapping CreateConvertMapping() => new TConvertMapping(); + protected virtual TConvertMapping CreateConvertMapping(HitObject source) => new TConvertMapping(); /// <summary> /// Creates the conversion value for a <see cref="HitObject"/>. A conversion value stores information about the converted <see cref="HitObject"/>. @@ -176,6 +197,32 @@ namespace osu.Game.Tests.Beatmaps [JsonProperty] public List<TConvertMapping> Mappings = new List<TConvertMapping>(); } + + private class ConversionWorkingBeatmap : WorkingBeatmap + { + public Action<HitObject, IEnumerable<HitObject>, IBeatmapConverter> ConversionGenerated; + + private readonly IBeatmap beatmap; + + public ConversionWorkingBeatmap(IBeatmap beatmap) + : base(beatmap.BeatmapInfo, null) + { + this.beatmap = beatmap; + } + + protected override IBeatmap GetBeatmap() => beatmap; + + protected override Texture GetBackground() => throw new NotImplementedException(); + + protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + + protected override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) + { + var converter = base.CreateBeatmapConverter(beatmap, ruleset); + converter.ObjectConverted += (orig, converted) => ConversionGenerated?.Invoke(orig, converted, converter); + return converter; + } + } } public abstract class BeatmapConversionTest<TConvertValue> : BeatmapConversionTest<ConvertMapping<TConvertValue>, TConvertValue> @@ -198,6 +245,13 @@ namespace osu.Game.Tests.Beatmaps set => Objects = value; } - public virtual bool Equals(ConvertMapping<TConvertValue> other) => StartTime.Equals(other?.StartTime); + /// <summary> + /// Invoked after this <see cref="ConvertMapping{TConvertValue}"/> is populated to post-process the contained data. + /// </summary> + public virtual void PostProcess() + { + } + + public virtual bool Equals(ConvertMapping<TConvertValue> other) => StartTime == other?.StartTime; } } diff --git a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs index 108fa8ff71..748a52d1c5 100644 --- a/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs +++ b/osu.Game/Tests/Beatmaps/DifficultyCalculatorTest.cs @@ -7,6 +7,7 @@ using System.Reflection; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; +using osu.Game.IO; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; @@ -26,7 +27,7 @@ namespace osu.Game.Tests.Beatmaps private WorkingBeatmap getBeatmap(string name) { using (var resStream = openResource($"{resource_namespace}.{name}.osu")) - using (var stream = new StreamReader(resStream)) + using (var stream = new LineBufferedReader(resStream)) { var decoder = Decoder.GetDecoder<Beatmap>(stream); ((LegacyBeatmapDecoder)decoder).ApplyOffsets = false; diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs new file mode 100644 index 0000000000..1e43e5d148 --- /dev/null +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -0,0 +1,193 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.IO.Stores; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.Rulesets; +using osu.Game.Skinning; +using osu.Game.Storyboards; +using osu.Game.Tests.Visual; +using osu.Game.Users; + +namespace osu.Game.Tests.Beatmaps +{ + public abstract class HitObjectSampleTest : PlayerTestScene + { + protected abstract IResourceStore<byte[]> Resources { get; } + protected LegacySkin Skin { get; private set; } + + [Resolved] + private RulesetStore rulesetStore { get; set; } + + private readonly SkinInfo userSkinInfo = new SkinInfo(); + + private readonly BeatmapInfo beatmapInfo = new BeatmapInfo + { + BeatmapSet = new BeatmapSetInfo(), + Metadata = new BeatmapMetadata + { + Author = User.SYSTEM_USER + } + }; + + private readonly TestResourceStore userSkinResourceStore = new TestResourceStore(); + private readonly TestResourceStore beatmapSkinResourceStore = new TestResourceStore(); + private SkinSourceDependencyContainer dependencies; + private IBeatmap currentTestBeatmap; + protected sealed override bool HasCustomSteps => true; + + protected sealed override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + => new DependencyContainer(dependencies = new SkinSourceDependencyContainer(base.CreateChildDependencies(parent))); + + protected sealed override IBeatmap CreateBeatmap(RulesetInfo ruleset) => currentTestBeatmap; + + protected sealed override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + => new TestWorkingBeatmap(beatmapInfo, beatmapSkinResourceStore, beatmap, storyboard, Clock, Audio); + + protected void CreateTestWithBeatmap(string filename) + { + CreateTest(() => + { + AddStep("clear performed lookups", () => + { + userSkinResourceStore.PerformedLookups.Clear(); + beatmapSkinResourceStore.PerformedLookups.Clear(); + }); + + AddStep($"load {filename}", () => + { + using (var reader = new LineBufferedReader(Resources.GetStream($"Resources/SampleLookups/{filename}"))) + currentTestBeatmap = Decoder.GetDecoder<Beatmap>(reader).Decode(reader); + + // populate ruleset for beatmap converters that require it to be present. + currentTestBeatmap.BeatmapInfo.Ruleset = rulesetStore.GetRuleset(currentTestBeatmap.BeatmapInfo.RulesetID); + }); + }); + } + + protected void SetupSkins(string beatmapFile, string userFile) + { + AddStep("setup skins", () => + { + userSkinInfo.Files = new List<SkinFileInfo> + { + new SkinFileInfo + { + Filename = userFile, + FileInfo = new IO.FileInfo { Hash = userFile } + } + }; + + beatmapInfo.BeatmapSet.Files = new List<BeatmapSetFileInfo> + { + new BeatmapSetFileInfo + { + Filename = beatmapFile, + FileInfo = new IO.FileInfo { Hash = beatmapFile } + } + }; + + // Need to refresh the cached skin source to refresh the skin resource store. + dependencies.SkinSource = new SkinProvidingContainer(Skin = new LegacySkin(userSkinInfo, userSkinResourceStore, Audio)); + }); + } + + protected void AssertBeatmapLookup(string name) => AddAssert($"\"{name}\" looked up from beatmap skin", + () => !userSkinResourceStore.PerformedLookups.Contains(name) && beatmapSkinResourceStore.PerformedLookups.Contains(name)); + + protected void AssertUserLookup(string name) => AddAssert($"\"{name}\" looked up from user skin", + () => !beatmapSkinResourceStore.PerformedLookups.Contains(name) && userSkinResourceStore.PerformedLookups.Contains(name)); + + protected void AssertNoLookup(string name) => AddAssert($"\"{name}\" not looked up", + () => !beatmapSkinResourceStore.PerformedLookups.Contains(name) && !userSkinResourceStore.PerformedLookups.Contains(name)); + + private class SkinSourceDependencyContainer : IReadOnlyDependencyContainer + { + public ISkinSource SkinSource; + + private readonly IReadOnlyDependencyContainer fallback; + + public SkinSourceDependencyContainer(IReadOnlyDependencyContainer fallback) + { + this.fallback = fallback; + } + + public object Get(Type type) + { + if (type == typeof(ISkinSource)) + return SkinSource; + + return fallback.Get(type); + } + + public object Get(Type type, CacheInfo info) + { + if (type == typeof(ISkinSource)) + return SkinSource; + + return fallback.Get(type, info); + } + + public void Inject<T>(T instance) where T : class + { + // Never used directly + } + } + + private class TestResourceStore : IResourceStore<byte[]> + { + public readonly List<string> PerformedLookups = new List<string>(); + + public byte[] Get(string name) + { + markLookup(name); + return Array.Empty<byte>(); + } + + public Task<byte[]> GetAsync(string name) + { + markLookup(name); + return Task.FromResult(Array.Empty<byte>()); + } + + public Stream GetStream(string name) + { + markLookup(name); + return new MemoryStream(); + } + + private void markLookup(string name) => PerformedLookups.Add(name.Substring(name.LastIndexOf(Path.DirectorySeparatorChar) + 1)); + + public IEnumerable<string> GetAvailableResources() => Enumerable.Empty<string>(); + + public void Dispose() + { + } + } + + private class TestWorkingBeatmap : ClockBackedTestWorkingBeatmap + { + private readonly BeatmapInfo skinBeatmapInfo; + private readonly IResourceStore<byte[]> resourceStore; + + public TestWorkingBeatmap(BeatmapInfo skinBeatmapInfo, IResourceStore<byte[]> resourceStore, IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock, AudioManager audio) + : base(beatmap, storyboard, referenceClock, audio) + { + this.skinBeatmapInfo = skinBeatmapInfo; + this.resourceStore = resourceStore; + } + + protected override ISkin GetSkin() => new LegacyBeatmapSkin(skinBeatmapInfo, resourceStore, AudioManager); + } + } +} diff --git a/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs new file mode 100644 index 0000000000..e93bf916c7 --- /dev/null +++ b/osu.Game/Tests/Beatmaps/LegacyModConversionTest.cs @@ -0,0 +1,35 @@ +// 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. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Rulesets; + +namespace osu.Game.Tests.Beatmaps +{ + /// <summary> + /// Base class for tests of converting <see cref="LegacyMods"/> enumeration flags to ruleset mod instances. + /// </summary> + public abstract class LegacyModConversionTest + { + /// <summary> + /// Creates the <see cref="Ruleset"/> whose legacy mod conversion is to be tested. + /// </summary> + /// <returns></returns> + protected abstract Ruleset CreateRuleset(); + + protected void Test(LegacyMods legacyMods, Type[] expectedMods) + { + var ruleset = CreateRuleset(); + var mods = ruleset.ConvertFromLegacyMods(legacyMods).ToList(); + Assert.AreEqual(expectedMods.Length, mods.Count); + + foreach (var modType in expectedMods) + { + Assert.IsNotNull(mods.SingleOrDefault(mod => mod.GetType() == modType)); + } + } + } +} diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index b77a8508ad..87b77f4616 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -1,10 +1,13 @@ // 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. +using System; using System.Collections.Generic; using System.IO; using System.Text; +using osu.Framework.Extensions; using osu.Game.Beatmaps; +using osu.Game.IO; using osu.Game.Rulesets; using Decoder = osu.Game.Beatmaps.Formats.Decoder; @@ -12,17 +15,21 @@ namespace osu.Game.Tests.Beatmaps { public class TestBeatmap : Beatmap { - public TestBeatmap(RulesetInfo ruleset) + public TestBeatmap(RulesetInfo ruleset, bool withHitObjects = true) { - var baseBeatmap = createTestBeatmap(); + var baseBeatmap = CreateBeatmap(); BeatmapInfo = baseBeatmap.BeatmapInfo; ControlPointInfo = baseBeatmap.ControlPointInfo; Breaks = baseBeatmap.Breaks; - HitObjects = baseBeatmap.HitObjects; + + if (withHitObjects) + HitObjects = baseBeatmap.HitObjects; BeatmapInfo.Ruleset = ruleset; + BeatmapInfo.RulesetID = ruleset.ID ?? 0; BeatmapInfo.BeatmapSet.Metadata = BeatmapInfo.Metadata; + BeatmapInfo.BeatmapSet.Files = new List<BeatmapSetFileInfo>(); BeatmapInfo.BeatmapSet.Beatmaps = new List<BeatmapInfo> { BeatmapInfo }; BeatmapInfo.BeatmapSet.OnlineInfo = new BeatmapSetOnlineInfo { @@ -36,13 +43,30 @@ namespace osu.Game.Tests.Beatmaps }; } + protected virtual Beatmap CreateBeatmap() => createTestBeatmap(); + private static Beatmap createTestBeatmap() { using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(test_beatmap_data))) - using (var reader = new StreamReader(stream)) - return Decoder.GetDecoder<Beatmap>(reader).Decode(reader); + { + using (var reader = new LineBufferedReader(stream)) + { + var b = Decoder.GetDecoder<Beatmap>(reader).Decode(reader); + + b.BeatmapInfo.MD5Hash = test_beatmap_hash.Value.md5; + b.BeatmapInfo.Hash = test_beatmap_hash.Value.sha2; + + return b; + } + } } + private static readonly Lazy<(string md5, string sha2)> test_beatmap_hash = new Lazy<(string md5, string sha2)>(() => + { + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(test_beatmap_data))) + return (stream.ComputeMD5Hash(), stream.ComputeSHA2Hash()); + }); + private const string test_beatmap_data = @"osu file format v14 [General] diff --git a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs index 0ef35879e3..bfcb2403c1 100644 --- a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs @@ -1,30 +1,42 @@ // 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. +using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; +using osu.Game.Storyboards; namespace osu.Game.Tests.Beatmaps { public class TestWorkingBeatmap : WorkingBeatmap { private readonly IBeatmap beatmap; + private readonly Storyboard storyboard; /// <summary> /// Create an instance which provides the <see cref="IBeatmap"/> when requested. /// </summary> - /// <param name="beatmap">The beatmap</param> - public TestWorkingBeatmap(IBeatmap beatmap) - : base(beatmap.BeatmapInfo, null) + /// <param name="beatmap">The beatmap.</param> + /// <param name="storyboard">An optional storyboard.</param> + /// <param name="audioManager">The <see cref="AudioManager"/>.</param> + public TestWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null, AudioManager audioManager = null) + : base(beatmap.BeatmapInfo, audioManager) { this.beatmap = beatmap; + this.storyboard = storyboard; } + public override bool TrackLoaded => true; + + public override bool BeatmapLoaded => true; + protected override IBeatmap GetBeatmap() => beatmap; + protected override Storyboard GetStoryboard() => storyboard ?? base.GetStoryboard(); + protected override Texture GetBackground() => null; - protected override Track GetTrack() => null; + protected override Track GetBeatmapTrack() => null; } } diff --git a/osu.Game/Tests/CleanRunHeadlessGameHost.cs b/osu.Game/Tests/CleanRunHeadlessGameHost.cs index bfbf7bb9da..baa7b27d28 100644 --- a/osu.Game/Tests/CleanRunHeadlessGameHost.cs +++ b/osu.Game/Tests/CleanRunHeadlessGameHost.cs @@ -1,6 +1,7 @@ // 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. +using System.Runtime.CompilerServices; using osu.Framework.Platform; namespace osu.Game.Tests @@ -10,8 +11,15 @@ namespace osu.Game.Tests /// </summary> public class CleanRunHeadlessGameHost : HeadlessGameHost { - public CleanRunHeadlessGameHost(string gameName = @"", bool bindIPC = false, bool realtime = true) - : base(gameName, bindIPC, realtime) + /// <summary> + /// Create a new instance. + /// </summary> + /// <param name="gameSuffix">An optional suffix which will isolate this host from others called from the same method source.</param> + /// <param name="bindIPC">Whether to bind IPC channels.</param> + /// <param name="realtime">Whether the host should be forced to run in realtime, rather than accelerated test time.</param> + /// <param name="callingMethodName">The name of the calling method, used for test file isolation and clean-up.</param> + public CleanRunHeadlessGameHost(string gameSuffix = @"", bool bindIPC = false, bool realtime = true, [CallerMemberName] string callingMethodName = @"") + : base(callingMethodName + gameSuffix, bindIPC, realtime) { } diff --git a/osu.Game/Tests/TestScoreInfo.cs b/osu.Game/Tests/TestScoreInfo.cs new file mode 100644 index 0000000000..9090a12d3f --- /dev/null +++ b/osu.Game/Tests/TestScoreInfo.cs @@ -0,0 +1,62 @@ +// 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. + +using System; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Tests.Beatmaps; +using osu.Game.Users; + +namespace osu.Game.Tests +{ + public class TestScoreInfo : ScoreInfo + { + public TestScoreInfo(RulesetInfo ruleset) + { + User = new User + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }; + + Beatmap = new TestBeatmap(ruleset).BeatmapInfo; + Ruleset = ruleset; + RulesetID = ruleset.ID ?? 0; + Mods = new Mod[] { new TestModHardRock(), new TestModDoubleTime() }; + + TotalScore = 2845370; + Accuracy = 0.95; + MaxCombo = 999; + Rank = ScoreRank.S; + Date = DateTimeOffset.Now; + + Statistics[HitResult.Miss] = 1; + Statistics[HitResult.Meh] = 50; + Statistics[HitResult.Ok] = 100; + Statistics[HitResult.Good] = 200; + Statistics[HitResult.Great] = 300; + Statistics[HitResult.Perfect] = 320; + Statistics[HitResult.SmallTickHit] = 50; + Statistics[HitResult.SmallTickMiss] = 25; + Statistics[HitResult.LargeTickHit] = 100; + Statistics[HitResult.LargeTickMiss] = 50; + Statistics[HitResult.SmallBonus] = 10; + Statistics[HitResult.SmallBonus] = 50; + + Position = 1; + } + + private class TestModHardRock : ModHardRock + { + public override double ScoreMultiplier => 1; + } + + private class TestModDoubleTime : ModDoubleTime + { + public override double ScoreMultiplier => 1; + } + } +} diff --git a/osu.Game/Tests/Visual/EditorClockTestScene.cs b/osu.Game/Tests/Visual/EditorClockTestScene.cs index 58a443ed3d..693c9cb792 100644 --- a/osu.Game/Tests/Visual/EditorClockTestScene.cs +++ b/osu.Game/Tests/Visual/EditorClockTestScene.cs @@ -15,11 +15,13 @@ namespace osu.Game.Tests.Visual /// Provides a clock, beat-divisor, and scrolling capability for test cases of editor components that /// are preferrably tested within the presence of a clock and seek controls. /// </summary> - public abstract class EditorClockTestScene : OsuTestScene + public abstract class EditorClockTestScene : OsuManualInputManagerTestScene { protected readonly BindableBeatDivisor BeatDivisor = new BindableBeatDivisor(); protected new readonly EditorClock Clock; + protected virtual bool ScrollUsingMouseWheel => true; + protected EditorClockTestScene() { Clock = new EditorClock(new ControlPointInfo(), 5000, BeatDivisor) { IsCoupled = false }; @@ -30,8 +32,7 @@ namespace osu.Game.Tests.Visual var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); dependencies.Cache(BeatDivisor); - dependencies.CacheAs<IFrameBasedClock>(Clock); - dependencies.CacheAs<IAdjustableClock>(Clock); + dependencies.CacheAs(Clock); return dependencies; } @@ -58,6 +59,9 @@ namespace osu.Game.Tests.Visual protected override bool OnScroll(ScrollEvent e) { + if (!ScrollUsingMouseWheel) + return false; + if (e.ScrollDelta.Y > 0) Clock.SeekBackward(true); else diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs index 75bbb3e110..a9ee8e2668 100644 --- a/osu.Game/Tests/Visual/EditorTestScene.cs +++ b/osu.Game/Tests/Visual/EditorTestScene.cs @@ -1,31 +1,69 @@ // 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. -using System; -using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Testing; using osu.Game.Rulesets; +using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Compose.Components.Timeline; namespace osu.Game.Tests.Visual { public abstract class EditorTestScene : ScreenTestScene { - public override IReadOnlyList<Type> RequiredTypes => new[] { typeof(Editor), typeof(EditorScreen) }; + protected EditorBeatmap EditorBeatmap; - private readonly Ruleset ruleset; + protected TestEditor Editor { get; private set; } - protected EditorTestScene(Ruleset ruleset) - { - this.ruleset = ruleset; - } + protected EditorClock EditorClock { get; private set; } [BackgroundDependencyLoader] private void load() { - Beatmap.Value = CreateWorkingBeatmap(ruleset.RulesetInfo); + Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); + } - LoadScreen(new Editor()); + protected virtual bool EditorComponentsReady => Editor.ChildrenOfType<HitObjectComposer>().FirstOrDefault()?.IsLoaded == true + && Editor.ChildrenOfType<TimelineArea>().FirstOrDefault()?.IsLoaded == true; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("load editor", () => LoadScreen(Editor = CreateEditor())); + AddUntilStep("wait for editor to load", () => EditorComponentsReady); + AddStep("get beatmap", () => EditorBeatmap = Editor.ChildrenOfType<EditorBeatmap>().Single()); + AddStep("get clock", () => EditorClock = Editor.ChildrenOfType<EditorClock>().Single()); + } + + /// <summary> + /// Creates the ruleset for providing a corresponding beatmap to load the editor on. + /// </summary> + [NotNull] + protected abstract Ruleset CreateEditorRuleset(); + + protected sealed override Ruleset CreateRuleset() => CreateEditorRuleset(); + + protected virtual TestEditor CreateEditor() => new TestEditor(); + + protected class TestEditor : Editor + { + public new void Undo() => base.Undo(); + + public new void Redo() => base.Redo(); + + public new void Save() => base.Save(); + + public new void Cut() => base.Cut(); + + public new void Copy() => base.Copy(); + + public new void Paste() => base.Paste(); + + public new bool HasUnsavedChanges => base.HasUnsavedChanges; } } } diff --git a/osu.Game/Tests/Visual/ModPerfectTestScene.cs b/osu.Game/Tests/Visual/ModPerfectTestScene.cs new file mode 100644 index 0000000000..93b38a149c --- /dev/null +++ b/osu.Game/Tests/Visual/ModPerfectTestScene.cs @@ -0,0 +1,64 @@ +// 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. + +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Tests.Visual +{ + public abstract class ModPerfectTestScene : ModTestScene + { + private readonly ModPerfect mod; + + protected ModPerfectTestScene(ModPerfect mod) + { + this.mod = mod; + } + + protected void CreateHitObjectTest(HitObjectTestData testData, bool shouldMiss) => CreateModTest(new ModTestData + { + Mod = mod, + Beatmap = new Beatmap + { + BeatmapInfo = { Ruleset = CreatePlayerRuleset().RulesetInfo }, + HitObjects = { testData.HitObject } + }, + Autoplay = !shouldMiss, + PassCondition = () => ((PerfectModTestPlayer)Player).CheckFailed(shouldMiss && testData.FailOnMiss) + }); + + protected override TestPlayer CreateModPlayer(Ruleset ruleset) => new PerfectModTestPlayer(); + + private class PerfectModTestPlayer : TestPlayer + { + public PerfectModTestPlayer() + : base(showResults: false) + { + } + + protected override bool CheckModsAllowFailure() => true; + + public bool CheckFailed(bool failed) + { + if (!failed) + return ScoreProcessor.HasCompleted.Value && !HealthProcessor.HasFailed; + + return HealthProcessor.HasFailed; + } + } + + protected class HitObjectTestData + { + public readonly HitObject HitObject; + public readonly bool FailOnMiss; + + public HitObjectTestData(HitObject hitObject, bool failOnMiss = true) + { + HitObject = hitObject; + FailOnMiss = failOnMiss; + } + } + } +} diff --git a/osu.Game/Tests/Visual/ModTestScene.cs b/osu.Game/Tests/Visual/ModTestScene.cs new file mode 100644 index 0000000000..a71d008eb9 --- /dev/null +++ b/osu.Game/Tests/Visual/ModTestScene.cs @@ -0,0 +1,102 @@ +// 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. + +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Tests.Visual +{ + public abstract class ModTestScene : PlayerTestScene + { + protected sealed override bool HasCustomSteps => true; + + private ModTestData currentTestData; + + protected void CreateModTest(ModTestData testData) => CreateTest(() => + { + AddStep("set test data", () => currentTestData = testData); + }); + + public override void TearDownSteps() + { + AddUntilStep("test passed", () => + { + if (currentTestData == null) + return true; + + return currentTestData.PassCondition?.Invoke() ?? false; + }); + + base.TearDownSteps(); + } + + protected sealed override IBeatmap CreateBeatmap(RulesetInfo ruleset) => currentTestData?.Beatmap ?? base.CreateBeatmap(ruleset); + + protected sealed override TestPlayer CreatePlayer(Ruleset ruleset) + { + var mods = new List<Mod>(SelectedMods.Value); + + if (currentTestData.Mods != null) + mods.AddRange(currentTestData.Mods); + if (currentTestData.Autoplay) + mods.Add(ruleset.GetAutoplayMod()); + + SelectedMods.Value = mods; + + return CreateModPlayer(ruleset); + } + + protected virtual TestPlayer CreateModPlayer(Ruleset ruleset) => new ModTestPlayer(AllowFail); + + protected class ModTestPlayer : TestPlayer + { + private readonly bool allowFail; + + protected override bool CheckModsAllowFailure() => allowFail; + + public ModTestPlayer(bool allowFail) + : base(false, false) + { + this.allowFail = allowFail; + } + } + + protected class ModTestData + { + /// <summary> + /// Whether to use a replay to simulate an auto-play. True by default. + /// </summary> + public bool Autoplay = true; + + /// <summary> + /// The beatmap for this test case. + /// </summary> + [CanBeNull] + public IBeatmap Beatmap; + + /// <summary> + /// The conditions that cause this test case to pass. + /// </summary> + [CanBeNull] + public Func<bool> PassCondition; + + /// <summary> + /// The <see cref="Mod"/>s this test case tests. + /// </summary> + public IReadOnlyList<Mod> Mods; + + /// <summary> + /// Convenience property for setting <see cref="Mods"/> if only + /// a single mod is to be tested. + /// </summary> + public Mod Mod + { + set => Mods = new[] { value }; + } + } + } +} diff --git a/osu.Game/Tests/Visual/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/MultiplayerTestScene.cs index ffb431b4d3..4d073f16f4 100644 --- a/osu.Game/Tests/Visual/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/MultiplayerTestScene.cs @@ -10,7 +10,7 @@ namespace osu.Game.Tests.Visual public abstract class MultiplayerTestScene : ScreenTestScene { [Cached] - private readonly Bindable<Room> currentRoom = new Bindable<Room>(new Room()); + private readonly Bindable<Room> currentRoom = new Bindable<Room>(); protected Room Room { diff --git a/osu.Game.Tests/Visual/UserInterface/OsuGridTestScene.cs b/osu.Game/Tests/Visual/OsuGridTestScene.cs similarity index 88% rename from osu.Game.Tests/Visual/UserInterface/OsuGridTestScene.cs rename to osu.Game/Tests/Visual/OsuGridTestScene.cs index 096ac951de..48f85be6ba 100644 --- a/osu.Game.Tests/Visual/UserInterface/OsuGridTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGridTestScene.cs @@ -5,7 +5,7 @@ using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -namespace osu.Game.Tests.Visual.UserInterface +namespace osu.Game.Tests.Visual { /// <summary> /// An abstract test case which exposes small cells arranged in a grid. @@ -37,9 +37,12 @@ namespace osu.Game.Tests.Visual.UserInterface Add(testContainer = new GridContainer { RelativeSizeAxes = Axes.Both }); cells = new Drawable[rows, cols]; + for (int r = 0; r < rows; r++) - for (int c = 0; c < cols; c++) - cells[r, c] = new Container { RelativeSizeAxes = Axes.Both }; + { + for (int c = 0; c < cols; c++) + cells[r, c] = new Container { RelativeSizeAxes = Axes.Both }; + } testContainer.Content = cells.ToJagged(); } diff --git a/osu.Game/Tests/Visual/ManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs similarity index 89% rename from osu.Game/Tests/Visual/ManualInputManagerTestScene.cs rename to osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index 86191609a4..64f4d7b95b 100644 --- a/osu.Game/Tests/Visual/ManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -8,12 +8,13 @@ using osu.Framework.Testing.Input; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; namespace osu.Game.Tests.Visual { - public abstract class ManualInputManagerTestScene : OsuTestScene + public abstract class OsuManualInputManagerTestScene : OsuTestScene { protected override Container<Drawable> Content => content; private readonly Container content; @@ -23,14 +24,18 @@ namespace osu.Game.Tests.Visual private readonly TriangleButton buttonTest; private readonly TriangleButton buttonLocal; - protected ManualInputManagerTestScene() + protected OsuManualInputManagerTestScene() { + MenuCursorContainer cursorContainer; + base.Content.AddRange(new Drawable[] { InputManager = new ManualInputManager { UseParentInput = true, - Child = content = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }, + Child = new GlobalActionContainer(null) + .WithChild((cursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }) + .WithChild(content = new OsuTooltipContainer(cursorContainer.Cursor) { RelativeSizeAxes = Axes.Both })) }, new Container { diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 9b3c15aa91..198d22fedd 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. +// 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. using System; @@ -6,86 +6,138 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; -using osu.Framework.Graphics.Textures; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI; +using osu.Game.Screens; +using osu.Game.Storyboards; using osu.Game.Tests.Beatmaps; -using osuTK; namespace osu.Game.Tests.Visual { + [ExcludeFromDynamicCompile] public abstract class OsuTestScene : TestScene { - [Cached(typeof(Bindable<WorkingBeatmap>))] - [Cached(typeof(IBindable<WorkingBeatmap>))] - private OsuTestBeatmap beatmap; + protected Bindable<WorkingBeatmap> Beatmap { get; private set; } - protected BindableBeatmap Beatmap => beatmap; + protected Bindable<RulesetInfo> Ruleset; - [Cached] - [Cached(typeof(IBindable<RulesetInfo>))] - protected readonly Bindable<RulesetInfo> Ruleset = new Bindable<RulesetInfo>(); + protected Bindable<IReadOnlyList<Mod>> SelectedMods; - [Cached] - [Cached(Type = typeof(IBindable<IReadOnlyList<Mod>>))] - protected readonly Bindable<IReadOnlyList<Mod>> Mods = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>()); + protected new OsuScreenDependencies Dependencies { get; private set; } - protected new DependencyContainer Dependencies { get; private set; } + private DrawableRulesetDependencies rulesetDependencies; - private readonly Lazy<Storage> localStorage; + private Lazy<Storage> localStorage; protected Storage LocalStorage => localStorage.Value; + private Lazy<DatabaseContextFactory> contextFactory; + + protected IAPIProvider API + { + get + { + if (UseOnlineAPI) + throw new InvalidOperationException($"Using the {nameof(OsuTestScene)} dummy API is not supported when {nameof(UseOnlineAPI)} is true"); + + return dummyAPI; + } + } + + private DummyAPIAccess dummyAPI; + + protected DatabaseContextFactory ContextFactory => contextFactory.Value; + + /// <summary> + /// Whether this test scene requires real-world API access. + /// If true, this will bypass the local <see cref="DummyAPIAccess"/> and use the <see cref="OsuGameBase"/> provided one. + /// </summary> + protected virtual bool UseOnlineAPI => false; + + /// <summary> + /// When running headless, there is an opportunity to use the host storage rather than creating a second isolated one. + /// This is because the host is recycled per TestScene execution in headless at an nunit level. + /// </summary> + private Storage isolatedHostStorage; + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - // This is the earliest we can get OsuGameBase, which is used by the dummy working beatmap to find textures - var working = new DummyWorkingBeatmap(parent.Get<AudioManager>(), parent.Get<TextureStore>()); + if (!UseFreshStoragePerRun) + isolatedHostStorage = (parent.Get<GameHost>() as HeadlessGameHost)?.Storage; - beatmap = new OsuTestBeatmap(working) + contextFactory = new Lazy<DatabaseContextFactory>(() => { - Default = working - }; + var factory = new DatabaseContextFactory(LocalStorage); - return Dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + // only reset the database if not using the host storage. + // if we reset the host storage, it will delete global key bindings. + if (isolatedHostStorage == null) + factory.ResetDatabase(); + + using (var usage = factory.Get()) + usage.Migrate(); + return factory; + }); + + RecycleLocalStorage(); + + var baseDependencies = base.CreateChildDependencies(parent); + + var providedRuleset = CreateRuleset(); + if (providedRuleset != null) + baseDependencies = rulesetDependencies = new DrawableRulesetDependencies(providedRuleset, baseDependencies); + + Dependencies = new OsuScreenDependencies(false, baseDependencies); + + Beatmap = Dependencies.Beatmap; + Beatmap.SetDefault(); + + Ruleset = Dependencies.Ruleset; + Ruleset.SetDefault(); + + SelectedMods = Dependencies.Mods; + SelectedMods.SetDefault(); + + if (!UseOnlineAPI) + { + dummyAPI = new DummyAPIAccess(); + Dependencies.CacheAs<IAPIProvider>(dummyAPI); + Add(dummyAPI); + } + + return Dependencies; } + protected override Container<Drawable> Content => content ?? base.Content; + + private readonly Container content; + protected OsuTestScene() { - localStorage = new Lazy<Storage>(() => new NativeStorage($"{GetType().Name}-{Guid.NewGuid()}")); + base.Content.Add(content = new DrawSizePreservingFillContainer()); } - [Resolved] - private AudioManager audio { get; set; } + protected virtual bool UseFreshStoragePerRun => false; - protected virtual IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset); - - protected WorkingBeatmap CreateWorkingBeatmap(RulesetInfo ruleset) => - CreateWorkingBeatmap(CreateBeatmap(ruleset)); - - protected virtual WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap) => - new ClockBackedTestWorkingBeatmap(beatmap, Clock, audio); - - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) + public virtual void RecycleLocalStorage() { - Ruleset.Value = rulesets.AvailableRulesets.First(); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (beatmap?.Value.TrackLoaded == true) - beatmap.Value.Track.Stop(); - - if (localStorage.IsValueCreated) + if (localStorage?.IsValueCreated == true) { try { @@ -96,6 +148,53 @@ namespace osu.Game.Tests.Visual // we don't really care if this fails; it will just leave folders lying around from test runs. } } + + localStorage = + new Lazy<Storage>(() => isolatedHostStorage ?? new NativeStorage(Path.Combine(RuntimeInfo.StartupDirectory, $"{GetType().Name}-{Guid.NewGuid()}"))); + } + + [Resolved] + protected AudioManager Audio { get; private set; } + + [Resolved] + protected MusicController MusicController { get; private set; } + + /// <summary> + /// Creates the ruleset to be used for this test scene. + /// </summary> + /// <remarks> + /// When testing against ruleset-specific components, this method must be overriden to their corresponding ruleset. + /// </remarks> + [CanBeNull] + protected virtual Ruleset CreateRuleset() => null; + + protected virtual IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset); + + protected WorkingBeatmap CreateWorkingBeatmap(RulesetInfo ruleset) => + CreateWorkingBeatmap(CreateBeatmap(ruleset), null); + + protected virtual WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => + new ClockBackedTestWorkingBeatmap(beatmap, storyboard, Clock, Audio); + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + Ruleset.Value = CreateRuleset()?.RulesetInfo ?? rulesets.AvailableRulesets.First(); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + rulesetDependencies?.Dispose(); + + if (MusicController?.TrackLoaded == true) + MusicController.Stop(); + + if (contextFactory?.IsValueCreated == true) + contextFactory.Value.ResetDatabase(); + + RecycleLocalStorage(); } protected override ITestSceneTestRunner CreateRunner() => new OsuTestSceneTestRunner(); @@ -113,7 +212,7 @@ namespace osu.Game.Tests.Visual /// <param name="referenceClock">A clock which should be used instead of a stopwatch for virtual time progression.</param> /// <param name="audio">Audio manager. Required if a reference clock isn't provided.</param> public ClockBackedTestWorkingBeatmap(RulesetInfo ruleset, IFrameBasedClock referenceClock, AudioManager audio) - : this(new TestBeatmap(ruleset), referenceClock, audio) + : this(new TestBeatmap(ruleset), null, referenceClock, audio) { } @@ -121,29 +220,35 @@ namespace osu.Game.Tests.Visual /// Create an instance which provides the <see cref="IBeatmap"/> when requested. /// </summary> /// <param name="beatmap">The beatmap</param> + /// <param name="storyboard">The storyboard.</param> /// <param name="referenceClock">An optional clock which should be used instead of a stopwatch for virtual time progression.</param> /// <param name="audio">Audio manager. Required if a reference clock isn't provided.</param> - /// <param name="length">The length of the returned virtual track.</param> - public ClockBackedTestWorkingBeatmap(IBeatmap beatmap, IFrameBasedClock referenceClock, AudioManager audio, double length = 60000) - : base(beatmap) + public ClockBackedTestWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard, IFrameBasedClock referenceClock, AudioManager audio) + : base(beatmap, storyboard, audio) { + double trackLength = 60000; + + if (beatmap.HitObjects.Count > 0) + // add buffer after last hitobject to allow for final replay frames etc. + trackLength = Math.Max(trackLength, beatmap.HitObjects.Max(h => h.GetEndTime()) + 2000); + if (referenceClock != null) { store = new TrackVirtualStore(referenceClock); audio.AddItem(store); - track = store.GetVirtual(length); + track = store.GetVirtual(trackLength); } else - track = audio?.Tracks.GetVirtual(length); + track = audio?.Tracks.GetVirtual(trackLength); } - protected override void Dispose(bool isDisposing) + ~ClockBackedTestWorkingBeatmap() { - base.Dispose(isDisposing); + // Remove the track store from the audio manager store?.Dispose(); } - protected override Track GetTrack() => track; + protected override Track GetBeatmapTrack() => track; public class TrackVirtualStore : AudioCollectionManager<Track>, ITrackStore { @@ -162,7 +267,7 @@ namespace osu.Game.Tests.Visual public IEnumerable<string> GetAvailableResources() => throw new NotImplementedException(); - public Track GetVirtual(double length = Double.PositiveInfinity) + public Track GetVirtual(double length = double.PositiveInfinity) { var track = new TrackVirtualManual(referenceClock) { Length = length }; AddItem(track); @@ -177,15 +282,8 @@ namespace osu.Game.Tests.Visual { private readonly IFrameBasedClock referenceClock; - private readonly ManualClock clock = new ManualClock(); - private bool running; - /// <summary> - /// Local offset added to the reference clock to resolve correct time. - /// </summary> - private double offset; - public TrackVirtualManual(IFrameBasedClock referenceClock) { this.referenceClock = referenceClock; @@ -194,10 +292,10 @@ namespace osu.Game.Tests.Visual public override bool Seek(double seek) { - offset = MathHelper.Clamp(seek, 0, Length); + accumulated = Math.Clamp(seek, 0, Length); lastReferenceTime = null; - return offset == seek; + return accumulated == seek; } public override void Start() @@ -216,9 +314,6 @@ namespace osu.Game.Tests.Visual if (running) { running = false; - // on stopping, the current value should be transferred out of the clock, as we can no longer rely on - // the referenceClock (which will still be counting time). - offset = clock.CurrentTime; lastReferenceTime = null; } } @@ -227,7 +322,9 @@ namespace osu.Game.Tests.Visual private double? lastReferenceTime; - public override double CurrentTime => clock.CurrentTime; + private double accumulated; + + public override double CurrentTime => Math.Min(accumulated, Length); protected override void UpdateState() { @@ -237,18 +334,14 @@ namespace osu.Game.Tests.Visual { double refTime = referenceClock.CurrentTime; - if (!lastReferenceTime.HasValue) - { - // if the clock just started running, the current value should be transferred to the offset - // (to zero the progression of time). - offset -= refTime; - } + double? lastRefTime = lastReferenceTime; + + if (lastRefTime != null) + accumulated += (refTime - lastRefTime.Value) * Rate; lastReferenceTime = refTime; } - clock.CurrentTime = Math.Min((lastReferenceTime ?? 0) + offset, Length); - if (CurrentTime >= Length) { Stop(); @@ -272,13 +365,5 @@ namespace osu.Game.Tests.Visual public void RunTestBlocking(TestScene test) => runner.RunTestBlocking(test); } - - private class OsuTestBeatmap : BindableBeatmap - { - public OsuTestBeatmap(WorkingBeatmap defaultValue) - : base(defaultValue) - { - } - } } } diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index 2b177e264f..c3d74f21aa 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -8,45 +8,59 @@ using osu.Framework.Timing; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; namespace osu.Game.Tests.Visual { [Cached(Type = typeof(IPlacementHandler))] - public abstract class PlacementBlueprintTestScene : OsuTestScene, IPlacementHandler + public abstract class PlacementBlueprintTestScene : OsuManualInputManagerTestScene, IPlacementHandler { - protected Container HitObjectContainer; + protected readonly Container HitObjectContainer; private PlacementBlueprint currentBlueprint; protected PlacementBlueprintTestScene() { - Add(HitObjectContainer = CreateHitObjectContainer()); + Add(HitObjectContainer = CreateHitObjectContainer().With(c => c.Clock = new FramedClock(new StopwatchClock()))); } [BackgroundDependencyLoader] private void load() { Beatmap.Value.BeatmapInfo.BaseDifficulty.CircleSize = 2; - Add(currentBlueprint = CreateBlueprint()); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - dependencies.CacheAs<IAdjustableClock>(new StopwatchClock()); + dependencies.CacheAs(new EditorClock()); return dependencies; } + protected override void LoadComplete() + { + base.LoadComplete(); + + ResetPlacement(); + } + public void BeginPlacement(HitObject hitObject) { } - public void EndPlacement(HitObject hitObject) + public void EndPlacement(HitObject hitObject, bool commit) { - AddHitObject(CreateHitObject(hitObject)); + if (commit) + AddHitObject(CreateHitObject(hitObject)); - Remove(currentBlueprint); + ResetPlacement(); + } + + protected void ResetPlacement() + { + if (currentBlueprint != null) + Remove(currentBlueprint); Add(currentBlueprint = CreateBlueprint()); } @@ -54,10 +68,31 @@ namespace osu.Game.Tests.Visual { } - protected virtual Container CreateHitObjectContainer() => new Container { RelativeSizeAxes = Axes.Both }; + protected override void Update() + { + base.Update(); + + currentBlueprint.UpdatePosition(SnapForBlueprint(currentBlueprint)); + } + + protected virtual SnapResult SnapForBlueprint(PlacementBlueprint blueprint) => + new SnapResult(InputManager.CurrentState.Mouse.Position, null); + + public override void Add(Drawable drawable) + { + base.Add(drawable); + + if (drawable is PlacementBlueprint blueprint) + { + blueprint.Show(); + blueprint.UpdatePosition(SnapForBlueprint(blueprint)); + } + } protected virtual void AddHitObject(DrawableHitObject hitObject) => HitObjectContainer.Add(hitObject); + protected virtual Container CreateHitObjectContainer() => new Container { RelativeSizeAxes = Axes.Both }; + protected abstract DrawableHitObject CreateHitObject(HitObject hitObject); protected abstract PlacementBlueprint CreateBlueprint(); } diff --git a/osu.Game/Tests/Visual/PlayerTestScene.cs b/osu.Game/Tests/Visual/PlayerTestScene.cs index 03e17a819c..088e997de9 100644 --- a/osu.Game/Tests/Visual/PlayerTestScene.cs +++ b/osu.Game/Tests/Visual/PlayerTestScene.cs @@ -1,57 +1,100 @@ // 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. +using System; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual { public abstract class PlayerTestScene : RateAdjustedBeatmapTestScene { - private readonly Ruleset ruleset; + /// <summary> + /// Whether custom test steps are provided. Custom tests should invoke <see cref="CreateTest"/> to create the test steps. + /// </summary> + protected virtual bool HasCustomSteps => false; - protected Player Player; + protected TestPlayer Player; - protected PlayerTestScene(Ruleset ruleset) - { - this.ruleset = ruleset; - } + protected OsuConfigManager LocalConfig; [BackgroundDependencyLoader] private void load() { - OsuConfigManager manager; - Dependencies.Cache(manager = new OsuConfigManager(LocalStorage)); - manager.GetBindable<double>(OsuSetting.DimLevel).Value = 1.0; + Dependencies.Cache(LocalConfig = new OsuConfigManager(LocalStorage)); + LocalConfig.GetBindable<double>(OsuSetting.DimLevel).Value = 1.0; } [SetUpSteps] - public virtual void SetUpSteps() + public override void SetUpSteps() { - AddStep(ruleset.RulesetInfo.Name, loadPlayer); + base.SetUpSteps(); + + if (!HasCustomSteps) + CreateTest(null); + } + + protected void CreateTest(Action action) + { + if (action != null && !HasCustomSteps) + throw new InvalidOperationException($"Cannot add custom test steps without {nameof(HasCustomSteps)} being set."); + + action?.Invoke(); + + AddStep(CreatePlayerRuleset().Description, LoadPlayer); AddUntilStep("player loaded", () => Player.IsLoaded && Player.Alpha == 1); } protected virtual bool AllowFail => false; - private void loadPlayer() + protected virtual bool Autoplay => false; + + protected void LoadPlayer() { + var ruleset = Ruleset.Value.CreateInstance(); var beatmap = CreateBeatmap(ruleset.RulesetInfo); Beatmap.Value = CreateWorkingBeatmap(beatmap); + SelectedMods.Value = Array.Empty<Mod>(); if (!AllowFail) - Mods.Value = new[] { ruleset.GetAllMods().First(m => m is ModNoFail) }; + { + var noFailMod = ruleset.GetAllMods().FirstOrDefault(m => m is ModNoFail); + if (noFailMod != null) + SelectedMods.Value = new[] { noFailMod }; + } + + if (Autoplay) + { + var mod = ruleset.GetAutoplayMod(); + if (mod != null) + SelectedMods.Value = SelectedMods.Value.Concat(mod.Yield()).ToArray(); + } Player = CreatePlayer(ruleset); LoadScreen(Player); } - protected virtual Player CreatePlayer(Ruleset ruleset) => new TestPlayer(false, false); + protected override void Dispose(bool isDisposing) + { + LocalConfig?.Dispose(); + base.Dispose(isDisposing); + } + + /// <summary> + /// Creates the ruleset for setting up the <see cref="Player"/> component. + /// </summary> + [NotNull] + protected abstract Ruleset CreatePlayerRuleset(); + + protected sealed override Ruleset CreateRuleset() => CreatePlayerRuleset(); + + protected virtual TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(false, false); } } diff --git a/osu.Game/Tests/Visual/RateAdjustedBeatmapTestScene.cs b/osu.Game/Tests/Visual/RateAdjustedBeatmapTestScene.cs index 921a1d9789..ad24ffc7b8 100644 --- a/osu.Game/Tests/Visual/RateAdjustedBeatmapTestScene.cs +++ b/osu.Game/Tests/Visual/RateAdjustedBeatmapTestScene.cs @@ -13,7 +13,7 @@ namespace osu.Game.Tests.Visual base.Update(); // note that this will override any mod rate application - Beatmap.Value.Track.TempoAdjust = Clock.Rate; + Beatmap.Value.Track.Tempo.Value = Clock.Rate; } } } diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index 23f45e0d0f..33cc00e748 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Screens; namespace osu.Game.Tests.Visual @@ -10,9 +11,9 @@ namespace osu.Game.Tests.Visual /// <summary> /// A test case which can be used to test a screen (that relies on OnEntering being called to execute startup instructions). /// </summary> - public abstract class ScreenTestScene : ManualInputManagerTestScene + public abstract class ScreenTestScene : OsuManualInputManagerTestScene { - private readonly OsuScreenStack stack; + protected readonly OsuScreenStack Stack; private readonly Container content; @@ -22,16 +23,28 @@ namespace osu.Game.Tests.Visual { base.Content.AddRange(new Drawable[] { - stack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, + Stack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, content = new Container { RelativeSizeAxes = Axes.Both } }); } - protected void LoadScreen(OsuScreen screen) + protected void LoadScreen(OsuScreen screen) => Stack.Push(screen); + + [SetUpSteps] + public virtual void SetUpSteps() => addExitAllScreensStep(); + + [TearDownSteps] + public virtual void TearDownSteps() => addExitAllScreensStep(); + + private void addExitAllScreensStep() { - if (stack.CurrentScreen != null) - stack.Exit(); - stack.Push(screen); + AddUntilStep("exit all screens", () => + { + if (Stack.CurrentScreen == null) return true; + + Stack.Exit(); + return false; + }); } } } diff --git a/osu.Game/Tests/Visual/ScrollingTestContainer.cs b/osu.Game/Tests/Visual/ScrollingTestContainer.cs index bdad3d278c..994f23577d 100644 --- a/osu.Game/Tests/Visual/ScrollingTestContainer.cs +++ b/osu.Game/Tests/Visual/ScrollingTestContainer.cs @@ -30,6 +30,11 @@ namespace osu.Game.Tests.Visual set => scrollingInfo.TimeRange.Value = value; } + public ScrollingDirection Direction + { + set => scrollingInfo.Direction.Value = value; + } + public IScrollingInfo ScrollingInfo => scrollingInfo; [Cached(Type = typeof(IScrollingInfo))] @@ -42,19 +47,19 @@ namespace osu.Game.Tests.Visual public void Flip() => scrollingInfo.Direction.Value = scrollingInfo.Direction.Value == ScrollingDirection.Up ? ScrollingDirection.Down : ScrollingDirection.Up; - private class TestScrollingInfo : IScrollingInfo + public class TestScrollingInfo : IScrollingInfo { public readonly Bindable<ScrollingDirection> Direction = new Bindable<ScrollingDirection>(); IBindable<ScrollingDirection> IScrollingInfo.Direction => Direction; - public readonly Bindable<double> TimeRange = new Bindable<double>(1000) { Value = 1000 }; + public readonly Bindable<double> TimeRange = new BindableDouble(1000) { Value = 1000 }; IBindable<double> IScrollingInfo.TimeRange => TimeRange; public readonly TestScrollAlgorithm Algorithm = new TestScrollAlgorithm(); IScrollAlgorithm IScrollingInfo.Algorithm => Algorithm; } - private class TestScrollAlgorithm : IScrollAlgorithm + public class TestScrollAlgorithm : IScrollAlgorithm { public readonly SortedList<MultiplierControlPoint> ControlPoints = new SortedList<MultiplierControlPoint>(); @@ -86,8 +91,8 @@ namespace osu.Game.Tests.Visual } } - public double GetDisplayStartTime(double time, double timeRange) - => implementation.GetDisplayStartTime(time, timeRange); + public double GetDisplayStartTime(double originTime, float offset, double timeRange, float scrollLength) + => implementation.GetDisplayStartTime(originTime, offset, timeRange, scrollLength); public float GetLength(double startTime, double endTime, double timeRange, float scrollLength) => implementation.GetLength(startTime, endTime, timeRange, scrollLength); diff --git a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs index df3af2cc43..1176361679 100644 --- a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs @@ -1,19 +1,15 @@ // 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. -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Events; using osu.Framework.Timing; using osu.Game.Rulesets.Edit; namespace osu.Game.Tests.Visual { - public abstract class SelectionBlueprintTestScene : OsuTestScene + public abstract class SelectionBlueprintTestScene : OsuManualInputManagerTestScene { - private SelectionBlueprint blueprint; - protected override Container<Drawable> Content => content ?? base.Content; private readonly Container content; @@ -26,25 +22,13 @@ namespace osu.Game.Tests.Visual }); } - [BackgroundDependencyLoader] - private void load() + protected void AddBlueprint(OverlaySelectionBlueprint blueprint) { - blueprint = CreateBlueprint(); - blueprint.Depth = float.MinValue; - blueprint.SelectionRequested += (_, __) => blueprint.Select(); - - Add(blueprint); - - AddStep("Select", () => blueprint.Select()); - AddStep("Deselect", () => blueprint.Deselect()); + Add(blueprint.With(d => + { + d.Depth = float.MinValue; + d.Select(); + })); } - - protected override bool OnClick(ClickEvent e) - { - blueprint.Deselect(); - return true; - } - - protected abstract SelectionBlueprint CreateBlueprint(); } } diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs new file mode 100644 index 0000000000..68098f9d3b --- /dev/null +++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs @@ -0,0 +1,199 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio; +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.IO.Stores; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual +{ + public abstract class SkinnableTestScene : OsuGridTestScene + { + private Skin metricsSkin; + private Skin defaultSkin; + private Skin specialSkin; + private Skin oldSkin; + + protected SkinnableTestScene() + : base(2, 3) + { + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio, SkinManager skinManager, OsuGameBase game) + { + var dllStore = new DllResourceStore(DynamicCompilationOriginal.GetType().Assembly); + + metricsSkin = new TestLegacySkin(new SkinInfo { Name = "metrics-skin" }, new NamespacedResourceStore<byte[]>(dllStore, "Resources/metrics_skin"), audio, true); + defaultSkin = new DefaultLegacySkin(new NamespacedResourceStore<byte[]>(game.Resources, "Skins/Legacy"), audio); + specialSkin = new TestLegacySkin(new SkinInfo { Name = "special-skin" }, new NamespacedResourceStore<byte[]>(dllStore, "Resources/special_skin"), audio, true); + oldSkin = new TestLegacySkin(new SkinInfo { Name = "old-skin" }, new NamespacedResourceStore<byte[]>(dllStore, "Resources/old_skin"), audio, true); + } + + private readonly List<Drawable> createdDrawables = new List<Drawable>(); + + public void SetContents(Func<Drawable> creationFunction) + { + createdDrawables.Clear(); + + var beatmap = CreateBeatmapForSkinProvider(); + + Cell(0).Child = createProvider(null, creationFunction, beatmap); + Cell(1).Child = createProvider(metricsSkin, creationFunction, beatmap); + Cell(2).Child = createProvider(defaultSkin, creationFunction, beatmap); + Cell(3).Child = createProvider(specialSkin, creationFunction, beatmap); + Cell(4).Child = createProvider(oldSkin, creationFunction, beatmap); + } + + protected IEnumerable<Drawable> CreatedDrawables => createdDrawables; + + private Drawable createProvider(Skin skin, Func<Drawable> creationFunction, IBeatmap beatmap) + { + var created = creationFunction(); + + createdDrawables.Add(created); + + SkinProvidingContainer mainProvider; + Container childContainer; + OutlineBox outlineBox; + SkinProvidingContainer skinProvider; + + var children = new Container + { + RelativeSizeAxes = Axes.Both, + BorderColour = Color4.White, + BorderThickness = 5, + Masking = true, + + Children = new Drawable[] + { + new Box + { + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = skin?.SkinInfo?.Name ?? "none", + Scale = new Vector2(1.5f), + Padding = new MarginPadding(5), + }, + childContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + outlineBox = new OutlineBox(), + (mainProvider = new SkinProvidingContainer(skin)).WithChild( + skinProvider = new SkinProvidingContainer(Ruleset.Value.CreateInstance().CreateLegacySkinProvider(mainProvider, beatmap)) + { + Child = created, + } + ) + } + }, + } + }; + + // run this once initially to bring things into a sane state as early as possible. + updateSizing(); + + // run this once after construction to handle the case the changes are made in a BDL/LoadComplete call. + Schedule(updateSizing); + + return children; + + void updateSizing() + { + var autoSize = created.RelativeSizeAxes == Axes.None; + + foreach (var c in new[] { mainProvider, childContainer, skinProvider }) + { + c.RelativeSizeAxes = Axes.None; + c.AutoSizeAxes = Axes.None; + + c.RelativeSizeAxes = !autoSize ? Axes.Both : Axes.None; + c.AutoSizeAxes = autoSize ? Axes.Both : Axes.None; + } + + outlineBox.Alpha = autoSize ? 1 : 0; + } + } + + /// <summary> + /// Creates the ruleset for adding the corresponding skin transforming component. + /// </summary> + [NotNull] + protected abstract Ruleset CreateRulesetForSkinProvider(); + + protected sealed override Ruleset CreateRuleset() => CreateRulesetForSkinProvider(); + + protected virtual IBeatmap CreateBeatmapForSkinProvider() => CreateWorkingBeatmap(Ruleset.Value).GetPlayableBeatmap(Ruleset.Value); + + private class OutlineBox : CompositeDrawable + { + public OutlineBox() + { + BorderColour = Color4.IndianRed; + BorderThickness = 5; + Masking = true; + RelativeSizeAxes = Axes.Both; + + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Color4.Brown, + AlwaysPresent = true + }; + } + } + + private class TestLegacySkin : LegacySkin + { + private readonly bool extrapolateAnimations; + + public TestLegacySkin(SkinInfo skin, IResourceStore<byte[]> storage, AudioManager audioManager, bool extrapolateAnimations) + : base(skin, storage, audioManager, "skin.ini") + { + this.extrapolateAnimations = extrapolateAnimations; + } + + public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + var lookup = base.GetTexture(componentName, wrapModeS, wrapModeT); + + if (lookup != null) + return lookup; + + // extrapolate frames to test longer animations + if (extrapolateAnimations) + { + var match = Regex.Match(componentName, "-([0-9]*)"); + + if (match.Length > 0 && int.TryParse(match.Groups[1].Value, out var number) && number < 60) + return base.GetTexture(componentName.Replace($"-{number}", $"-{number % 2}"), wrapModeS, wrapModeT); + } + + return null; + } + } + } +} diff --git a/osu.Game/Tests/Visual/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs index b93a1466e0..f016d29f38 100644 --- a/osu.Game/Tests/Visual/TestPlayer.cs +++ b/osu.Game/Tests/Visual/TestPlayer.cs @@ -1,17 +1,51 @@ // 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. +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual { + /// <summary> + /// A player that exposes many components that would otherwise not be available, for testing purposes. + /// </summary> public class TestPlayer : Player { - protected override bool PauseOnFocusLost => false; + protected override bool PauseOnFocusLost { get; } - public TestPlayer(bool allowPause = true, bool showResults = true) + public new DrawableRuleset DrawableRuleset => base.DrawableRuleset; + + /// <summary> + /// Mods from *player* (not OsuScreen). + /// </summary> + public new Bindable<IReadOnlyList<Mod>> Mods => base.Mods; + + public new HUDOverlay HUDOverlay => base.HUDOverlay; + + public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; + + public new ScoreProcessor ScoreProcessor => base.ScoreProcessor; + + public new HealthProcessor HealthProcessor => base.HealthProcessor; + + public readonly List<JudgementResult> Results = new List<JudgementResult>(); + + public TestPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) : base(allowPause, showResults) { + PauseOnFocusLost = pauseOnFocusLost; + } + + [BackgroundDependencyLoader] + private void load() + { + ScoreProcessor.NewJudgement += r => Results.Add(r); } } } diff --git a/osu.Desktop/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs similarity index 66% rename from osu.Desktop/Updater/SimpleUpdateManager.cs rename to osu.Game/Updater/SimpleUpdateManager.cs index 5184791de1..4ebf2a7368 100644 --- a/osu.Desktop/Updater/SimpleUpdateManager.cs +++ b/osu.Game/Updater/SimpleUpdateManager.cs @@ -1,48 +1,41 @@ // 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. +using System; using System.Collections.Generic; using System.Threading.Tasks; using Newtonsoft.Json; using osu.Framework; using osu.Framework.Allocation; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; -using osu.Framework.IO.Network; using osu.Framework.Platform; -using osu.Game; -using osu.Game.Overlays; +using osu.Game.Online.API; using osu.Game.Overlays.Notifications; -namespace osu.Desktop.Updater +namespace osu.Game.Updater { /// <summary> /// An update manager that shows notifications if a newer release is detected. /// Installation is left up to the user. /// </summary> - internal class SimpleUpdateManager : CompositeDrawable + public class SimpleUpdateManager : UpdateManager { - private NotificationOverlay notificationOverlay; private string version; - private GameHost host; + + [Resolved] + private GameHost host { get; set; } [BackgroundDependencyLoader] - private void load(NotificationOverlay notification, OsuGameBase game, GameHost host) + private void load(OsuGameBase game) { - notificationOverlay = notification; - - this.host = host; version = game.Version; - - if (game.IsDeployedBuild) - Schedule(() => Task.Run(() => checkForUpdateAsync())); } - private async void checkForUpdateAsync() + protected override async Task<bool> PerformUpdateCheck() { try { - var releases = new JsonWebRequest<GitHubRelease>("https://api.github.com/repos/ppy/osu/releases/latest"); + var releases = new OsuJsonWebRequest<GitHubRelease>("https://api.github.com/repos/ppy/osu/releases/latest"); await releases.PerformAsync(); @@ -50,7 +43,7 @@ namespace osu.Desktop.Updater if (latest.TagName != version) { - notificationOverlay.Post(new SimpleNotification + Notifications.Post(new SimpleNotification { Text = $"A newer release of osu! has been found ({version} → {latest.TagName}).\n\n" + "Click here to download the new version, which can be installed over the top of your existing installation", @@ -61,12 +54,17 @@ namespace osu.Desktop.Updater return true; } }); + + return true; } } catch { // we shouldn't crash on a web failure. or any failure for the matter. + return true; } + + return false; } private string getBestUrl(GitHubRelease release) @@ -76,11 +74,25 @@ namespace osu.Desktop.Updater switch (RuntimeInfo.OS) { case RuntimeInfo.Platform.Windows: - bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".exe")); + bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".exe", StringComparison.Ordinal)); break; case RuntimeInfo.Platform.MacOsx: - bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".app.zip")); + bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".app.zip", StringComparison.Ordinal)); + break; + + case RuntimeInfo.Platform.Linux: + bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".AppImage", StringComparison.Ordinal)); + break; + + case RuntimeInfo.Platform.iOS: + // iOS releases are available via testflight. this link seems to work well enough for now. + // see https://stackoverflow.com/a/32960501 + return "itms-beta://beta.itunes.apple.com/v1/app/1447765923"; + + case RuntimeInfo.Platform.Android: + // on our testing device this causes the download to magically disappear. + //bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".apk")); break; } diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs new file mode 100644 index 0000000000..f772c6d282 --- /dev/null +++ b/osu.Game/Updater/UpdateManager.cs @@ -0,0 +1,111 @@ +// 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. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Updater +{ + /// <summary> + /// An update manager which only shows notifications after an update completes. + /// </summary> + public class UpdateManager : CompositeDrawable + { + /// <summary> + /// Whether this UpdateManager should be or is capable of checking for updates. + /// </summary> + public bool CanCheckForUpdate => game.IsDeployedBuild && + // only implementations will actually check for updates. + GetType() != typeof(UpdateManager); + + [Resolved] + private OsuConfigManager config { get; set; } + + [Resolved] + private OsuGameBase game { get; set; } + + [Resolved] + protected NotificationOverlay Notifications { get; private set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Schedule(() => Task.Run(CheckForUpdateAsync)); + + var version = game.Version; + + var lastVersion = config.Get<string>(OsuSetting.Version); + + if (game.IsDeployedBuild && version != lastVersion) + { + // only show a notification if we've previously saved a version to the config file (ie. not the first run). + if (!string.IsNullOrEmpty(lastVersion)) + Notifications.Post(new UpdateCompleteNotification(version)); + } + + // 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); + } + + private readonly object updateTaskLock = new object(); + + private Task<bool> updateCheckTask; + + public async Task<bool> CheckForUpdateAsync() + { + if (!CanCheckForUpdate) + return false; + + Task<bool> waitTask; + + lock (updateTaskLock) + waitTask = (updateCheckTask ??= PerformUpdateCheck()); + + bool hasUpdates = await waitTask; + + lock (updateTaskLock) + updateCheckTask = null; + + return hasUpdates; + } + + /// <summary> + /// Performs an asynchronous check for application updates. + /// </summary> + /// <returns>Whether any update is waiting. May return true if an error occured (there is potentially an update available).</returns> + protected virtual Task<bool> PerformUpdateCheck() => Task.FromResult(false); + + private class UpdateCompleteNotification : SimpleNotification + { + private readonly string version; + + public UpdateCompleteNotification(string version) + { + this.version = version; + Text = $"You are now running osu!lazer {version}.\nClick to see what's new!"; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, ChangelogOverlay changelog, NotificationOverlay notificationOverlay) + { + Icon = FontAwesome.Solid.CheckSquare; + IconBackgound.Colour = colours.BlueDark; + + Activated = delegate + { + notificationOverlay.Hide(); + changelog.ShowBuild(OsuGameBase.CLIENT_STREAM_NAME, version); + return true; + }; + } + } + } +} diff --git a/osu.Game/Users/Country.cs b/osu.Game/Users/Country.cs index 1dcce6e870..a9fcd69286 100644 --- a/osu.Game/Users/Country.cs +++ b/osu.Game/Users/Country.cs @@ -1,11 +1,12 @@ // 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. +using System; using Newtonsoft.Json; namespace osu.Game.Users { - public class Country + public class Country : IEquatable<Country> { /// <summary> /// The name of this country. @@ -18,5 +19,7 @@ namespace osu.Game.Users /// </summary> [JsonProperty(@"code")] public string FlagName; + + public bool Equals(Country other) => FlagName == other?.FlagName; } } diff --git a/osu.Game/Users/CountryStatistics.cs b/osu.Game/Users/CountryStatistics.cs new file mode 100644 index 0000000000..000553c32b --- /dev/null +++ b/osu.Game/Users/CountryStatistics.cs @@ -0,0 +1,28 @@ +// 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. + +using Newtonsoft.Json; + +namespace osu.Game.Users +{ + public class CountryStatistics + { + [JsonProperty] + public Country Country; + + [JsonProperty(@"code")] + public string FlagName; + + [JsonProperty(@"active_users")] + public long ActiveUsers; + + [JsonProperty(@"play_count")] + public long PlayCount; + + [JsonProperty(@"ranked_score")] + public long RankedScore; + + [JsonProperty(@"performance")] + public long Performance; + } +} diff --git a/osu.Game/Users/Drawables/DrawableAvatar.cs b/osu.Game/Users/Drawables/DrawableAvatar.cs index ee3cf6331b..42d2dbb1c6 100644 --- a/osu.Game/Users/Drawables/DrawableAvatar.cs +++ b/osu.Game/Users/Drawables/DrawableAvatar.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.Containers; namespace osu.Game.Users.Drawables { + [LongRunningLoad] public class DrawableAvatar : Container { /// <summary> @@ -42,7 +43,7 @@ namespace osu.Game.Users.Drawables Texture texture = null; if (user != null && user.Id > 1) texture = textures.Get($@"https://a.ppy.sh/{user.Id}"); - if (texture == null) texture = textures.Get(@"Online/avatar-guest"); + texture ??= textures.Get(@"Online/avatar-guest"); ClickableArea clickableArea; Add(clickableArea = new ClickableArea @@ -67,13 +68,13 @@ namespace osu.Game.Users.Drawables if (!OpenOnClick.Value) return; - if (user != null) + if (user?.Id > 1) game?.ShowUser(user.Id); } private class ClickableArea : OsuClickableContainer { - public override string TooltipText => Enabled.Value ? @"View Profile" : null; + public override string TooltipText => Enabled.Value ? @"view profile" : null; protected override bool OnClick(ClickEvent e) { diff --git a/osu.Game/Users/Drawables/DrawableFlag.cs b/osu.Game/Users/Drawables/DrawableFlag.cs index 368354e48e..1d648e46b6 100644 --- a/osu.Game/Users/Drawables/DrawableFlag.cs +++ b/osu.Game/Users/Drawables/DrawableFlag.cs @@ -26,7 +26,7 @@ namespace osu.Game.Users.Drawables if (ts == null) throw new ArgumentNullException(nameof(ts)); - Texture = ts.Get($@"Flags/{country?.FlagName ?? @"__"}"); + Texture = ts.Get($@"Flags/{country?.FlagName ?? @"__"}") ?? ts.Get(@"Flags/__"); } } } diff --git a/osu.Game/Users/Drawables/UpdateableAvatar.cs b/osu.Game/Users/Drawables/UpdateableAvatar.cs index 795b90ba11..171462f3fc 100644 --- a/osu.Game/Users/Drawables/UpdateableAvatar.cs +++ b/osu.Game/Users/Drawables/UpdateableAvatar.cs @@ -31,12 +31,20 @@ namespace osu.Game.Users.Drawables set => base.CornerRadius = value; } + public new float CornerExponent + { + get => base.CornerExponent; + set => base.CornerExponent = value; + } + public new EdgeEffectParameters EdgeEffect { get => base.EdgeEffect; set => base.EdgeEffect = value; } + protected override double LoadDelay => 200; + /// <summary> /// Whether to show a default guest representation on null user (as opposed to nothing). /// </summary> diff --git a/osu.Game/Users/Drawables/UpdateableFlag.cs b/osu.Game/Users/Drawables/UpdateableFlag.cs index abc16b2390..1d30720889 100644 --- a/osu.Game/Users/Drawables/UpdateableFlag.cs +++ b/osu.Game/Users/Drawables/UpdateableFlag.cs @@ -1,8 +1,11 @@ // 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. +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Game.Overlays; namespace osu.Game.Users.Drawables { @@ -34,5 +37,14 @@ namespace osu.Game.Users.Drawables RelativeSizeAxes = Axes.Both, }; } + + [Resolved(canBeNull: true)] + private RankingsOverlay rankingsOverlay { get; set; } + + protected override bool OnClick(ClickEvent e) + { + rankingsOverlay?.ShowCountry(Country); + return true; + } } } diff --git a/osu.Game/Users/ExtendedUserPanel.cs b/osu.Game/Users/ExtendedUserPanel.cs new file mode 100644 index 0000000000..2604815751 --- /dev/null +++ b/osu.Game/Users/ExtendedUserPanel.cs @@ -0,0 +1,148 @@ +// 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. + +using osuTK; +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.Framework.Graphics.Sprites; +using osu.Game.Users.Drawables; +using osu.Framework.Input.Events; + +namespace osu.Game.Users +{ + public abstract class ExtendedUserPanel : UserPanel + { + public readonly Bindable<UserStatus> Status = new Bindable<UserStatus>(); + + public readonly IBindable<UserActivity> Activity = new Bindable<UserActivity>(); + + protected TextFlowContainer LastVisitMessage { get; private set; } + + private SpriteIcon statusIcon; + private OsuSpriteText statusMessage; + + protected ExtendedUserPanel(User user) + : base(user) + { + } + + [BackgroundDependencyLoader] + private void load() + { + BorderColour = ColourProvider?.Light1 ?? Colours.GreyVioletLighter; + + Status.ValueChanged += status => displayStatus(status.NewValue, Activity.Value); + Activity.ValueChanged += activity => displayStatus(Status.Value, activity.NewValue); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Status.TriggerChange(); + + // Colour should be applied immediately on first load. + statusIcon.FinishTransforms(); + } + + protected UpdateableAvatar CreateAvatar() => new UpdateableAvatar + { + User = User, + OpenOnClick = { Value = false } + }; + + protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.Country) + { + Size = new Vector2(39, 26) + }; + + protected SpriteIcon CreateStatusIcon() => statusIcon = new SpriteIcon + { + Icon = FontAwesome.Regular.Circle, + Size = new Vector2(25) + }; + + protected FillFlowContainer CreateStatusMessage(bool rightAlignedChildren) + { + var statusContainer = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical + }; + + var alignment = rightAlignedChildren ? Anchor.CentreRight : Anchor.CentreLeft; + + statusContainer.Add(LastVisitMessage = new TextFlowContainer(t => t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold)).With(text => + { + text.Anchor = alignment; + text.Origin = alignment; + text.AutoSizeAxes = Axes.Both; + text.Alpha = 0; + + if (User.LastVisit.HasValue) + { + text.AddText(@"Last seen "); + text.AddText(new DrawableDate(User.LastVisit.Value, italic: false) + { + Shadow = false + }); + } + })); + + statusContainer.Add(statusMessage = new OsuSpriteText + { + Anchor = alignment, + Origin = alignment, + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold) + }); + + return statusContainer; + } + + private void displayStatus(UserStatus status, UserActivity activity = null) + { + if (status != null) + { + LastVisitMessage.FadeTo(status is UserStatusOffline && User.LastVisit.HasValue ? 1 : 0); + + // Set status message based on activity (if we have one) and status is not offline + if (activity != null && !(status is UserStatusOffline)) + { + statusMessage.Text = activity.Status; + statusIcon.FadeColour(activity.GetAppropriateColour(Colours), 500, Easing.OutQuint); + return; + } + + // Otherwise use only status + statusMessage.Text = status.Message; + statusIcon.FadeColour(status.GetAppropriateColour(Colours), 500, Easing.OutQuint); + + return; + } + + // Fallback to web status if local one is null + if (User.IsOnline) + { + Status.Value = new UserStatusOnline(); + return; + } + + Status.Value = new UserStatusOffline(); + } + + protected override bool OnHover(HoverEvent e) + { + BorderThickness = 2; + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + BorderThickness = 0; + base.OnHoverLost(e); + } + } +} diff --git a/osu.Game/Users/User.cs b/osu.Game/Users/User.cs index df41e194b0..2a76a963e1 100644 --- a/osu.Game/Users/User.cs +++ b/osu.Game/Users/User.cs @@ -9,10 +9,10 @@ using osu.Framework.Bindables; namespace osu.Game.Users { - public class User + public class User : IEquatable<User> { [JsonProperty(@"id")] - public long Id = 1; + public int Id = 1; [JsonProperty(@"join_date")] public DateTimeOffset JoinDate; @@ -20,12 +20,15 @@ namespace osu.Game.Users [JsonProperty(@"username")] public string Username; + [JsonProperty(@"previous_usernames")] + public string[] PreviousUsernames; + [JsonProperty(@"country")] public Country Country; - public Bindable<UserStatus> Status = new Bindable<UserStatus>(); + public readonly Bindable<UserStatus> Status = new Bindable<UserStatus>(); - public IBindable<UserActivity> Activity = new Bindable<UserActivity>(); + public readonly Bindable<UserActivity> Activity = new Bindable<UserActivity>(); //public Team Team; @@ -66,6 +69,9 @@ namespace osu.Game.Users [JsonProperty(@"support_level")] public int SupportLevel; + [JsonProperty(@"current_mode_rank")] + public int? CurrentModeRank; + [JsonProperty(@"is_gmt")] public bool IsGMT; @@ -75,6 +81,9 @@ namespace osu.Game.Users [JsonProperty(@"is_bng")] public bool IsBNG; + [JsonProperty(@"is_bot")] + public bool IsBot; + [JsonProperty(@"is_active")] public bool Active; @@ -102,9 +111,6 @@ namespace osu.Game.Users [JsonProperty(@"twitter")] public string Twitter; - [JsonProperty(@"lastfm")] - public string Lastfm; - [JsonProperty(@"skype")] public string Skype; @@ -118,12 +124,30 @@ namespace osu.Game.Users public int PostCount; [JsonProperty(@"follower_count")] - public int[] FollowerCount; + public int FollowerCount; + + [JsonProperty(@"favourite_beatmapset_count")] + public int FavouriteBeatmapsetCount; + + [JsonProperty(@"graveyard_beatmapset_count")] + public int GraveyardBeatmapsetCount; + + [JsonProperty(@"loved_beatmapset_count")] + public int LovedBeatmapsetCount; + + [JsonProperty(@"ranked_and_approved_beatmapset_count")] + public int RankedAndApprovedBeatmapsetCount; + + [JsonProperty(@"unranked_beatmapset_count")] + public int UnrankedBeatmapsetCount; + + [JsonProperty(@"scores_first_count")] + public int ScoresFirstCount; [JsonProperty] private string[] playstyle { - set { PlayStyles = value?.Select(str => Enum.Parse(typeof(PlayStyle), str, true)).Cast<PlayStyle>().ToArray(); } + set => PlayStyles = value?.Select(str => Enum.Parse(typeof(PlayStyle), str, true)).Cast<PlayStyle>().ToArray(); } public PlayStyle[] PlayStyles; @@ -146,8 +170,27 @@ namespace osu.Game.Users public int Available; } + private UserStatistics statistics; + [JsonProperty(@"statistics")] - public UserStatistics Statistics; + public UserStatistics Statistics + { + get => statistics ??= new UserStatistics(); + set + { + if (statistics != null) + // we may already have rank history populated + value.RankHistory = statistics.RankHistory; + + statistics = value; + } + } + + [JsonProperty(@"rank_history")] + private RankHistoryData rankHistory + { + set => statistics.RankHistory = value; + } public class RankHistoryData { @@ -158,9 +201,6 @@ namespace osu.Game.Users public int[] Data; } - [JsonProperty(@"rankHistory")] - public RankHistoryData RankHistory; - [JsonProperty("badges")] public Badge[] Badges; @@ -176,6 +216,21 @@ namespace osu.Game.Users public int ID; } + [JsonProperty("monthly_playcounts")] + public UserHistoryCount[] MonthlyPlaycounts; + + [JsonProperty("replays_watched_counts")] + public UserHistoryCount[] ReplaysWatchedCounts; + + public class UserHistoryCount + { + [JsonProperty("start_date")] + public DateTime Date; + + [JsonProperty("count")] + public long Count; + } + public override string ToString() => Username; /// <summary> @@ -184,6 +239,7 @@ namespace osu.Game.Users public static readonly User SYSTEM_USER = new User { Username = "system", + Colour = @"9c0101", Id = 0 }; @@ -201,5 +257,13 @@ namespace osu.Game.Users [Description("Touch Screen")] Touch, } + + public bool Equals(User other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return Id == other.Id; + } } } diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index 918c547978..0b4fa94942 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -3,6 +3,8 @@ using osu.Game.Beatmaps; using osu.Game.Graphics; +using osu.Game.Online.Multiplayer; +using osu.Game.Rulesets; using osuTK.Graphics; namespace osu.Game.Users @@ -44,15 +46,15 @@ namespace osu.Game.Users { public BeatmapInfo Beatmap { get; } - public Rulesets.RulesetInfo Ruleset { get; } + public RulesetInfo Ruleset { get; } - public SoloGame(BeatmapInfo info, Rulesets.RulesetInfo ruleset) + public SoloGame(BeatmapInfo info, RulesetInfo ruleset) { Beatmap = info; Ruleset = ruleset; } - public override string Status => @"Playing alone"; + public override string Status => Ruleset.CreateInstance().PlayingVerb; } public class Spectating : UserActivity @@ -60,9 +62,21 @@ namespace osu.Game.Users public override string Status => @"Spectating a game"; } + public class SearchingForLobby : UserActivity + { + public override string Status => @"Looking for a lobby"; + } + public class InLobby : UserActivity { - public override string Status => @"In a Multiplayer Lobby"; + public override string Status => @"In a multiplayer lobby"; + + public readonly Room Room; + + public InLobby(Room room) + { + Room = room; + } } } } diff --git a/osu.Game/Users/UserBrickPanel.cs b/osu.Game/Users/UserBrickPanel.cs new file mode 100644 index 0000000000..9ca7768187 --- /dev/null +++ b/osu.Game/Users/UserBrickPanel.cs @@ -0,0 +1,65 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Users +{ + public class UserBrickPanel : UserPanel + { + public UserBrickPanel(User user) + : base(user) + { + AutoSizeAxes = Axes.Both; + CornerRadius = 6; + } + + [BackgroundDependencyLoader] + private void load() + { + Background.FadeTo(0.2f); + } + + protected override Drawable CreateLayout() => new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5, 0), + Margin = new MarginPadding + { + Horizontal = 10, + Vertical = 3, + }, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new CircularContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Masking = true, + Width = 4, + Height = 13, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = string.IsNullOrEmpty(User.Colour) ? Color4Extensions.FromHex("0087ca") : Color4Extensions.FromHex(User.Colour) + } + }, + CreateUsername().With(u => + { + u.Anchor = Anchor.CentreLeft; + u.Origin = Anchor.CentreLeft; + u.Font = OsuFont.GetFont(size: 13, weight: FontWeight.Bold); + }) + } + }; + } +} diff --git a/osu.Game/Users/UserCoverBackground.cs b/osu.Game/Users/UserCoverBackground.cs index e583acac9f..34bbf6892e 100644 --- a/osu.Game/Users/UserCoverBackground.cs +++ b/osu.Game/Users/UserCoverBackground.cs @@ -1,6 +1,7 @@ // 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. +using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -23,6 +24,17 @@ namespace osu.Game.Users protected override Drawable CreateDrawable(User user) => new Cover(user); + protected override double LoadDelay => 300; + + /// <summary> + /// Delay before the background is unloaded while off-screen. + /// </summary> + protected virtual double UnloadDelay => 5000; + + protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func<Drawable> createContentFunc, double timeBeforeLoad) + => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay); + + [LongRunningLoad] private class Cover : CompositeDrawable { private readonly User user; @@ -46,6 +58,7 @@ namespace osu.Game.Users }; } else + { InternalChild = new Sprite { RelativeSizeAxes = Axes.Both, @@ -54,6 +67,7 @@ namespace osu.Game.Users Anchor = Anchor.Centre, Origin = Anchor.Centre }; + } } protected override void LoadComplete() diff --git a/osu.Game/Users/UserGridPanel.cs b/osu.Game/Users/UserGridPanel.cs new file mode 100644 index 0000000000..44dcbc305d --- /dev/null +++ b/osu.Game/Users/UserGridPanel.cs @@ -0,0 +1,139 @@ +// 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. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays.Profile.Header.Components; +using osuTK; + +namespace osu.Game.Users +{ + public class UserGridPanel : ExtendedUserPanel + { + private const int margin = 10; + + public UserGridPanel(User user) + : base(user) + { + Height = 120; + CornerRadius = 10; + } + + [BackgroundDependencyLoader] + private void load() + { + Background.FadeTo(0.3f); + } + + protected override Drawable CreateLayout() + { + FillFlowContainer details; + + var layout = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(margin), + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, margin), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + CreateAvatar().With(avatar => + { + avatar.Size = new Vector2(60); + avatar.Masking = true; + avatar.CornerRadius = 6; + }), + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = margin }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension() + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + details = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(6), + Children = new Drawable[] + { + CreateFlag(), + } + } + }, + new Drawable[] + { + CreateUsername().With(username => + { + username.Anchor = Anchor.CentreLeft; + username.Origin = Anchor.CentreLeft; + }) + } + } + } + } + }, + new[] + { + Empty(), + Empty() + }, + new Drawable[] + { + CreateStatusIcon().With(icon => + { + icon.Anchor = Anchor.Centre; + icon.Origin = Anchor.Centre; + }), + CreateStatusMessage(false).With(message => + { + message.Anchor = Anchor.CentreLeft; + message.Origin = Anchor.CentreLeft; + message.Margin = new MarginPadding { Left = margin }; + }) + } + } + } + }; + + if (User.IsSupporter) + { + details.Add(new SupporterIcon + { + Height = 26, + SupportLevel = User.SupportLevel + }); + } + + return layout; + } + } +} diff --git a/osu.Game/Users/UserListPanel.cs b/osu.Game/Users/UserListPanel.cs new file mode 100644 index 0000000000..cc4fca9b94 --- /dev/null +++ b/osu.Game/Users/UserListPanel.cs @@ -0,0 +1,109 @@ +// 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. + +using osu.Framework.Graphics; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Colour; +using osu.Framework.Extensions.Color4Extensions; +using osuTK.Graphics; +using osu.Framework.Graphics.Containers; +using osuTK; +using osu.Game.Overlays.Profile.Header.Components; + +namespace osu.Game.Users +{ + public class UserListPanel : ExtendedUserPanel + { + public UserListPanel(User user) + : base(user) + { + RelativeSizeAxes = Axes.X; + Height = 40; + CornerRadius = 6; + } + + [BackgroundDependencyLoader] + private void load() + { + Background.Width = 0.5f; + Background.Origin = Anchor.CentreRight; + Background.Anchor = Anchor.CentreRight; + Background.Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(1), Color4.White.Opacity(0.3f)); + } + + protected override Drawable CreateLayout() + { + FillFlowContainer details; + + var layout = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + details = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Children = new Drawable[] + { + CreateAvatar().With(avatar => + { + avatar.Anchor = Anchor.CentreLeft; + avatar.Origin = Anchor.CentreLeft; + avatar.Size = new Vector2(40); + }), + CreateFlag().With(flag => + { + flag.Anchor = Anchor.CentreLeft; + flag.Origin = Anchor.CentreLeft; + }), + CreateUsername().With(username => + { + username.Anchor = Anchor.CentreLeft; + username.Origin = Anchor.CentreLeft; + }) + } + }, + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Margin = new MarginPadding { Right = 10 }, + Children = new Drawable[] + { + CreateStatusIcon().With(icon => + { + icon.Anchor = Anchor.CentreRight; + icon.Origin = Anchor.CentreRight; + }), + CreateStatusMessage(true).With(message => + { + message.Anchor = Anchor.CentreRight; + message.Origin = Anchor.CentreRight; + }) + } + } + } + }; + + if (User.IsSupporter) + { + details.Add(new SupporterIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Height = 20, + SupportLevel = User.SupportLevel + }); + } + + return layout; + } + } +} diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index c63c12773e..0981136dba 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -2,13 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; -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.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -16,234 +11,80 @@ using osu.Game.Overlays; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.Containers; -using osu.Game.Overlays.Profile.Header.Components; -using osu.Game.Users.Drawables; +using JetBrains.Annotations; namespace osu.Game.Users { - public class UserPanel : OsuClickableContainer, IHasContextMenu + public abstract class UserPanel : OsuClickableContainer, IHasContextMenu { - private readonly User user; - private const float height = 100; - private const float content_padding = 10; - private const float status_height = 30; - - [Resolved(canBeNull: true)] - private OsuColour colours { get; set; } - - private Container statusBar; - private Box statusBg; - private OsuSpriteText statusMessage; - - private Container content; - protected override Container<Drawable> Content => content; - - public readonly Bindable<UserStatus> Status = new Bindable<UserStatus>(); - - public readonly IBindable<UserActivity> Activity = new Bindable<UserActivity>(); + public readonly User User; + /// <summary> + /// Perform an action in addition to showing the user's profile. + /// This should be used to perform auxiliary tasks and not as a primary action for clicking a user panel (to maintain a consistent UX). + /// </summary> public new Action Action; - protected Action ViewProfile; + protected Action ViewProfile { get; private set; } - public UserPanel(User user) + protected Drawable Background { get; private set; } + + protected UserPanel(User user) { if (user == null) throw new ArgumentNullException(nameof(user)); - this.user = user; - - Height = height - status_height; + User = user; } - [BackgroundDependencyLoader(permitNulls: true)] - private void load(UserProfileOverlay profile) + [Resolved(canBeNull: true)] + private UserProfileOverlay profileOverlay { get; set; } + + [Resolved(canBeNull: true)] + protected OverlayColourProvider ColourProvider { get; private set; } + + [Resolved] + protected OsuColour Colours { get; private set; } + + [BackgroundDependencyLoader] + private void load() { - if (colours == null) - throw new ArgumentNullException(nameof(colours)); + Masking = true; - FillFlowContainer infoContainer; - - AddInternal(content = new Container + AddRange(new[] { - RelativeSizeAxes = Axes.Both, - Masking = true, - CornerRadius = 5, - EdgeEffect = new EdgeEffectParameters + new Box { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.25f), - Radius = 4, + RelativeSizeAxes = Axes.Both, + Colour = ColourProvider?.Background5 ?? Colours.Gray1 }, - - Children = new Drawable[] + Background = new UserCoverBackground { - new DelayedLoadUnloadWrapper(() => new UserCoverBackground - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - User = user, - }, 300, 5000) - { - RelativeSizeAxes = Axes.Both, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black.Opacity(0.7f), - }, - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Top = content_padding, Horizontal = content_padding }, - Children = new Drawable[] - { - new UpdateableAvatar - { - Size = new Vector2(height - status_height - content_padding * 2), - User = user, - Masking = true, - CornerRadius = 5, - OpenOnClick = { Value = false }, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Colour = Color4.Black.Opacity(0.25f), - Radius = 4, - }, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = height - status_height - content_padding }, - Children = new Drawable[] - { - new OsuSpriteText - { - Text = user.Username, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 18, italics: true), - }, - infoContainer = new FillFlowContainer - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - AutoSizeAxes = Axes.X, - Height = 20f, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5f, 0f), - Children = new Drawable[] - { - new UpdateableFlag(user.Country) - { - Width = 30f, - RelativeSizeAxes = Axes.Y, - }, - }, - }, - }, - }, - }, - }, - statusBar = new Container - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Alpha = 0f, - Children = new Drawable[] - { - statusBg = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.5f, - }, - new FillFlowContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5f, 0f), - Children = new Drawable[] - { - new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Icon = FontAwesome.Regular.Circle, - Shadow = true, - Size = new Vector2(14), - }, - statusMessage = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold), - }, - }, - }, - }, - }, - } + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + User = User, + }, + CreateLayout() }); - if (user.IsSupporter) - { - infoContainer.Add(new SupporterIcon - { - Height = 20f, - SupportLevel = user.SupportLevel - }); - } - - Status.ValueChanged += status => displayStatus(status.NewValue, Activity.Value); - Activity.ValueChanged += activity => displayStatus(Status.Value, activity.NewValue); - base.Action = ViewProfile = () => { Action?.Invoke(); - profile?.ShowUser(user); + profileOverlay?.ShowUser(User); }; } - protected override void LoadComplete() + [NotNull] + protected abstract Drawable CreateLayout(); + + protected OsuSpriteText CreateUsername() => new OsuSpriteText { - base.LoadComplete(); - Status.TriggerChange(); - } - - private void displayStatus(UserStatus status, UserActivity activity = null) - { - const float transition_duration = 500; - - if (status == null) - { - statusBar.ResizeHeightTo(0f, transition_duration, Easing.OutQuint); - statusBar.FadeOut(transition_duration, Easing.OutQuint); - this.ResizeHeightTo(height - status_height, transition_duration, Easing.OutQuint); - } - else - { - statusBar.ResizeHeightTo(status_height, transition_duration, Easing.OutQuint); - statusBar.FadeIn(transition_duration, Easing.OutQuint); - this.ResizeHeightTo(height, transition_duration, Easing.OutQuint); - } - - if (status is UserStatusOnline && activity != null) - { - statusMessage.Text = activity.Status; - statusBg.FadeColour(activity.GetAppropriateColour(colours), 500, Easing.OutQuint); - } - else - { - statusMessage.Text = status?.Message; - statusBg.FadeColour(status?.GetAppropriateColour(colours) ?? colours.Gray5, 500, Easing.OutQuint); - } - } + Font = OsuFont.GetFont(size: 16, weight: FontWeight.Bold), + Shadow = false, + Text = User.Username, + }; public MenuItem[] ContextMenuItems => new MenuItem[] { diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index 7afbef01c5..8b7699d0ad 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -4,11 +4,16 @@ using System; using Newtonsoft.Json; using osu.Game.Scoring; +using osu.Game.Utils; +using static osu.Game.Users.User; namespace osu.Game.Users { public class UserStatistics { + [JsonProperty] + public User User; + [JsonProperty(@"level")] public LevelInfo Level; @@ -25,7 +30,7 @@ namespace osu.Game.Users public decimal? PP; [JsonProperty(@"pp_rank")] // the API sometimes only returns this value in condensed user responses - private int rank + private int? rank { set => Ranks.Global = value; } @@ -39,6 +44,9 @@ namespace osu.Game.Users [JsonProperty(@"hit_accuracy")] public decimal Accuracy; + [JsonIgnore] + public string DisplayAccuracy => Accuracy.FormatAccuracy(); + [JsonProperty(@"play_count")] public int PlayCount; @@ -63,13 +71,13 @@ namespace osu.Game.Users public struct Grades { [JsonProperty(@"ssh")] - public int SSPlus; + public int? SSPlus; [JsonProperty(@"ss")] public int SS; [JsonProperty(@"sh")] - public int SPlus; + public int? SPlus; [JsonProperty(@"s")] public int S; @@ -84,13 +92,13 @@ namespace osu.Game.Users switch (rank) { case ScoreRank.XH: - return SSPlus; + return SSPlus ?? 0; case ScoreRank.X: return SS; case ScoreRank.SH: - return SPlus; + return SPlus ?? 0; case ScoreRank.S: return S; @@ -113,5 +121,7 @@ namespace osu.Game.Users [JsonProperty(@"country")] public int? Country; } + + public RankHistoryData RankHistory; } } diff --git a/osu.Game/Users/UserStatus.cs b/osu.Game/Users/UserStatus.cs index cf372560af..21c18413f4 100644 --- a/osu.Game/Users/UserStatus.cs +++ b/osu.Game/Users/UserStatus.cs @@ -15,7 +15,7 @@ namespace osu.Game.Users public class UserStatusOnline : UserStatus { public override string Message => @"Online"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.BlueDarker; + public override Color4 GetAppropriateColour(OsuColour colours) => colours.GreenLight; } public abstract class UserStatusBusy : UserStatusOnline @@ -26,7 +26,7 @@ namespace osu.Game.Users public class UserStatusOffline : UserStatus { public override string Message => @"Offline"; - public override Color4 GetAppropriateColour(OsuColour colours) => colours.Gray7; + public override Color4 GetAppropriateColour(OsuColour colours) => Color4.Black; } public class UserStatusDoNotDisturb : UserStatus diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs new file mode 100644 index 0000000000..f2ab99f4b7 --- /dev/null +++ b/osu.Game/Utils/FormatUtils.cs @@ -0,0 +1,22 @@ +// 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. + +namespace osu.Game.Utils +{ + public static class FormatUtils + { + /// <summary> + /// Turns the provided accuracy into a percentage with 2 decimal places. + /// </summary> + /// <param name="accuracy">The accuracy to be formatted</param> + /// <returns>formatted accuracy in percentage</returns> + public static string FormatAccuracy(this double accuracy) => $"{accuracy:0.00%}"; + + /// <summary> + /// Turns the provided accuracy into a percentage with 2 decimal places. + /// </summary> + /// <param name="accuracy">The accuracy to be formatted</param> + /// <returns>formatted accuracy in percentage</returns> + public static string FormatAccuracy(this decimal accuracy) => $"{accuracy:0.00}%"; + } +} diff --git a/osu.Game/Utils/HumanizerUtils.cs b/osu.Game/Utils/HumanizerUtils.cs new file mode 100644 index 0000000000..5b7c3630d9 --- /dev/null +++ b/osu.Game/Utils/HumanizerUtils.cs @@ -0,0 +1,30 @@ +// 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. + +using System; +using System.Globalization; +using Humanizer; + +namespace osu.Game.Utils +{ + public static class HumanizerUtils + { + /// <summary> + /// Turns the current or provided date into a human readable sentence + /// </summary> + /// <param name="input">The date to be humanized</param> + /// <returns>distance of time in words</returns> + public static string Humanize(DateTimeOffset input) + { + // this works around https://github.com/xamarin/xamarin-android/issues/2012 and https://github.com/Humanizr/Humanizer/issues/690#issuecomment-368536282 + try + { + return input.Humanize(); + } + catch (ArgumentException) + { + return input.Humanize(culture: new CultureInfo("en-US")); + } + } + } +} diff --git a/osu.Game/Utils/OrderAttribute.cs b/osu.Game/Utils/OrderAttribute.cs new file mode 100644 index 0000000000..aded7f9814 --- /dev/null +++ b/osu.Game/Utils/OrderAttribute.cs @@ -0,0 +1,52 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace osu.Game.Utils +{ + public static class OrderAttributeUtils + { + /// <summary> + /// Get values of an enum in order. Supports custom ordering via <see cref="OrderAttribute"/>. + /// </summary> + public static IEnumerable<T> GetValuesInOrder<T>() + { + var type = typeof(T); + + if (!type.IsEnum) + throw new InvalidOperationException("T must be an enum"); + + IEnumerable<T> items = (T[])Enum.GetValues(type); + + if (Attribute.GetCustomAttribute(type, typeof(HasOrderedElementsAttribute)) == null) + return items; + + return items.OrderBy(i => + { + if (type.GetField(i.ToString()).GetCustomAttributes(typeof(OrderAttribute), false).FirstOrDefault() is OrderAttribute attr) + return attr.Order; + + throw new ArgumentException($"Not all values of {nameof(T)} have {nameof(OrderAttribute)} specified."); + }); + } + } + + [AttributeUsage(AttributeTargets.Field)] + public class OrderAttribute : Attribute + { + public readonly int Order; + + public OrderAttribute(int order) + { + Order = order; + } + } + + [AttributeUsage(AttributeTargets.Enum)] + public class HasOrderedElementsAttribute : Attribute + { + } +} diff --git a/osu.Game/Utils/PeriodTracker.cs b/osu.Game/Utils/PeriodTracker.cs new file mode 100644 index 0000000000..ba77702247 --- /dev/null +++ b/osu.Game/Utils/PeriodTracker.cs @@ -0,0 +1,69 @@ +// 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. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace osu.Game.Utils +{ + /// <summary> + /// Represents a tracking component used for whether a specific time instant falls into any of the provided periods. + /// </summary> + public class PeriodTracker + { + private readonly List<Period> periods; + private int nearestIndex; + + public PeriodTracker(IEnumerable<Period> periods) + { + this.periods = periods.OrderBy(period => period.Start).ToList(); + } + + /// <summary> + /// Whether the provided time is in any of the added periods. + /// </summary> + /// <param name="time">The time value to check.</param> + public bool IsInAny(double time) + { + if (periods.Count == 0) + return false; + + if (time > periods[nearestIndex].End) + { + while (time > periods[nearestIndex].End && nearestIndex < periods.Count - 1) + nearestIndex++; + } + else + { + while (time < periods[nearestIndex].Start && nearestIndex > 0) + nearestIndex--; + } + + var nearest = periods[nearestIndex]; + return time >= nearest.Start && time <= nearest.End; + } + } + + public readonly struct Period + { + /// <summary> + /// The start time of this period. + /// </summary> + public readonly double Start; + + /// <summary> + /// The end time of this period. + /// </summary> + public readonly double End; + + public Period(double start, double end) + { + if (start >= end) + throw new ArgumentException($"Invalid period provided, {nameof(start)} must be less than {nameof(end)}"); + + Start = start; + End = end; + } + } +} diff --git a/osu.Game/Utils/RavenLogger.cs b/osu.Game/Utils/RavenLogger.cs deleted file mode 100644 index 7f4faa60ae..0000000000 --- a/osu.Game/Utils/RavenLogger.cs +++ /dev/null @@ -1,100 +0,0 @@ -// 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. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using osu.Framework.Logging; -using SharpRaven; -using SharpRaven.Data; - -namespace osu.Game.Utils -{ - /// <summary> - /// Report errors to sentry. - /// </summary> - public class RavenLogger : IDisposable - { - private readonly RavenClient raven = new RavenClient("https://5e342cd55f294edebdc9ad604d28bbd3@sentry.io/1255255"); - - private readonly List<Task> tasks = new List<Task>(); - - public RavenLogger(OsuGame game) - { - raven.Release = game.Version; - - if (!game.IsDeployedBuild) return; - - Exception lastException = null; - - Logger.NewEntry += entry => - { - if (entry.Level < LogLevel.Verbose) return; - - var exception = entry.Exception; - - if (exception != null) - { - if (exception is IOException ioe) - { - // disk full exceptions, see https://stackoverflow.com/a/9294382 - const int hr_error_handle_disk_full = unchecked((int)0x80070027); - const int hr_error_disk_full = unchecked((int)0x80070070); - - if (ioe.HResult == hr_error_handle_disk_full || ioe.HResult == hr_error_disk_full) - return; - } - - // since we let unhandled exceptions go ignored at times, we want to ensure they don't get submitted on subsequent reports. - if (lastException != null && - lastException.Message == exception.Message && exception.StackTrace.StartsWith(lastException.StackTrace)) - { - return; - } - - lastException = exception; - queuePendingTask(raven.CaptureAsync(new SentryEvent(exception))); - } - else - raven.AddTrail(new Breadcrumb(entry.Target.ToString(), BreadcrumbType.Navigation) { Message = entry.Message }); - }; - } - - private void queuePendingTask(Task<string> task) - { - lock (tasks) tasks.Add(task); - task.ContinueWith(_ => - { - lock (tasks) - tasks.Remove(task); - }); - } - - #region Disposal - - ~RavenLogger() - { - Dispose(false); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - private bool isDisposed; - - protected virtual void Dispose(bool isDisposing) - { - if (isDisposed) - return; - - isDisposed = true; - lock (tasks) Task.WaitAll(tasks.ToArray(), 5000); - } - - #endregion - } -} diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs new file mode 100644 index 0000000000..e8e41cdbbe --- /dev/null +++ b/osu.Game/Utils/SentryLogger.cs @@ -0,0 +1,115 @@ +// 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. + +using System; +using System.IO; +using System.Net; +using osu.Framework.Logging; +using Sentry; + +namespace osu.Game.Utils +{ + /// <summary> + /// Report errors to sentry. + /// </summary> + public class SentryLogger : IDisposable + { + private SentryClient sentry; + private Scope sentryScope; + + public SentryLogger(OsuGame game) + { + if (!game.IsDeployedBuild) return; + + var options = new SentryOptions + { + Dsn = new Dsn("https://5e342cd55f294edebdc9ad604d28bbd3@sentry.io/1255255"), + Release = game.Version + }; + + sentry = new SentryClient(options); + sentryScope = new Scope(options); + + Exception lastException = null; + + Logger.NewEntry += entry => + { + if (entry.Level < LogLevel.Verbose) return; + + var exception = entry.Exception; + + if (exception != null) + { + if (!shouldSubmitException(exception)) + return; + + // since we let unhandled exceptions go ignored at times, we want to ensure they don't get submitted on subsequent reports. + if (lastException != null && + lastException.Message == exception.Message && exception.StackTrace.StartsWith(lastException.StackTrace, StringComparison.Ordinal)) + return; + + lastException = exception; + sentry.CaptureEvent(new SentryEvent(exception) { Message = entry.Message }, sentryScope); + } + else + sentryScope.AddBreadcrumb(DateTimeOffset.Now, entry.Message, entry.Target.ToString(), "navigation"); + }; + } + + private bool shouldSubmitException(Exception exception) + { + switch (exception) + { + case IOException ioe: + // disk full exceptions, see https://stackoverflow.com/a/9294382 + const int hr_error_handle_disk_full = unchecked((int)0x80070027); + const int hr_error_disk_full = unchecked((int)0x80070070); + + if (ioe.HResult == hr_error_handle_disk_full || ioe.HResult == hr_error_disk_full) + return false; + + break; + + case WebException we: + switch (we.Status) + { + // more statuses may need to be blocked as we come across them. + case WebExceptionStatus.Timeout: + return false; + } + + break; + } + + return true; + } + + #region Disposal + + ~SentryLogger() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private bool isDisposed; + + protected virtual void Dispose(bool isDisposing) + { + if (isDisposed) + return; + + isDisposed = true; + sentry?.Dispose(); + sentry = null; + sentryScope = null; + } + + #endregion + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 0b2baa982b..8f0cc58594 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -1,24 +1,36 @@ <Project Sdk="Microsoft.NET.Sdk"> - <Import Project="..\osu.Game.props" /> <PropertyGroup Label="Project"> - <TargetFramework>netstandard2.0</TargetFramework> + <TargetFramework>netstandard2.1</TargetFramework> <OutputType>Library</OutputType> - <PlatformTarget>AnyCPU</PlatformTarget> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> </PropertyGroup> - <ItemGroup Label="Service"> - <Service Include="{82a7f48d-3b50-4b1e-b82e-3ada8210c358}" /> + <PropertyGroup Label="Nuget"> + <Title>osu! + ppy.osu.Game + 0.0.0 + icon.png + true + + + + True + icon.png + - - - - - - - + + + + + + + + + + + + - - + diff --git a/osu.TestProject.props b/osu.TestProject.props index a5c70f4edc..7e87bc5414 100644 --- a/osu.TestProject.props +++ b/osu.TestProject.props @@ -1,16 +1,12 @@ - osu.Game.Tests.VisualTestRunner - - - - + - + VisualTestRunner.cs diff --git a/osu.iOS.props b/osu.iOS.props index 55d1afa645..f766e0ec03 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -1,116 +1,98 @@ + 8.0 {FEACFBD2-3405-455C-9665-78FE426C6842};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} Resources PackageReference - --nolinkaway -gcc_flags "-lstdc++ -framework AudioToolbox -framework SystemConfiguration -framework CFNetwork -framework Accelerate + --nolinkaway + -lstdc++ -lbz2 -framework AudioToolbox -framework AVFoundation -framework CoreMedia -framework VideoToolbox -framework SystemConfiguration -framework CFNetwork -framework Accelerate + bin\$(Platform)\$(Configuration) + cjk,mideast,other,rare,west + false + $(DefaultMtouchExtraArgs) -gcc_flags "$(DefaultMtouchGccFlags)" + NSUrlSessionHandler + + iPhone Developer + true + + + true + full + false + DEBUG;ENABLE_TEST_CLOUD; + true + true + + + pdbonly + true + + + x86_64 + None + + + true + SdkOnly + ARM64 + Entitlements.plist - true - full - false - bin\iPhoneSimulator\Debug - DEBUG;ENABLE_TEST_CLOUD; - prompt - 4 - iPhone Developer - true - true true 25823 - None - x86_64 - NSUrlSessionHandler false - - Default - $(DefaultMtouchExtraArgs) -force_load $(OutputPath)\libbass.a -force_load $(OutputPath)\libbass_fx.a" - false - cjk,mideast,other,rare,west - - - pdbonly - true - bin\iPhone\Release - - prompt - 4 - iPhone Distribution - true - true - Entitlements.plist - SdkOnly - ARM64 - NSUrlSessionHandler - - Default - $(DefaultMtouchExtraArgs) -force_load $(OutputPath)\libbass.a -force_load $(OutputPath)\libbass_fx.a" - false - cjk,mideast,other,rare,west - pdbonly - true - bin\iPhoneSimulator\Release - - prompt - 4 - iPhone Developer true - None - x86_64 - NSUrlSessionHandler - - Default - $(DefaultMtouchExtraArgs) -force_load $(OutputPath)\libbass.a -force_load $(OutputPath)\libbass_fx.a" - false - cjk,mideast,other,rare,west - true - full - false - bin\iPhone\Debug - DEBUG;ENABLE_TEST_CLOUD; - prompt - 4 - iPhone Developer true - true - true - true - Entitlements.plist 28126 - SdkOnly - ARM64 - NSUrlSessionHandler - - Default - $(DefaultMtouchExtraArgs) -force_load $(OutputPath)\libbass.a -force_load $(OutputPath)\libbass_fx.a" - false - cjk,mideast,other,rare,west + + + Static + False + True + + + Static + False + True + + - - - - - - - - - - + + + + + + $(NoWarn);NU1605 + + + + + + + + + + + + + + + - - + + diff --git a/osu.iOS.sln b/osu.iOS.sln deleted file mode 100644 index 21d02d33ab..0000000000 --- a/osu.iOS.sln +++ /dev/null @@ -1,187 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27004.2006 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game", "osu.Game\osu.Game.csproj", "{2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Osu", "osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj", "{C92A607B-1FDD-4954-9F92-03FF547D9080}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Catch", "osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj", "{58F6C80C-1253-4A0E-A465-B8C85EBEADF3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Taiko", "osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj", "{F167E17A-7DE6-4AF5-B920-A5112296C695}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Mania", "osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj", "{48F4582B-7687-4621-9CBE-5C24197CB536}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.iOS", "osu.iOS\osu.iOS.csproj", "{3F082D0B-A964-43D7-BDF7-C256D76A50D0}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Tests.iOS", "osu.Game.Tests.iOS\osu.Game.Tests.iOS.csproj", "{65FF8E19-6934-469B-B690-23C6D6E56A17}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Taiko.Tests.iOS", "osu.Game.Rulesets.Taiko.Tests.iOS\osu.Game.Rulesets.Taiko.Tests.iOS.csproj", "{7E408809-66AC-49D1-AF4D-98834F9B979A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Osu.Tests.iOS", "osu.Game.Rulesets.Osu.Tests.iOS\osu.Game.Rulesets.Osu.Tests.iOS.csproj", "{6653CA6F-DB06-4604-A3FD-762E25C2AF96}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Mania.Tests.iOS", "osu.Game.Rulesets.Mania.Tests.iOS\osu.Game.Rulesets.Mania.Tests.iOS.csproj", "{39FD990E-B6CE-4B2A-999F-BC008CF2C64C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Catch.Tests.iOS", "osu.Game.Rulesets.Catch.Tests.iOS\osu.Game.Rulesets.Catch.Tests.iOS.csproj", "{4004C7B7-1A62-43F1-9DF2-52450FA67E70}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - Debug|iPhoneSimulator = Debug|iPhoneSimulator - Release|iPhone = Release|iPhone - Release|iPhoneSimulator = Release|iPhoneSimulator - Debug|iPhone = Debug|iPhone - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.Build.0 = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhone.ActiveCfg = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhone.Build.0 = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhone.Build.0 = Debug|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Release|Any CPU.Build.0 = Release|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Release|iPhone.ActiveCfg = Release|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Release|iPhone.Build.0 = Release|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {D9A367C9-4C1A-489F-9B05-A0CEA2B53B58}.Debug|iPhone.Build.0 = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.Build.0 = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhone.ActiveCfg = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhone.Build.0 = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhone.Build.0 = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.Build.0 = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhone.ActiveCfg = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhone.Build.0 = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhone.Build.0 = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.Build.0 = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhone.ActiveCfg = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhone.Build.0 = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhone.Build.0 = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|Any CPU.Build.0 = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.ActiveCfg = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.Build.0 = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhone.ActiveCfg = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhone.Build.0 = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhoneSimulator.Build.0 = Release|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhone.ActiveCfg = Debug|Any CPU - {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhone.Build.0 = Debug|Any CPU - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|Any CPU.ActiveCfg = Release|iPhone - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhone.ActiveCfg = Release|iPhone - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhone.Build.0 = Release|iPhone - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhone.ActiveCfg = Debug|iPhone - {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhone.Build.0 = Debug|iPhone - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|Any CPU.ActiveCfg = Release|iPhone - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhone.ActiveCfg = Release|iPhone - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhone.Build.0 = Release|iPhone - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhone.ActiveCfg = Debug|iPhone - {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhone.Build.0 = Debug|iPhone - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|Any CPU.ActiveCfg = Release|iPhone - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhone.ActiveCfg = Release|iPhone - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhone.Build.0 = Release|iPhone - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhone.ActiveCfg = Debug|iPhone - {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhone.Build.0 = Debug|iPhone - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|Any CPU.ActiveCfg = Release|iPhone - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhone.ActiveCfg = Release|iPhone - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhone.Build.0 = Release|iPhone - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhone.ActiveCfg = Debug|iPhone - {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhone.Build.0 = Debug|iPhone - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|Any CPU.ActiveCfg = Release|iPhone - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhone.ActiveCfg = Release|iPhone - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhone.Build.0 = Release|iPhone - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhone.ActiveCfg = Debug|iPhone - {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhone.Build.0 = Debug|iPhone - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|Any CPU.ActiveCfg = Release|iPhone - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhone.ActiveCfg = Release|iPhone - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhone.Build.0 = Release|iPhone - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhone.ActiveCfg = Debug|iPhone - {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhone.Build.0 = Debug|iPhone - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {671B0BEC-2403-45B0-9357-2C97CC517668} - EndGlobalSection - GlobalSection(MonoDevelopProperties) = preSolution - Policies = $0 - $0.TextStylePolicy = $1 - $1.EolMarker = Windows - $1.scope = text/x-csharp - $1.FileWidth = 80 - $1.TabsToSpaces = True - $0.CSharpFormattingPolicy = $2 - $2.scope = text/x-csharp - EndGlobalSection -EndGlobal diff --git a/osu.iOS.sln.DotSettings b/osu.iOS.sln.DotSettings deleted file mode 100644 index 3b2b851d45..0000000000 --- a/osu.iOS.sln.DotSettings +++ /dev/null @@ -1,816 +0,0 @@ - - True - True - True - True - ExplicitlyExcluded - ExplicitlyExcluded - SOLUTION - HINT - WARNING - - True - WARNING - WARNING - HINT - HINT - HINT - HINT - WARNING - WARNING - WARNING - HINT - WARNING - HINT - SUGGESTION - HINT - HINT - HINT - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - HINT - WARNING - WARNING - HINT - WARNING - WARNING - DO_NOT_SHOW - HINT - WARNING - DO_NOT_SHOW - WARNING - HINT - HINT - HINT - ERROR - HINT - HINT - HINT - WARNING - WARNING - HINT - DO_NOT_SHOW - HINT - HINT - HINT - HINT - WARNING - WARNING - WARNING - WARNING - WARNING - HINT - HINT - HINT - HINT - HINT - WARNING - WARNING - WARNING - WARNING - WARNING - HINT - DO_NOT_SHOW - DO_NOT_SHOW - DO_NOT_SHOW - 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 - HINT - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - HINT - DO_NOT_SHOW - DO_NOT_SHOW - DO_NOT_SHOW - WARNING - WARNING - HINT - WARNING - HINT - HINT - HINT - HINT - HINT - HINT - HINT - - HINT - WARNING - WARNING - WARNING - WARNING - WARNING - WARNING - HINT - WARNING - WARNING - HINT - HINT - 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) - True - True - True - True - True - True - True - True - NEXT_LINE - NEXT_LINE - True - NEVER - NEVER - False - NEVER - False - True - False - False - True - True - False - CHOP_IF_LONG - True - 200 - CHOP_IF_LONG - False - False - AABB - API - BPM - GC - GL - GLSL - HID - HUD - ID - IP - IPC - LTRB - MD5 - NS - OS - 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 - 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 diff --git a/osu.iOS.slnf b/osu.iOS.slnf new file mode 100644 index 0000000000..48b1a095a1 --- /dev/null +++ b/osu.iOS.slnf @@ -0,0 +1,19 @@ +{ + "solution": { + "path": "osu.sln", + "projects": [ + "osu.Game.Rulesets.Catch.Tests.iOS\\osu.Game.Rulesets.Catch.Tests.iOS.csproj", + "osu.Game.Rulesets.Catch\\osu.Game.Rulesets.Catch.csproj", + "osu.Game.Rulesets.Mania.Tests.iOS\\osu.Game.Rulesets.Mania.Tests.iOS.csproj", + "osu.Game.Rulesets.Mania\\osu.Game.Rulesets.Mania.csproj", + "osu.Game.Rulesets.Osu.Tests.iOS\\osu.Game.Rulesets.Osu.Tests.iOS.csproj", + "osu.Game.Rulesets.Osu\\osu.Game.Rulesets.Osu.csproj", + "osu.Game.Rulesets.Taiko.Tests.iOS\\osu.Game.Rulesets.Taiko.Tests.iOS.csproj", + "osu.Game.Rulesets.Taiko\\osu.Game.Rulesets.Taiko.csproj", + "osu.Game.Tests.iOS\\osu.Game.Tests.iOS.csproj", + "osu.Game.Tests\\osu.Game.Tests.csproj", + "osu.Game\\osu.Game.csproj", + "osu.iOS\\osu.iOS.csproj" + ] + } +} \ No newline at end of file diff --git a/osu.iOS/AppDelegate.cs b/osu.iOS/AppDelegate.cs index 9ef21e014c..14e3627752 100644 --- a/osu.iOS/AppDelegate.cs +++ b/osu.iOS/AppDelegate.cs @@ -4,7 +4,6 @@ using System.Threading.Tasks; using Foundation; using osu.Framework.iOS; -using osu.Game; using UIKit; namespace osu.iOS @@ -16,9 +15,12 @@ namespace osu.iOS protected override Framework.Game CreateGame() => game = new OsuGameIOS(); - public override bool OpenUrl(UIApplication application, NSUrl url, string sourceApplication, NSObject annotation) + public override bool OpenUrl(UIApplication app, NSUrl url, NSDictionary options) { - Task.Run(() => game.Import(url.Path)); + if (url.IsFileUrl) + Task.Run(() => game.Import(url.Path)); + else + Task.Run(() => game.HandleLink(url.AbsoluteString)); return true; } } diff --git a/osu.iOS/Application.cs b/osu.iOS/Application.cs index 30e0e15ad1..740937e0e1 100644 --- a/osu.iOS/Application.cs +++ b/osu.iOS/Application.cs @@ -5,7 +5,7 @@ using UIKit; namespace osu.iOS { - public class Application + public static class Application { public static void Main(string[] args) { diff --git a/osu.iOS/Info.plist b/osu.iOS/Info.plist index 0775d1522d..249474b1d7 100644 --- a/osu.iOS/Info.plist +++ b/osu.iOS/Info.plist @@ -21,6 +21,8 @@ 1 2 + UIFileSharingEnabled + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities @@ -31,6 +33,10 @@ UIStatusBarHidden + NSCameraUsageDescription + We don't really use the camera. + NSMicrophoneUsageDescription + We don't really use the microphone. UISupportedInterfaceOrientations UIInterfaceOrientationPortrait @@ -45,7 +51,7 @@ UTTypeConformsTo - + public.data UTTypeIdentifier sh.ppy.osu.items @@ -99,11 +105,25 @@ Owner CFBundleTypeName Supported osu! files + CFBundleTypeRole + Viewer LSItemContentTypes sh.ppy.osu.items + CFBundleURLTypes + + + CFBundleURLSchemes + + osu + osump + + CFBundleTypeRole + Editor + + diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index 6cf18df9a6..5125ad81e0 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -4,11 +4,14 @@ using System; using Foundation; using osu.Game; +using osu.Game.Updater; namespace osu.iOS { public class OsuGameIOS : OsuGame { public override Version AssemblyVersion => new Version(NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString()); + + protected override UpdateManager CreateUpdateManager() => new SimpleUpdateManager(); } } diff --git a/osu.iOS/libbass.a b/osu.iOS/libbass.a deleted file mode 100644 index c34e6a0a0c..0000000000 Binary files a/osu.iOS/libbass.a and /dev/null differ diff --git a/osu.iOS/libbass_fx.a b/osu.iOS/libbass_fx.a deleted file mode 100644 index 007cd28647..0000000000 Binary files a/osu.iOS/libbass_fx.a and /dev/null differ diff --git a/osu.iOS/osu.iOS.csproj b/osu.iOS/osu.iOS.csproj index 12505e73e4..1e9a21865d 100644 --- a/osu.iOS/osu.iOS.csproj +++ b/osu.iOS/osu.iOS.csproj @@ -1,6 +1,5 @@ - + - Debug iPhoneSimulator @@ -8,6 +7,7 @@ {3F082D0B-A964-43D7-BDF7-C256D76A50D0} osu.iOS osu.iOS + false @@ -19,12 +19,6 @@ - - PreserveNewest - - - PreserveNewest - @@ -61,27 +55,66 @@ - - - - - - - - - - - - - - - - - - - - + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + + + false + - - \ No newline at end of file + diff --git a/osu.sln b/osu.sln index 688339fab5..1d64f6ff10 100644 --- a/osu.sln +++ b/osu.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27004.2006 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29424.173 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game", "osu.Game\osu.Game.csproj", "{2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}" EndProject @@ -25,68 +25,394 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.Taiko.Tes EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Rulesets.Osu.Tests", "osu.Game.Rulesets.Osu.Tests\osu.Game.Rulesets.Osu.Tests.csproj", "{6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Tournament", "osu.Game.Tournament\osu.Game.Tournament.csproj", "{5672CA4D-1B37-425B-A118-A8DA26E78938}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Tournament", "osu.Game.Tournament\osu.Game.Tournament.csproj", "{5672CA4D-1B37-425B-A118-A8DA26E78938}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Tournament.Tests", "osu.Game.Tournament.Tests\osu.Game.Tournament.Tests.csproj", "{5789E78D-38F9-4072-AB7B-978F34B2C17F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Tournament.Tests", "osu.Game.Tournament.Tests\osu.Game.Tournament.Tests.csproj", "{5789E78D-38F9-4072-AB7B-978F34B2C17F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.iOS", "osu.iOS\osu.iOS.csproj", "{3F082D0B-A964-43D7-BDF7-C256D76A50D0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Tests.iOS", "osu.Game.Tests.iOS\osu.Game.Tests.iOS.csproj", "{65FF8E19-6934-469B-B690-23C6D6E56A17}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Taiko.Tests.iOS", "osu.Game.Rulesets.Taiko.Tests.iOS\osu.Game.Rulesets.Taiko.Tests.iOS.csproj", "{7E408809-66AC-49D1-AF4D-98834F9B979A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Osu.Tests.iOS", "osu.Game.Rulesets.Osu.Tests.iOS\osu.Game.Rulesets.Osu.Tests.iOS.csproj", "{6653CA6F-DB06-4604-A3FD-762E25C2AF96}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Mania.Tests.iOS", "osu.Game.Rulesets.Mania.Tests.iOS\osu.Game.Rulesets.Mania.Tests.iOS.csproj", "{39FD990E-B6CE-4B2A-999F-BC008CF2C64C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Catch.Tests.iOS", "osu.Game.Rulesets.Catch.Tests.iOS\osu.Game.Rulesets.Catch.Tests.iOS.csproj", "{4004C7B7-1A62-43F1-9DF2-52450FA67E70}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Android", "osu.Android\osu.Android.csproj", "{D1D5F9A8-B40B-40E6-B02F-482D03346D3D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Catch.Tests.Android", "osu.Game.Rulesets.Catch.Tests.Android\osu.Game.Rulesets.Catch.Tests.Android.csproj", "{C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Mania.Tests.Android", "osu.Game.Rulesets.Mania.Tests.Android\osu.Game.Rulesets.Mania.Tests.Android.csproj", "{531F1092-DB27-445D-AA33-2A77C7187C99}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Osu.Tests.Android", "osu.Game.Rulesets.Osu.Tests.Android\osu.Game.Rulesets.Osu.Tests.Android.csproj", "{90CAB706-39CB-4B93-9629-3218A6FF8E9B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Rulesets.Taiko.Tests.Android", "osu.Game.Rulesets.Taiko.Tests.Android\osu.Game.Rulesets.Taiko.Tests.Android.csproj", "{3701A0A1-8476-42C6-B5C4-D24129B4A484}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Game.Tests.Android", "osu.Game.Tests.Android\osu.Game.Tests.Android.csproj", "{5CC222DC-5716-4499-B897-DCBDDA4A5CF9}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{10DF8F12-50FD-45D8-8A38-17BA764BF54D}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + Directory.Build.props = Directory.Build.props + global.json = global.json + osu.Android.props = osu.Android.props + osu.iOS.props = osu.iOS.props + CodeAnalysis\osu.ruleset = CodeAnalysis\osu.ruleset + osu.sln.DotSettings = osu.sln.DotSettings + osu.TestProject.props = osu.TestProject.props + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "osu.Game.Benchmarks", "osu.Game.Benchmarks\osu.Game.Benchmarks.csproj", "{93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|iPhone = Debug|iPhone + Debug|iPhoneSimulator = Debug|iPhoneSimulator Release|Any CPU = Release|Any CPU + Release|iPhone = Release|iPhone + Release|iPhoneSimulator = Release|iPhoneSimulator EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhone.Build.0 = Debug|Any CPU + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|Any CPU.Build.0 = Release|Any CPU + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhone.ActiveCfg = Release|Any CPU + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhone.Build.0 = Release|Any CPU + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {2A66DD92-ADB1-4994-89E2-C94E04ACDA0D}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhone.Build.0 = Debug|Any CPU + {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {C92A607B-1FDD-4954-9F92-03FF547D9080}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.ActiveCfg = Release|Any CPU {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|Any CPU.Build.0 = Release|Any CPU + {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhone.ActiveCfg = Release|Any CPU + {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhone.Build.0 = Release|Any CPU + {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {C92A607B-1FDD-4954-9F92-03FF547D9080}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhone.Build.0 = Debug|Any CPU + {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.ActiveCfg = Release|Any CPU {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|Any CPU.Build.0 = Release|Any CPU + {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhone.ActiveCfg = Release|Any CPU + {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhone.Build.0 = Release|Any CPU + {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {58F6C80C-1253-4A0E-A465-B8C85EBEADF3}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhone.Build.0 = Debug|Any CPU + {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {F167E17A-7DE6-4AF5-B920-A5112296C695}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.ActiveCfg = Release|Any CPU {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|Any CPU.Build.0 = Release|Any CPU + {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhone.ActiveCfg = Release|Any CPU + {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhone.Build.0 = Release|Any CPU + {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {F167E17A-7DE6-4AF5-B920-A5112296C695}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|Any CPU.Build.0 = Debug|Any CPU + {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhone.Build.0 = Debug|Any CPU + {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {48F4582B-7687-4621-9CBE-5C24197CB536}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.ActiveCfg = Release|Any CPU {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|Any CPU.Build.0 = Release|Any CPU + {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhone.ActiveCfg = Release|Any CPU + {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhone.Build.0 = Release|Any CPU + {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {48F4582B-7687-4621-9CBE-5C24197CB536}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|iPhone.Build.0 = Debug|Any CPU + {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {54377672-20B1-40AF-8087-5CF73BF3953A}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|Any CPU.ActiveCfg = Release|Any CPU {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|Any CPU.Build.0 = Release|Any CPU + {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|iPhone.ActiveCfg = Release|Any CPU + {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|iPhone.Build.0 = Release|Any CPU + {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {54377672-20B1-40AF-8087-5CF73BF3953A}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|iPhone.Build.0 = Debug|Any CPU + {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {419659FD-72EA-4678-9EB8-B22A746CED70}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|Any CPU.ActiveCfg = Release|Any CPU {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|Any CPU.Build.0 = Release|Any CPU + {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|iPhone.ActiveCfg = Release|Any CPU + {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|iPhone.Build.0 = Release|Any CPU + {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {419659FD-72EA-4678-9EB8-B22A746CED70}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|iPhone.Build.0 = Debug|Any CPU + {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|Any CPU.ActiveCfg = Release|Any CPU {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|Any CPU.Build.0 = Release|Any CPU + {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|iPhone.ActiveCfg = Release|Any CPU + {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|iPhone.Build.0 = Release|Any CPU + {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {3AD63355-D6B1-4365-8D31-5652C989BEF1}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|iPhone.Build.0 = Debug|Any CPU + {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7E9E9C34-B204-406B-82E2-E01E900699CD}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|Any CPU.ActiveCfg = Release|Any CPU {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|Any CPU.Build.0 = Release|Any CPU + {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|iPhone.ActiveCfg = Release|Any CPU + {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|iPhone.Build.0 = Release|Any CPU + {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {7E9E9C34-B204-406B-82E2-E01E900699CD}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|iPhone.Build.0 = Debug|Any CPU + {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|Any CPU.ActiveCfg = Release|Any CPU {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|Any CPU.Build.0 = Release|Any CPU + {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|iPhone.ActiveCfg = Release|Any CPU + {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|iPhone.Build.0 = Release|Any CPU + {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {B698561F-FB28-46B1-857E-3CA7B92F9D70}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|iPhone.Build.0 = Debug|Any CPU + {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|Any CPU.Build.0 = Release|Any CPU + {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|iPhone.ActiveCfg = Release|Any CPU + {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|iPhone.Build.0 = Release|Any CPU + {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {6A2D5D58-0261-4A75-BE84-2BE8B076B7C2}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|iPhone.Build.0 = Debug|Any CPU + {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {5672CA4D-1B37-425B-A118-A8DA26E78938}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|Any CPU.ActiveCfg = Release|Any CPU {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|Any CPU.Build.0 = Release|Any CPU + {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|iPhone.ActiveCfg = Release|Any CPU + {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|iPhone.Build.0 = Release|Any CPU + {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {5672CA4D-1B37-425B-A118-A8DA26E78938}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|iPhone.Build.0 = Debug|Any CPU + {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|Any CPU.ActiveCfg = Release|Any CPU {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|Any CPU.Build.0 = Release|Any CPU + {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|iPhone.ActiveCfg = Release|Any CPU + {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|iPhone.Build.0 = Release|Any CPU + {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {5789E78D-38F9-4072-AB7B-978F34B2C17F}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhone.ActiveCfg = Debug|iPhone + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhone.Build.0 = Debug|iPhone + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|Any CPU.ActiveCfg = Release|iPhone + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhone.ActiveCfg = Release|iPhone + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhone.Build.0 = Release|iPhone + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator + {3F082D0B-A964-43D7-BDF7-C256D76A50D0}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhone.ActiveCfg = Debug|iPhone + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhone.Build.0 = Debug|iPhone + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|Any CPU.ActiveCfg = Release|iPhone + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhone.ActiveCfg = Release|iPhone + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhone.Build.0 = Release|iPhone + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator + {65FF8E19-6934-469B-B690-23C6D6E56A17}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhone.ActiveCfg = Debug|iPhone + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhone.Build.0 = Debug|iPhone + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|Any CPU.ActiveCfg = Release|iPhone + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhone.ActiveCfg = Release|iPhone + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhone.Build.0 = Release|iPhone + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator + {7E408809-66AC-49D1-AF4D-98834F9B979A}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhone.ActiveCfg = Debug|iPhone + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhone.Build.0 = Debug|iPhone + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|Any CPU.ActiveCfg = Release|iPhone + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhone.ActiveCfg = Release|iPhone + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhone.Build.0 = Release|iPhone + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator + {6653CA6F-DB06-4604-A3FD-762E25C2AF96}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhone.ActiveCfg = Debug|iPhone + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhone.Build.0 = Debug|iPhone + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|Any CPU.ActiveCfg = Release|iPhone + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhone.ActiveCfg = Release|iPhone + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhone.Build.0 = Release|iPhone + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator + {39FD990E-B6CE-4B2A-999F-BC008CF2C64C}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|Any CPU.ActiveCfg = Debug|iPhoneSimulator + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhone.ActiveCfg = Debug|iPhone + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhone.Build.0 = Debug|iPhone + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhoneSimulator.ActiveCfg = Debug|iPhoneSimulator + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Debug|iPhoneSimulator.Build.0 = Debug|iPhoneSimulator + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|Any CPU.ActiveCfg = Release|iPhone + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhone.ActiveCfg = Release|iPhone + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhone.Build.0 = Release|iPhone + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator + {4004C7B7-1A62-43F1-9DF2-52450FA67E70}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhone.Build.0 = Debug|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhone.Deploy.0 = Debug|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.Build.0 = Release|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|Any CPU.Deploy.0 = Release|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhone.ActiveCfg = Release|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhone.Build.0 = Release|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhone.Deploy.0 = Release|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {D1D5F9A8-B40B-40E6-B02F-482D03346D3D}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhone.Build.0 = Debug|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhone.Deploy.0 = Debug|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.Build.0 = Release|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|Any CPU.Deploy.0 = Release|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhone.ActiveCfg = Release|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhone.Build.0 = Release|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhone.Deploy.0 = Release|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {C5379ECB-3A94-4D2F-AC3B-2615AC23EB0D}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.Build.0 = Debug|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhone.Build.0 = Debug|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhone.Deploy.0 = Debug|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.ActiveCfg = Release|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.Build.0 = Release|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|Any CPU.Deploy.0 = Release|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhone.ActiveCfg = Release|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhone.Build.0 = Release|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhone.Deploy.0 = Release|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {531F1092-DB27-445D-AA33-2A77C7187C99}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhone.Build.0 = Debug|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhone.Deploy.0 = Debug|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.Build.0 = Release|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|Any CPU.Deploy.0 = Release|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhone.ActiveCfg = Release|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhone.Build.0 = Release|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhone.Deploy.0 = Release|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {90CAB706-39CB-4B93-9629-3218A6FF8E9B}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhone.Build.0 = Debug|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhone.Deploy.0 = Debug|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.Build.0 = Release|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|Any CPU.Deploy.0 = Release|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhone.ActiveCfg = Release|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhone.Build.0 = Release|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhone.Deploy.0 = Release|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {3701A0A1-8476-42C6-B5C4-D24129B4A484}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhone.Build.0 = Debug|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhone.Deploy.0 = Debug|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Debug|iPhoneSimulator.Deploy.0 = Debug|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.Build.0 = Release|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|Any CPU.Deploy.0 = Release|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhone.ActiveCfg = Release|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhone.Build.0 = Release|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhone.Deploy.0 = Release|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {5CC222DC-5716-4499-B897-DCBDDA4A5CF9}.Release|iPhoneSimulator.Deploy.0 = Release|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|Any CPU.Build.0 = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhone.Build.0 = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|Any CPU.ActiveCfg = Release|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|Any CPU.Build.0 = Release|Any CPU + {93632F2D-2BB4-46C1-A7B8-F8CF2FB27118}.Release|iPhone.ActiveCfg = Release|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index c3e274569d..3ef419c572 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -12,14 +12,15 @@ HINT HINT WARNING - + WARNING True WARNING WARNING HINT + DO_NOT_SHOW HINT - HINT - HINT + WARNING + WARNING WARNING WARNING WARNING @@ -59,22 +60,35 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING WARNING - HINT + 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 @@ -92,6 +106,8 @@ HINT WARNING WARNING + WARNING + WARNING WARNING WARNING WARNING @@ -107,6 +123,7 @@ HINT HINT HINT + DO_NOT_SHOW HINT HINT WARNING @@ -121,10 +138,11 @@ DO_NOT_SHOW DO_NOT_SHOW WARNING - WARNING WARNING WARNING + WARNING + WARNING WARNING WARNING ERROR @@ -171,7 +189,7 @@ WARNING WARNING WARNING - HINT + WARNING WARNING WARNING WARNING @@ -181,7 +199,10 @@ WARNING WARNING WARNING + WARNING HINT + WARNING + WARNING DO_NOT_SHOW DO_NOT_SHOW DO_NOT_SHOW @@ -199,12 +220,16 @@ HINT HINT HINT - HINT + HINT + HINT + DO_NOT_SHOW WARNING WARNING WARNING + WARNING WARNING + WARNING True WARNING @@ -218,9 +243,19 @@ 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 - ExpressionBody + BlockBody True + NEXT_LINE True True True @@ -232,14 +267,24 @@ 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 @@ -247,6 +292,7 @@ True True False + False CHOP_IF_LONG True 200 @@ -260,9 +306,11 @@ GL GLSL HID + HTML HUD ID IL + IOS IP IPC JIT @@ -276,397 +324,397 @@ SHA SRGB TK - SS - PP - GMT - QAT - BNG + 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> + <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. @@ -726,6 +774,8 @@ See the LICENCE file in the repository root for full licence text. <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 @@ -736,6 +786,7 @@ See the LICENCE file in the repository root for full licence text. True True True + TestFolder True True o!f – Object Initializer: Anchor&Origin @@ -761,7 +812,7 @@ Origin = Anchor.$anchor$, True InternalChildren = new Drawable[] { - $END$ + $END$ }; True True @@ -774,12 +825,12 @@ Origin = Anchor.$anchor$, True new GridContainer { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] { $END$ }, - new Drawable[] { } - } + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] { $END$ }, + new Drawable[] { } + } }; True True @@ -792,12 +843,12 @@ Origin = Anchor.$anchor$, True new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - $END$ - } + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + $END$ + } }, True True @@ -810,11 +861,11 @@ Origin = Anchor.$anchor$, True new Container { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - $END$ - } + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + $END$ + } }, True True @@ -828,7 +879,7 @@ Origin = Anchor.$anchor$, [BackgroundDependencyLoader] private void load() { - $END$ + $END$ } True True @@ -841,8 +892,8 @@ private void load() True new Box { - Colour = Color4.Black, - RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, }, True True @@ -855,23 +906,27 @@ private void load() True Children = new Drawable[] { - $END$ + $END$ }; True True True True + True True True True + True True True True True True True + True True True True + True True True