diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index b51ecb4f7e..97fcb52ab1 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -2,12 +2,6 @@ "version": 1, "isRoot": true, "tools": { - "cake.tool": { - "version": "0.35.0", - "commands": [ - "dotnet-cake" - ] - }, "dotnet-format": { "version": "3.1.37601", "commands": [ @@ -20,20 +14,20 @@ "jb" ] }, - "nvika": { - "version": "2.0.0", + "smoogipoo.nvika": { + "version": "1.0.1", "commands": [ "nvika" ] }, "codefilesanity": { - "version": "15.0.0", + "version": "0.0.36", "commands": [ "CodeFileSanity" ] }, "ppy.localisationanalyser.tools": { - "version": "2021.524.0", + "version": "2021.705.0", "commands": [ "localisation" ] diff --git a/.editorconfig b/.editorconfig index f4d7e08d08..19bd89c52f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -157,7 +157,7 @@ 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 +csharp_style_deconstructed_variable_declaration = false:silent #Style - other C# 7.x features dotnet_style_prefer_inferred_tuple_names = true:warning @@ -168,8 +168,8 @@ 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_index_operator = false:silent +csharp_style_prefer_range_operator = false:silent csharp_style_prefer_switch_expression = false:none #Supressing roslyn built-in analyzers diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000..29cbdd2d37 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +on: [push, pull_request] +name: Continuous Integration + +jobs: + test: + name: Test + runs-on: ${{matrix.os.fullname}} + env: + OSU_EXECUTION_MODE: ${{matrix.threadingMode}} + strategy: + fail-fast: false + matrix: + os: + - { prettyname: Windows, fullname: windows-latest } + - { prettyname: macOS, fullname: macos-latest } + - { prettyname: Linux, fullname: ubuntu-latest } + threadingMode: ['SingleThread', 'MultiThreaded'] + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Install .NET 5.0.x + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "5.0.x" + + # FIXME: libavformat is not included in Ubuntu. Let's fix that. + # https://github.com/ppy/osu-framework/issues/4349 + # Remove this once https://github.com/actions/virtual-environments/issues/3306 has been resolved. + - name: Install libavformat-dev + if: ${{matrix.os.fullname == 'ubuntu-latest'}} + run: | + sudo apt-get update && \ + sudo apt-get -y install libavformat-dev + + - name: Compile + run: dotnet build -c Debug -warnaserror osu.Desktop.slnf + + - name: Test + run: dotnet test $pwd/*.Tests/bin/Debug/*/*.Tests.dll --logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx" + shell: pwsh + + # Attempt to upload results even if test fails. + # https://docs.github.com/en/actions/reference/context-and-expression-syntax-for-github-actions#always + - name: Upload Test Results + uses: actions/upload-artifact@v2 + if: ${{ always() }} + with: + name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} + path: ${{github.workspace}}/TestResults/TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx + + inspect-code: + name: Code Quality + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + # FIXME: Tools won't run in .NET 5.0 unless you install 3.1.x LTS side by side. + # https://itnext.io/how-to-support-multiple-net-sdks-in-github-actions-workflows-b988daa884e + - name: Install .NET 3.1.x LTS + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "3.1.x" + + - name: Install .NET 5.0.x + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "5.0.x" + + - name: Restore Tools + run: dotnet tool restore + + - name: Restore Packages + run: dotnet restore + + - name: CodeFileSanity + run: | + # TODO: Add ignore filters and GitHub Workflow Command Reporting in CFS. That way we don't have to do this workaround. + # FIXME: Suppress warnings from templates project + dotnet codefilesanity | while read -r line; do + echo "::warning::$line" + done + + # Temporarily disabled due to test failures, but it won't work anyway until the tool is upgraded. + # - name: .NET Format (Dry Run) + # run: dotnet format --dry-run --check + + - name: InspectCode + run: dotnet jb inspectcode $(pwd)/osu.Desktop.slnf --output=$(pwd)/inspectcodereport.xml --cachesDir=$(pwd)/inspectcode --verbosity=WARN + + - name: NVika + run: dotnet nvika parsereport "${{github.workspace}}/inspectcodereport.xml" --treatwarningsaserrors diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml new file mode 100644 index 0000000000..e0ccd50989 --- /dev/null +++ b/.github/workflows/report-nunit.yml @@ -0,0 +1,32 @@ +# This is a workaround to allow PRs to report their coverage. This will run inside the base repository. +# See: +# * https://github.com/dorny/test-reporter#recommended-setup-for-public-repositories +# * https://docs.github.com/en/actions/reference/authentication-in-a-workflow#permissions-for-the-github_token +name: Annotate CI run with test results +on: + workflow_run: + workflows: ["Continuous Integration"] + types: + - completed +jobs: + annotate: + name: Annotate CI run with test results + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion != 'cancelled' }} + strategy: + fail-fast: false + matrix: + os: + - { prettyname: Windows } + - { prettyname: macOS } + - { prettyname: Linux } + threadingMode: ['SingleThread', 'MultiThreaded'] + timeout-minutes: 5 + steps: + - name: Annotate CI run with test results + uses: dorny/test-reporter@v1.4.2 + with: + artifact: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} + name: Test Results (${{matrix.os.prettyname}}, ${{matrix.threadingMode}}) + path: "*.trx" + reporter: dotnet-trx diff --git a/.gitignore b/.gitignore index d122d25054..de6a3ac848 100644 --- a/.gitignore +++ b/.gitignore @@ -336,3 +336,6 @@ inspectcode /BenchmarkDotNet.Artifacts *.GeneratedMSBuildEditorConfig.editorconfig + +# Fody (pulled in by Realm) - schema file +FodyWeavers.xsd diff --git a/.idea/.idea.osu.Desktop/.idea/dataSources.xml b/.idea/.idea.osu.Desktop/.idea/dataSources.xml deleted file mode 100644 index 10f8c1c84d..0000000000 --- a/.idea/.idea.osu.Desktop/.idea/dataSources.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - sqlite.xerial - true - org.sqlite.JDBC - jdbc:sqlite:$USER_HOME$/.local/share/osu/client.db - - - - - - \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index afd997f91d..1b590008cd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -113,20 +113,6 @@ "cwd": "${workspaceRoot}", "preLaunchTask": "Build benchmarks", "console": "internalConsole" - }, - { - "name": "Cake: Debug Script", - "type": "coreclr", - "request": "launch", - "program": "${workspaceRoot}/build/tools/Cake.CoreCLR/0.30.0/Cake.dll", - "args": [ - "${workspaceRoot}/build/build.cake", - "--debug", - "--verbosity=diagnostic" - ], - "cwd": "${workspaceRoot}/build", - "stopAtEntry": true, - "externalConsole": false } ] } diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index 46c50dbfa2..ea3e25142c 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -3,6 +3,7 @@ M:System.Object.Equals(System.Object)~System.Boolean;Don't use object.Equals. Us 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. +T:SixLabors.ImageSharp.IDeepCloneable`1;Use osu.Game.Utils.IDeepCloneable 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. diff --git a/FodyWeavers.xml b/FodyWeavers.xml new file mode 100644 index 0000000000..ea490e3297 --- /dev/null +++ b/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/InspectCode.ps1 b/InspectCode.ps1 index 6ed935fdbb..8316f48ff3 100644 --- a/InspectCode.ps1 +++ b/InspectCode.ps1 @@ -1,27 +1,11 @@ -[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 + +# Temporarily disabled until the tool is upgraded to 5.0. + # The version specified in .config/dotnet-tools.json (3.1.37601) won't run on .NET hosts >=5.0.7. + # - cmd: dotnet format --dry-run --check + +dotnet CodeFileSanity +dotnet jb inspectcode "osu.Desktop.slnf" --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN +dotnet nvika parsereport "inspectcodereport.xml" --treatwarningsaserrors + +exit $LASTEXITCODE diff --git a/InspectCode.sh b/InspectCode.sh new file mode 100755 index 0000000000..cf2bc18175 --- /dev/null +++ b/InspectCode.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +dotnet tool restore +dotnet CodeFileSanity +dotnet jb inspectcode "osu.Desktop.slnf" --output="inspectcodereport.xml" --caches-home="inspectcode" --verbosity=WARN +dotnet nvika parsereport "inspectcodereport.xml" --treatwarningsaserrors diff --git a/README.md b/README.md index 3054f19e79..016bd7d922 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 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. +The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Currently known by and released under the codename "*lazer*". As in sharper than cutting-edge. ## Status @@ -23,7 +23,7 @@ We are accepting bug reports (please report with as much detail as possible and - 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. +- Read peppy's [blog post](https://blog.ppy.sh/a-definitive-lazer-faq/) exploring where the project is currently and the roadmap going forward. ## Running osu! @@ -43,7 +43,7 @@ If your platform is not listed above, there is still a chance you can manually b osu! is designed to have extensible modular gameplay modes, called "rulesets". Building one of these allows a developer to harness the power of osu! for their own game style. To get started working on a ruleset, we have some templates available [here](https://github.com/ppy/osu/tree/master/Templates). -You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/issues/5852). +You can see some examples of custom rulesets by visiting the [custom ruleset directory](https://github.com/ppy/osu/discussions/13096). ## Developing osu! diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj index 5eb5efa54c..3dd6be7307 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform.Tests/osu.Game.Rulesets.EmptyFreeform.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs index 59a68245a6..a80f1178b6 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/EmptyFreeformDifficultyCalculator.cs @@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.EmptyFreeform protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0]; } } diff --git a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs index d5c1e9bd15..f705009d18 100644 --- a/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs +++ b/Templates/Rulesets/ruleset-empty/osu.Game.Rulesets.EmptyFreeform/Mods/EmptyFreeformModAutoplay.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using osu.Game.Beatmaps; -using osu.Game.Rulesets.EmptyFreeform.Objects; using osu.Game.Rulesets.EmptyFreeform.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; @@ -11,7 +10,7 @@ using osu.Game.Users; namespace osu.Game.Rulesets.EmptyFreeform.Mods { - public class EmptyFreeformModAutoplay : ModAutoplay + public class EmptyFreeformModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index d7c116411a..0c4bfe0ed7 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs index 8ea334c99c..4565c97d1a 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs @@ -4,14 +4,13 @@ using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Pippidon.Objects; using osu.Game.Rulesets.Pippidon.Replays; using osu.Game.Scoring; using osu.Game.Users; namespace osu.Game.Rulesets.Pippidon.Mods { - public class PippidonModAutoplay : ModAutoplay + public class PippidonModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index f6340f6c25..290148d14b 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.Pippidon protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0]; } } diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj index 89b551286b..bb0a487274 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling.Tests/osu.Game.Rulesets.EmptyScrolling.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs index 7f29c4e712..f557a4c754 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/EmptyScrollingDifficultyCalculator.cs @@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.EmptyScrolling protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0]; } } diff --git a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs index 6dad1ff43b..431994e098 100644 --- a/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs +++ b/Templates/Rulesets/ruleset-scrolling-empty/osu.Game.Rulesets.EmptyScrolling/Mods/EmptyScrollingModAutoplay.cs @@ -3,7 +3,6 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.EmptyScrolling.Objects; using osu.Game.Rulesets.EmptyScrolling.Replays; using osu.Game.Scoring; using osu.Game.Users; @@ -11,7 +10,7 @@ using System.Collections.Generic; namespace osu.Game.Rulesets.EmptyScrolling.Mods { - public class EmptyScrollingModAutoplay : ModAutoplay + public class EmptyScrollingModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj index d7c116411a..0c4bfe0ed7 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon.Tests/osu.Game.Rulesets.Pippidon.Tests.csproj @@ -12,7 +12,7 @@ - + diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs index 8ea334c99c..4565c97d1a 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Mods/PippidonModAutoplay.cs @@ -4,14 +4,13 @@ using System.Collections.Generic; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Pippidon.Objects; using osu.Game.Rulesets.Pippidon.Replays; using osu.Game.Scoring; using osu.Game.Users; namespace osu.Game.Rulesets.Pippidon.Mods { - public class PippidonModAutoplay : ModAutoplay + public class PippidonModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs index f6340f6c25..290148d14b 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/PippidonDifficultyCalculator.cs @@ -25,6 +25,6 @@ namespace osu.Game.Rulesets.Pippidon protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) => Enumerable.Empty(); - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[0]; + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[0]; } } diff --git a/appveyor.yml b/appveyor.yml index a4a0cedc66..5be73f9875 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,24 +1,27 @@ clone_depth: 1 version: '{branch}-{build}' image: Visual Studio 2019 +cache: + - '%LOCALAPPDATA%\NuGet\v3-cache -> appveyor.yml' + 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 + - cmd: dotnet --info # Useful when version mismatch between CI and local + - cmd: 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: diff --git a/build/Desktop.proj b/build/Desktop.proj deleted file mode 100644 index b1c6b065e8..0000000000 --- a/build/Desktop.proj +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/build/InspectCode.cake b/build/InspectCode.cake deleted file mode 100644 index 6836d9071b..0000000000 --- a/build/InspectCode.cake +++ /dev/null @@ -1,41 +0,0 @@ -#addin "nuget:?package=CodeFileSanity&version=0.0.36" - -/////////////////////////////////////////////////////////////////////////////// -// ARGUMENTS -/////////////////////////////////////////////////////////////////////////////// - -var target = Argument("target", "CodeAnalysis"); -var configuration = Argument("configuration", "Release"); - -var rootDirectory = new DirectoryPath(".."); -var sln = rootDirectory.CombineWithFilePath("osu.sln"); -var desktopSlnf = rootDirectory.CombineWithFilePath("osu.Desktop.slnf"); - -/////////////////////////////////////////////////////////////////////////////// -// TASKS -/////////////////////////////////////////////////////////////////////////////// - -Task("InspectCode") - .Does(() => { - var inspectcodereport = "inspectcodereport.xml"; - var cacheDir = "inspectcode"; - var verbosity = AppVeyor.IsRunningOnAppVeyor ? "WARN" : "INFO"; // Don't flood CI output - - DotNetCoreTool(rootDirectory.FullPath, - "jb", $@"inspectcode ""{desktopSlnf}"" --output=""{inspectcodereport}"" --caches-home=""{cacheDir}"" --verbosity={verbosity}"); - DotNetCoreTool(rootDirectory.FullPath, "nvika", $@"parsereport ""{inspectcodereport}"" --treatwarningsaserrors"); - }); - -Task("CodeFileSanity") - .Does(() => { - ValidateCodeSanity(new ValidateCodeSanitySettings { - RootDirectory = rootDirectory.FullPath, - IsAppveyorBuild = AppVeyor.IsRunningOnAppVeyor - }); - }); - -Task("CodeAnalysis") - .IsDependentOn("CodeFileSanity") - .IsDependentOn("InspectCode"); - -RunTarget(target); \ No newline at end of file diff --git a/cake.config b/cake.config deleted file mode 100644 index 187d825591..0000000000 --- a/cake.config +++ /dev/null @@ -1,5 +0,0 @@ - -[Nuget] -Source=https://api.nuget.org/v3/index.json -UseInProcessClient=true -LoadDependencies=true diff --git a/osu.Android.props b/osu.Android.props index e95c7e6619..171a0862a1 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,7 +51,11 @@ - - + + + + + + diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index cffcea22c2..063e02d349 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -20,7 +20,8 @@ namespace osu.Android [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false, LaunchMode = LaunchMode.SingleInstance, Exported = true)] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*", DataMimeType = "*/*")] - [IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[] { "application/zip", "application/octet-stream", "application/download", "application/x-zip", "application/x-zip-compressed" })] + [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-archive")] + [IntentFilter(new[] { Intent.ActionSend, Intent.ActionSendMultiple }, Categories = new[] { Intent.CategoryDefault }, DataMimeTypes = new[] { "application/zip", "application/octet-stream", "application/download", "application/x-zip", "application/x-zip-compressed", "application/x-osu-archive" })] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryBrowsable, Intent.CategoryDefault }, DataSchemes = new[] { "osu", "osump" })] public class OsuGameActivity : AndroidGameActivity { diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index 47cd39dc5a..910751a723 100644 --- a/osu.Desktop/Updater/SquirrelUpdateManager.cs +++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs @@ -68,6 +68,8 @@ namespace osu.Desktop.Updater return false; } + scheduleRecheck = false; + if (notification == null) { notification = new UpdateProgressNotification(this) { State = ProgressNotificationState.Active }; @@ -98,7 +100,6 @@ namespace osu.Desktop.Updater // could fail if deltas are unavailable for full update path (https://github.com/Squirrel/Squirrel.Windows/issues/959) // try again without deltas. await checkForUpdateAsync(false, notification).ConfigureAwait(false); - scheduleRecheck = false; } else { @@ -110,13 +111,14 @@ namespace osu.Desktop.Updater catch (Exception) { // we'll ignore this and retry later. can be triggered by no internet connection or thread abortion. + scheduleRecheck = true; } finally { if (scheduleRecheck) { // check again in 30 minutes. - Scheduler.AddDelayed(async () => await checkForUpdateAsync().ConfigureAwait(false), 60000 * 30); + Scheduler.AddDelayed(() => Task.Run(async () => await checkForUpdateAsync().ConfigureAwait(false)), 60000 * 30); } } @@ -141,7 +143,7 @@ namespace osu.Desktop.Updater Activated = () => { updateManager.PrepareUpdateAsync() - .ContinueWith(_ => updateManager.Schedule(() => game.GracefullyExit())); + .ContinueWith(_ => updateManager.Schedule(() => game?.GracefullyExit())); return true; }; } diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index ad5c323e9b..89b9ffb94b 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -5,8 +5,8 @@ true A free-to-win rhythm game. Rhythm is just a *click* away! osu! - osu!lazer - osu!lazer + osu! + osu!(lazer) lazer.ico app.manifest 0.0.0 diff --git a/osu.Desktop/osu.nuspec b/osu.Desktop/osu.nuspec index fa182f8e70..1757fd7c73 100644 --- a/osu.Desktop/osu.nuspec +++ b/osu.Desktop/osu.nuspec @@ -3,7 +3,7 @@ osulazer 0.0.0 - osu!lazer + osu! ppy Pty Ltd Dean Herbert https://osu.ppy.sh/ @@ -20,4 +20,3 @@ - diff --git a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj index 7a74563b2b..da8a0540f4 100644 --- a/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj +++ b/osu.Game.Benchmarks/osu.Game.Benchmarks.csproj @@ -9,7 +9,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchEditorTestSceneContainer.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchEditorTestSceneContainer.cs new file mode 100644 index 0000000000..158c8edba5 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchEditorTestSceneContainer.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Edit; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public class CatchEditorTestSceneContainer : Container + { + [Cached(typeof(Playfield))] + public readonly ScrollingPlayfield Playfield; + + protected override Container Content { get; } + + public CatchEditorTestSceneContainer() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Width = CatchPlayfield.WIDTH; + Height = 1000; + Padding = new MarginPadding + { + Bottom = 100 + }; + + InternalChildren = new Drawable[] + { + new ScrollingTestContainer(ScrollingDirection.Down) + { + TimeRange = 1000, + RelativeSizeAxes = Axes.Both, + Child = Playfield = new TestCatchPlayfield + { + RelativeSizeAxes = Axes.Both + } + }, + new PlayfieldBorder + { + PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Full }, + Clock = new FramedClock(new StopwatchClock(true)) + }, + Content = new Container + { + RelativeSizeAxes = Axes.Both + } + }; + } + + private class TestCatchPlayfield : CatchEditorPlayfield + { + public TestCatchPlayfield() + : base(new BeatmapDifficulty { CircleSize = 0 }) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchPlacementBlueprintTestScene.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchPlacementBlueprintTestScene.cs new file mode 100644 index 0000000000..1d30ae34cd --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchPlacementBlueprintTestScene.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 System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public abstract class CatchPlacementBlueprintTestScene : PlacementBlueprintTestScene + { + protected const double TIME_SNAP = 100; + + protected DrawableCatchHitObject LastObject; + + protected new ScrollingHitObjectContainer HitObjectContainer => contentContainer.Playfield.HitObjectContainer; + + protected override Container Content => contentContainer; + + private readonly CatchEditorTestSceneContainer contentContainer; + + protected CatchPlacementBlueprintTestScene() + { + base.Content.Add(contentContainer = new CatchEditorTestSceneContainer()); + + contentContainer.Playfield.Clock = new FramedClock(new ManualClock()); + } + + [SetUp] + public void Setup() => Schedule(() => + { + HitObjectContainer.Clear(); + ResetPlacement(); + LastObject = null; + }); + + protected void AddMoveStep(double time, float x) => AddStep($"move to time={time}, x={x}", () => + { + float y = HitObjectContainer.PositionAtTime(time); + Vector2 pos = HitObjectContainer.ToScreenSpace(new Vector2(x, y + HitObjectContainer.DrawHeight)); + InputManager.MoveMouseTo(pos); + }); + + protected void AddClickStep(MouseButton button) => AddStep($"click {button}", () => + { + InputManager.Click(button); + }); + + protected IEnumerable FruitOutlines => Content.ChildrenOfType(); + + // Unused because AddHitObject is overriden + protected override Container CreateHitObjectContainer() => new Container(); + + protected override void AddHitObject(DrawableHitObject hitObject) + { + LastObject = (DrawableCatchHitObject)hitObject; + contentContainer.Playfield.HitObjectContainer.Add(hitObject); + } + + protected override SnapResult SnapForBlueprint(PlacementBlueprint blueprint) + { + var result = base.SnapForBlueprint(blueprint); + result.Time = Math.Round(HitObjectContainer.TimeAtScreenSpacePosition(result.ScreenSpacePosition) / TIME_SNAP) * TIME_SNAP; + return result; + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs new file mode 100644 index 0000000000..dcdc32145b --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.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.Framework.Graphics.Containers; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public abstract class CatchSelectionBlueprintTestScene : SelectionBlueprintTestScene + { + protected ScrollingHitObjectContainer HitObjectContainer => contentContainer.Playfield.HitObjectContainer; + + protected override Container Content => contentContainer; + + private readonly CatchEditorTestSceneContainer contentContainer; + + protected CatchSelectionBlueprintTestScene() + { + base.Content.Add(contentContainer = new CatchEditorTestSceneContainer()); + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.cs new file mode 100644 index 0000000000..e3811b7669 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneBananaShowerPlacementBlueprint.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.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public class TestSceneBananaShowerPlacementBlueprint : CatchPlacementBlueprintTestScene + { + protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableBananaShower((BananaShower)hitObject); + + protected override PlacementBlueprint CreateBlueprint() => new BananaShowerPlacementBlueprint(); + + protected override void AddHitObject(DrawableHitObject hitObject) + { + // Create nested bananas (but positions are not randomized because beatmap processing is not done). + hitObject.HitObject.ApplyDefaults(new ControlPointInfo(), Beatmap.Value.BeatmapInfo.BaseDifficulty); + + base.AddHitObject(hitObject); + } + + [Test] + public void TestBasicPlacement() + { + const double start_time = 100; + const double end_time = 500; + + AddMoveStep(start_time, 0); + AddClickStep(MouseButton.Left); + AddMoveStep(end_time, 0); + AddClickStep(MouseButton.Right); + AddAssert("banana shower is placed", () => LastObject is DrawableBananaShower); + AddAssert("start time is correct", () => Precision.AlmostEquals(LastObject.HitObject.StartTime, start_time)); + AddAssert("end time is correct", () => Precision.AlmostEquals(LastObject.HitObject.GetEndTime(), end_time)); + } + + [Test] + public void TestReversePlacement() + { + const double start_time = 100; + const double end_time = 500; + + AddMoveStep(end_time, 0); + AddClickStep(MouseButton.Left); + AddMoveStep(start_time, 0); + AddClickStep(MouseButton.Right); + AddAssert("start time is correct", () => Precision.AlmostEquals(LastObject.HitObject.StartTime, start_time)); + AddAssert("end time is correct", () => Precision.AlmostEquals(LastObject.HitObject.GetEndTime(), end_time)); + } + + [Test] + public void TestFinishWithZeroDuration() + { + AddMoveStep(100, 0); + AddClickStep(MouseButton.Left); + AddClickStep(MouseButton.Right); + AddAssert("banana shower is not placed", () => LastObject == null); + AddAssert("state is waiting", () => CurrentBlueprint?.PlacementActive == PlacementBlueprint.PlacementState.Waiting); + } + + [Test] + public void TestOpacity() + { + AddMoveStep(100, 0); + AddClickStep(MouseButton.Left); + AddUntilStep("outline is semitransparent", () => Precision.DefinitelyBigger(1, timeSpanOutline.Alpha)); + AddMoveStep(200, 0); + AddUntilStep("outline is opaque", () => Precision.AlmostEquals(timeSpanOutline.Alpha, 1)); + AddMoveStep(100, 0); + AddUntilStep("outline is semitransparent", () => Precision.DefinitelyBigger(1, timeSpanOutline.Alpha)); + } + + private TimeSpanOutline timeSpanOutline => Content.ChildrenOfType().Single(); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneEditor.cs new file mode 100644 index 0000000000..161c685043 --- /dev/null +++ b/osu.Game.Rulesets.Catch.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.Catch.Tests.Editor +{ + [TestFixture] + public class TestSceneEditor : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new CatchRuleset(); + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneFruitPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneFruitPlacementBlueprint.cs new file mode 100644 index 0000000000..4b1c45ae2f --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneFruitPlacementBlueprint.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public class TestSceneFruitPlacementBlueprint : CatchPlacementBlueprintTestScene + { + protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableFruit((Fruit)hitObject); + + protected override PlacementBlueprint CreateBlueprint() => new FruitPlacementBlueprint(); + + [Test] + public void TestFruitPlacementPosition() + { + const double time = 300; + const float x = CatchPlayfield.CENTER_X; + + AddMoveStep(time, x); + AddClickStep(MouseButton.Left); + + AddAssert("outline position is correct", () => + { + var outline = FruitOutlines.Single(); + return Precision.AlmostEquals(outline.X, x) && + Precision.AlmostEquals(outline.Y, HitObjectContainer.PositionAtTime(time)); + }); + + AddAssert("fruit time is correct", () => Precision.AlmostEquals(LastObject.StartTimeBindable.Value, time)); + AddAssert("fruit position is correct", () => Precision.AlmostEquals(LastObject.X, x)); + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs new file mode 100644 index 0000000000..1b96175020 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public class TestSceneJuiceStreamSelectionBlueprint : CatchSelectionBlueprintTestScene + { + public TestSceneJuiceStreamSelectionBlueprint() + { + var hitObject = new JuiceStream + { + OriginalX = 100, + StartTime = 100, + Path = new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(200, 100), + new Vector2(0, 200), + }), + }; + var controlPoint = new ControlPointInfo(); + controlPoint.Add(0, new TimingControlPoint + { + BeatLength = 100 + }); + hitObject.ApplyDefaults(controlPoint, new BeatmapDifficulty { CircleSize = 0 }); + AddBlueprint(new JuiceStreamSelectionBlueprint(hitObject)); + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs new file mode 100644 index 0000000000..5e4b6d9e1a --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs @@ -0,0 +1,288 @@ +// Copyright (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.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests +{ + [TestFixture] + public class JuiceStreamPathTest + { + [TestCase(1e3, true, false)] + // When the coordinates are large, the slope invariant fails within the specified absolute allowance due to the floating-number precision. + [TestCase(1e9, false, false)] + // Using discrete values sometimes discover more edge cases. + [TestCase(10, true, true)] + public void TestRandomInsertSetPosition(double scale, bool checkSlope, bool integralValues) + { + var rng = new Random(1); + var path = new JuiceStreamPath(); + + for (int iteration = 0; iteration < 100000; iteration++) + { + if (rng.Next(10) == 0) + path.Clear(); + + int vertexCount = path.Vertices.Count; + + switch (rng.Next(2)) + { + case 0: + { + double distance = rng.NextDouble() * scale * 2 - scale; + if (integralValues) + distance = Math.Round(distance); + + float oldX = path.PositionAtDistance(distance); + int index = path.InsertVertex(distance); + Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount + 1)); + Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance)); + Assert.That(path.Vertices[index].X, Is.EqualTo(oldX)); + break; + } + + case 1: + { + int index = rng.Next(path.Vertices.Count); + double distance = path.Vertices[index].Distance; + float newX = (float)(rng.NextDouble() * scale * 2 - scale); + if (integralValues) + newX = MathF.Round(newX); + + path.SetVertexPosition(index, newX); + Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount)); + Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance)); + Assert.That(path.Vertices[index].X, Is.EqualTo(newX)); + break; + } + } + + assertInvariants(path.Vertices, checkSlope); + } + } + + [Test] + public void TestRemoveVertices() + { + var path = new JuiceStreamPath(); + path.Add(10, 5); + path.Add(20, -5); + + int removeCount = path.RemoveVertices((v, i) => v.Distance == 10 && i == 1); + Assert.That(removeCount, Is.EqualTo(1)); + Assert.That(path.Vertices, Is.EqualTo(new[] + { + new JuiceStreamPathVertex(0, 0), + new JuiceStreamPathVertex(20, -5) + })); + + removeCount = path.RemoveVertices((_, i) => i == 0); + Assert.That(removeCount, Is.EqualTo(1)); + Assert.That(path.Vertices, Is.EqualTo(new[] + { + new JuiceStreamPathVertex(20, -5) + })); + + removeCount = path.RemoveVertices((_, i) => true); + Assert.That(removeCount, Is.EqualTo(1)); + Assert.That(path.Vertices, Is.EqualTo(new[] + { + new JuiceStreamPathVertex() + })); + } + + [Test] + public void TestResampleVertices() + { + var path = new JuiceStreamPath(); + path.Add(-100, -10); + path.Add(100, 50); + path.ResampleVertices(new double[] + { + -50, + 0, + 70, + 120 + }); + Assert.That(path.Vertices, Is.EqualTo(new[] + { + new JuiceStreamPathVertex(-100, -10), + new JuiceStreamPathVertex(-50, -5), + new JuiceStreamPathVertex(0, 0), + new JuiceStreamPathVertex(70, 35), + new JuiceStreamPathVertex(100, 50), + new JuiceStreamPathVertex(100, 50), + })); + + path.Clear(); + path.SetVertexPosition(0, 10); + path.ResampleVertices(Array.Empty()); + Assert.That(path.Vertices, Is.EqualTo(new[] + { + new JuiceStreamPathVertex(0, 10) + })); + } + + [Test] + public void TestRandomConvertFromSliderPath() + { + var rng = new Random(1); + var path = new JuiceStreamPath(); + var sliderPath = new SliderPath(); + + for (int iteration = 0; iteration < 10000; iteration++) + { + sliderPath.ControlPoints.Clear(); + + do + { + int start = sliderPath.ControlPoints.Count; + + do + { + float x = (float)(rng.NextDouble() * 1e3); + float y = (float)(rng.NextDouble() * 1e3); + sliderPath.ControlPoints.Add(new PathControlPoint(new Vector2(x, y))); + } while (rng.Next(2) != 0); + + int length = sliderPath.ControlPoints.Count - start + 1; + sliderPath.ControlPoints[start].Type.Value = length <= 2 ? PathType.Linear : length == 3 ? PathType.PerfectCurve : PathType.Bezier; + } while (rng.Next(3) != 0); + + if (rng.Next(5) == 0) + sliderPath.ExpectedDistance.Value = rng.NextDouble() * 3e3; + else + sliderPath.ExpectedDistance.Value = null; + + path.ConvertFromSliderPath(sliderPath); + Assert.That(path.Vertices[0].Distance, Is.EqualTo(0)); + Assert.That(path.Distance, Is.EqualTo(sliderPath.Distance).Within(1e-3)); + assertInvariants(path.Vertices, true); + + double[] sampleDistances = Enumerable.Range(0, 10) + .Select(_ => rng.NextDouble() * sliderPath.Distance) + .ToArray(); + + foreach (double distance in sampleDistances) + { + float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X; + Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3)); + } + + path.ResampleVertices(sampleDistances); + assertInvariants(path.Vertices, true); + + foreach (double distance in sampleDistances) + { + float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X; + Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3)); + } + } + } + + [Test] + public void TestRandomConvertToSliderPath() + { + var rng = new Random(1); + var path = new JuiceStreamPath(); + var sliderPath = new SliderPath(); + + for (int iteration = 0; iteration < 10000; iteration++) + { + path.Clear(); + + do + { + double distance = rng.NextDouble() * 1e3; + float x = (float)(rng.NextDouble() * 1e3); + path.Add(distance, x); + } while (rng.Next(5) != 0); + + float sliderStartY = (float)(rng.NextDouble() * JuiceStreamPath.OSU_PLAYFIELD_HEIGHT); + + path.ConvertToSliderPath(sliderPath, sliderStartY); + Assert.That(sliderPath.Distance, Is.EqualTo(path.Distance).Within(1e-3)); + Assert.That(sliderPath.ControlPoints[0].Position.Value.X, Is.EqualTo(path.Vertices[0].X)); + assertInvariants(path.Vertices, true); + + foreach (var point in sliderPath.ControlPoints) + { + Assert.That(point.Type.Value, Is.EqualTo(PathType.Linear).Or.Null); + Assert.That(sliderStartY + point.Position.Value.Y, Is.InRange(0, JuiceStreamPath.OSU_PLAYFIELD_HEIGHT)); + } + + for (int i = 0; i < 10; i++) + { + double distance = rng.NextDouble() * path.Distance; + float expected = path.PositionAtDistance(distance); + Assert.That(sliderPath.PositionAt(distance / sliderPath.Distance).X, Is.EqualTo(expected).Within(1e-3)); + } + } + } + + [Test] + public void TestInvalidation() + { + var path = new JuiceStreamPath(); + Assert.That(path.InvalidationID, Is.EqualTo(1)); + int previousId = path.InvalidationID; + + path.InsertVertex(10); + checkNewId(); + + path.SetVertexPosition(1, 5); + checkNewId(); + + path.Add(20, 0); + checkNewId(); + + path.RemoveVertices((v, _) => v.Distance == 20); + checkNewId(); + + path.ResampleVertices(new double[] { 5, 10, 15 }); + checkNewId(); + + path.Clear(); + checkNewId(); + + path.ConvertFromSliderPath(new SliderPath()); + checkNewId(); + + void checkNewId() + { + Assert.That(path.InvalidationID, Is.Not.EqualTo(previousId)); + previousId = path.InvalidationID; + } + } + + private void assertInvariants(IReadOnlyList vertices, bool checkSlope) + { + Assert.That(vertices, Is.Not.Empty); + + for (int i = 0; i < vertices.Count; i++) + { + Assert.That(double.IsFinite(vertices[i].Distance)); + Assert.That(float.IsFinite(vertices[i].X)); + } + + for (int i = 1; i < vertices.Count; i++) + { + Assert.That(vertices[i].Distance, Is.GreaterThanOrEqualTo(vertices[i - 1].Distance)); + + if (!checkSlope) continue; + + float xDiff = Math.Abs(vertices[i].X - vertices[i - 1].X); + double distanceDiff = vertices[i].Distance - vertices[i - 1].Distance; + Assert.That(xDiff, Is.LessThanOrEqualTo(distanceDiff).Within(Precision.FLOAT_EPSILON)); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/skin.ini b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/skin.ini new file mode 100644 index 0000000000..94c6b5b58d --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/skin.ini @@ -0,0 +1,2 @@ +[General] +// no version specified means v1 diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs index 1248409b2a..09362929d2 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchModHidden.cs @@ -4,10 +4,8 @@ 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; @@ -21,12 +19,6 @@ namespace osu.Game.Rulesets.Catch.Tests { public class TestSceneCatchModHidden : ModTestScene { - [BackgroundDependencyLoader] - private void load() - { - LocalConfig.SetValue(OsuSetting.IncreaseFirstObjectVisibility, false); - } - [Test] public void TestJuiceStream() { diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs new file mode 100644 index 0000000000..ec186bcfb2 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.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 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.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Judgements; +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 Direction = osu.Game.Rulesets.Catch.UI.Direction; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneCatchSkinConfiguration : OsuTestScene + { + [Cached] + private readonly DroppedObjectContainer droppedObjectContainer; + + private Catcher catcher; + + private readonly Container container; + + public TestSceneCatchSkinConfiguration() + { + Add(droppedObjectContainer = new DroppedObjectContainer()); + Add(container = new Container { RelativeSizeAxes = Axes.Both }); + } + + [TestCase(false)] + [TestCase(true)] + public void TestCatcherPlateFlipping(bool flip) + { + AddStep("setup catcher", () => + { + var skin = new TestSkin { FlipCatcherPlate = flip }; + container.Child = new SkinProvidingContainer(skin) + { + Child = catcher = new Catcher(new Container()) + { + Anchor = Anchor.Centre + } + }; + }); + + Fruit fruit = new Fruit(); + + AddStep("catch fruit", () => catchFruit(fruit, 20)); + + float position = 0; + + AddStep("record fruit position", () => position = getCaughtObjectPosition(fruit)); + + AddStep("face left", () => catcher.VisualDirection = Direction.Left); + + if (flip) + AddAssert("fruit position changed", () => !Precision.AlmostEquals(getCaughtObjectPosition(fruit), position)); + else + AddAssert("fruit position unchanged", () => Precision.AlmostEquals(getCaughtObjectPosition(fruit), position)); + + AddStep("face right", () => catcher.VisualDirection = Direction.Right); + + AddAssert("fruit position restored", () => Precision.AlmostEquals(getCaughtObjectPosition(fruit), position)); + } + + private float getCaughtObjectPosition(Fruit fruit) + { + var caughtObject = catcher.ChildrenOfType().Single(c => c.HitObject == fruit); + return caughtObject.Parent.ToSpaceOfOtherDrawable(caughtObject.Position, catcher).X; + } + + private void catchFruit(Fruit fruit, float x) + { + fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableFruit = new DrawableFruit(fruit) { X = x }; + var judgement = fruit.CreateJudgement(); + catcher.OnNewResult(drawableFruit, new CatchJudgementResult(fruit, judgement) + { + Type = judgement.MaxResult + }); + } + + private class TestSkin : DefaultSkin + { + public bool FlipCatcherPlate { get; set; } + + public TestSkin() + : base(null) + { + } + + public override IBindable GetConfig(TLookup lookup) + { + if (lookup is CatchSkinConfiguration config) + { + if (config == CatchSkinConfiguration.FlipCatcherPlate) + return SkinUtils.As(new Bindable(FlipCatcherPlate)); + } + + return base.GetConfig(lookup); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 517027a9fc..0a2dff6a21 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -6,8 +6,8 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Game.Rulesets.Catch.UI; using osu.Framework.Graphics; +using osu.Game.Rulesets.Catch.UI; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; @@ -31,10 +31,10 @@ namespace osu.Game.Rulesets.Catch.Tests [Resolved] private OsuConfigManager config { get; set; } - private Container droppedObjectContainer; - private TestCatcher catcher; + private DroppedObjectContainer droppedObjectContainer; + [SetUp] public void SetUp() => Schedule(() => { @@ -43,19 +43,24 @@ namespace osu.Game.Rulesets.Catch.Tests CircleSize = 0, }; - var trailContainer = new Container(); - droppedObjectContainer = new Container(); - catcher = new TestCatcher(trailContainer, droppedObjectContainer, difficulty); - - Child = new Container + var trailContainer = new Container { Anchor = Anchor.Centre, + }; + droppedObjectContainer = new DroppedObjectContainer(); + Child = new DependencyProvidingContainer + { + CachedDependencies = new (Type, object)[] + { + (typeof(DroppedObjectContainer), droppedObjectContainer), + }, Children = new Drawable[] { - trailContainer, droppedObjectContainer, - catcher - } + catcher = new TestCatcher(trailContainer, difficulty), + trailContainer + }, + Anchor = Anchor.Centre }; }); @@ -188,9 +193,9 @@ namespace osu.Game.Rulesets.Catch.Tests AddStep("catch more fruits", () => attemptCatch(() => new Fruit(), 9)); checkPlate(10); AddAssert("caught objects are stacked", () => - catcher.CaughtObjects.All(obj => obj.Y <= Catcher.CAUGHT_FRUIT_VERTICAL_OFFSET) && - catcher.CaughtObjects.Any(obj => obj.Y == Catcher.CAUGHT_FRUIT_VERTICAL_OFFSET) && - catcher.CaughtObjects.Any(obj => obj.Y < -25)); + catcher.CaughtObjects.All(obj => obj.Y <= 0) && + catcher.CaughtObjects.Any(obj => obj.Y == 0) && + catcher.CaughtObjects.Any(obj => obj.Y < 0)); } [Test] @@ -216,7 +221,7 @@ namespace osu.Game.Rulesets.Catch.Tests AddStep("enable hit lighting", () => config.SetValue(OsuSetting.HitLighting, true)); AddStep("catch fruit", () => attemptCatch(new Fruit())); AddAssert("correct hit lighting colour", () => - catcher.ChildrenOfType().First()?.ObjectColour == fruitColour); + catcher.ChildrenOfType().First()?.Entry?.ObjectColour == fruitColour); } [Test] @@ -293,8 +298,8 @@ namespace osu.Game.Rulesets.Catch.Tests { public IEnumerable CaughtObjects => this.ChildrenOfType(); - public TestCatcher(Container trailsTarget, Container droppedObjectTarget, BeatmapDifficulty difficulty) - : base(trailsTarget, droppedObjectTarget, difficulty) + public TestCatcher(Container trailsTarget, BeatmapDifficulty difficulty) + : base(trailsTarget, difficulty) { } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 4af5098451..877e115e2f 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -6,7 +6,6 @@ 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.Framework.Threading; using osu.Framework.Utils; @@ -97,18 +96,12 @@ namespace osu.Game.Rulesets.Catch.Tests SetContents(_ => { - var droppedObjectContainer = new Container - { - RelativeSizeAxes = Axes.Both - }; - return new CatchInputManager(catchRuleset) { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - droppedObjectContainer, - new TestCatcherArea(droppedObjectContainer, beatmapDifficulty) + new TestCatcherArea(beatmapDifficulty) { Anchor = Anchor.Centre, Origin = Anchor.TopCentre, @@ -126,9 +119,13 @@ namespace osu.Game.Rulesets.Catch.Tests private class TestCatcherArea : CatcherArea { - public TestCatcherArea(Container droppedObjectContainer, BeatmapDifficulty beatmapDifficulty) - : base(droppedObjectContainer, beatmapDifficulty) + [Cached] + private readonly DroppedObjectContainer droppedObjectContainer; + + public TestCatcherArea(BeatmapDifficulty beatmapDifficulty) + : base(beatmapDifficulty) { + AddInternal(droppedObjectContainer = new DroppedObjectContainer()); } public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs index 3e4995482d..fd6a9c7b7b 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs @@ -174,8 +174,8 @@ namespace osu.Game.Rulesets.Catch.Tests private void addToPlayfield(DrawableCatchHitObject drawable) { - foreach (var mod in SelectedMods.Value.OfType()) - mod.ApplyToDrawableHitObjects(new[] { drawable }); + foreach (var mod in SelectedMods.Value.OfType()) + mod.ApplyToDrawableHitObject(drawable); drawableRuleset.Playfield.Add(drawable); } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs index 683a776dcc..e7b0259ea2 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -118,11 +118,10 @@ namespace osu.Game.Rulesets.Catch.Tests AddStep("create hyper-dashing catcher", () => { - Child = setupSkinHierarchy(catcherArea = new CatcherArea(new Container()) + Child = setupSkinHierarchy(catcherArea = new TestCatcherArea { Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Scale = new Vector2(4f), + Origin = Anchor.Centre }, skin); }); @@ -139,7 +138,7 @@ namespace osu.Game.Rulesets.Catch.Tests AddStep("finish hyper-dashing", () => { - catcherArea.MovableCatcher.SetHyperDashState(1); + catcherArea.MovableCatcher.SetHyperDashState(); catcherArea.MovableCatcher.FinishTransforms(); }); @@ -206,5 +205,18 @@ namespace osu.Game.Rulesets.Catch.Tests { } } + + private class TestCatcherArea : CatcherArea + { + [Cached] + private readonly DroppedObjectContainer droppedObjectContainer; + + public TestCatcherArea() + { + Scale = new Vector2(4f); + + AddInternal(droppedObjectContainer = new DroppedObjectContainer()); + } + } } } 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 83d0744588..484da8e22e 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 @@ -4,7 +4,7 @@ - + diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs index 34964fc4ae..7774a7da09 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs @@ -23,7 +23,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps protected override IEnumerable ConvertHitObject(HitObject obj, IBeatmap beatmap, CancellationToken cancellationToken) { - var positionData = obj as IHasXPosition; + var xPositionData = obj as IHasXPosition; + var yPositionData = obj as IHasYPosition; var comboData = obj as IHasCombo; switch (obj) @@ -36,10 +37,11 @@ namespace osu.Game.Rulesets.Catch.Beatmaps Path = curveData.Path, NodeSamples = curveData.NodeSamples, RepeatCount = curveData.RepeatCount, - X = positionData?.X ?? 0, + X = xPositionData?.X ?? 0, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, - LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0 + LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0, + LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y }.Yield(); case IHasDuration endTime: @@ -59,7 +61,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps Samples = obj.Samples, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, - X = positionData?.X ?? 0 + X = xPositionData?.X ?? 0, + LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y }.Yield(); } } diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs index fac5d03833..3a5322ce82 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapProcessor.cs @@ -8,7 +8,6 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.MathUtils; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; -using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Catch.Beatmaps @@ -17,6 +16,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps { public const int RNG_SEED = 1337; + public bool HardRockOffsets { get; set; } + public CatchBeatmapProcessor(IBeatmap beatmap) : base(beatmap) { @@ -43,11 +44,10 @@ namespace osu.Game.Rulesets.Catch.Beatmaps } } - public static void ApplyPositionOffsets(IBeatmap beatmap, params Mod[] mods) + public void ApplyPositionOffsets(IBeatmap beatmap) { var rng = new FastRandom(RNG_SEED); - bool shouldApplyHardRockOffset = mods.Any(m => m is ModHardRock); float? lastPosition = null; double lastStartTime = 0; @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Beatmaps switch (obj) { case Fruit fruit: - if (shouldApplyHardRockOffset) + if (HardRockOffsets) applyHardRockOffset(fruit, ref lastPosition, ref lastStartTime, rng); break; diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 23ce444560..76863acc78 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -22,7 +22,9 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using System; using osu.Framework.Extensions.EnumExtensions; +using osu.Game.Rulesets.Catch.Edit; using osu.Game.Rulesets.Catch.Skinning.Legacy; +using osu.Game.Rulesets.Edit; using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch @@ -175,12 +177,14 @@ namespace osu.Game.Rulesets.Catch public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(this, beatmap); - public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new CatchLegacySkinTransformer(source); + public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new CatchLegacySkinTransformer(skin); public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => new CatchPerformanceCalculator(this, attributes, score); public int LegacyID => 2; public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new CatchReplayFrame(); + + public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this); } } diff --git a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs index 668f7197be..e736d68740 100644 --- a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs +++ b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs @@ -8,9 +8,7 @@ namespace osu.Game.Rulesets.Catch Fruit, Banana, Droplet, - CatcherIdle, - CatcherFail, - CatcherKiai, + Catcher, CatchComboCounter } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs index fa9011d826..4e05b1e3e0 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs @@ -7,6 +7,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty { public class CatchDifficultyAttributes : DifficultyAttributes { - public double ApproachRate; + public double ApproachRate { get; set; } } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index f5cce47186..9feaa55051 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty } } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { halfCatcherWidth = Catcher.CalculateCatchWidth(beatmap.BeatmapInfo.BaseDifficulty) * 0.5f; @@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty return new Skill[] { - new Movement(mods, halfCatcherWidth), + new Movement(mods, halfCatcherWidth, clockRate), }; } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index 6a3a16ed33..439890dac2 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -33,15 +32,11 @@ namespace osu.Game.Rulesets.Catch.Difficulty { mods = Score.Mods; - 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; + fruitsHit = Score.Statistics.GetValueOrDefault(HitResult.Great); + ticksHit = Score.Statistics.GetValueOrDefault(HitResult.LargeTickHit); + tinyTicksHit = Score.Statistics.GetValueOrDefault(HitResult.SmallTickHit); + tinyTicksMissed = Score.Statistics.GetValueOrDefault(HitResult.SmallTickMiss); + misses = Score.Statistics.GetValueOrDefault(HitResult.Miss); // We are heavily relying on aim in catch the beat double value = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0049) - 4.0, 2.0) / 100000.0; diff --git a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs index d936ef97ac..e19098c580 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Preprocessing/CatchDifficultyHitObject.cs @@ -24,8 +24,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing /// public readonly double StrainTime; - public readonly double ClockRate; - public CatchDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, float halfCatcherWidth) : base(hitObject, lastObject, clockRate) { @@ -37,7 +35,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing // 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 75e17f6c48..4372ed938c 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/Skills/Movement.cs @@ -28,10 +28,21 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills private float lastDistanceMoved; private double lastStrainTime; - public Movement(Mod[] mods, float halfCatcherWidth) + /// + /// The speed multiplier applied to the player's catcher. + /// + private readonly double catcherSpeedMultiplier; + + public Movement(Mod[] mods, float halfCatcherWidth, double clockRate) : base(mods) { HalfCatcherWidth = halfCatcherWidth; + + // In catch, clockrate adjustments do not only affect the timings of hitobjects, + // but also the speed of the player's catcher, which has an impact on difficulty + // TODO: Support variable clockrates caused by mods such as ModTimeRamp + // (perhaps by using IApplicableToRate within the CatchDifficultyHitObject constructor to set a catcher speed for each object before processing) + catcherSpeedMultiplier = clockRate; } protected override double StrainValueOf(DifficultyHitObject current) @@ -48,7 +59,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills float distanceMoved = playerPosition - lastPlayerPosition.Value; - double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catchCurrent.ClockRate); + double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier); double distanceAddition = (Math.Pow(Math.Abs(distanceMoved), 1.3) / 510); double sqrtStrain = Math.Sqrt(weightedStrainTime); @@ -81,7 +92,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills playerPosition = catchCurrent.NormalizedPosition; } - 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 + distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20) * Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values } lastPlayerPosition = playerPosition; diff --git a/osu.Game.Rulesets.Catch/Edit/BananaShowerCompositionTool.cs b/osu.Game.Rulesets.Catch/Edit/BananaShowerCompositionTool.cs new file mode 100644 index 0000000000..31075db7d1 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/BananaShowerCompositionTool.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.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class BananaShowerCompositionTool : HitObjectCompositionTool + { + public BananaShowerCompositionTool() + : base(nameof(BananaShower)) + { + } + + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners); + + public override PlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs new file mode 100644 index 0000000000..6dea8b0712 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.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.Input.Events; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class BananaShowerPlacementBlueprint : CatchPlacementBlueprint + { + private readonly TimeSpanOutline outline; + + public BananaShowerPlacementBlueprint() + { + InternalChild = outline = new TimeSpanOutline(); + } + + protected override void Update() + { + base.Update(); + + outline.UpdateFrom(HitObjectContainer, HitObject); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + switch (PlacementActive) + { + case PlacementState.Waiting: + if (e.Button != MouseButton.Left) break; + + BeginPlacement(true); + return true; + + case PlacementState.Active: + if (e.Button != MouseButton.Right) break; + + // If the duration is negative, swap the start and the end time to make the duration positive. + if (HitObject.Duration < 0) + { + HitObject.StartTime = HitObject.EndTime; + HitObject.Duration = -HitObject.Duration; + } + + EndPlacement(HitObject.Duration > 0); + return true; + } + + return base.OnMouseDown(e); + } + + public override void UpdateTimeAndPosition(SnapResult result) + { + base.UpdateTimeAndPosition(result); + + if (!(result.Time is double time)) return; + + switch (PlacementActive) + { + case PlacementState.Waiting: + HitObject.StartTime = time; + break; + + case PlacementState.Active: + HitObject.EndTime = time; + break; + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.cs new file mode 100644 index 0000000000..9132b1a9e8 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerSelectionBlueprint.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.Catch.Objects; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class BananaShowerSelectionBlueprint : CatchSelectionBlueprint + { + public BananaShowerSelectionBlueprint(BananaShower hitObject) + : base(hitObject) + { + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.cs new file mode 100644 index 0000000000..5a32d241ad --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchPlacementBlueprint.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.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class CatchPlacementBlueprint : PlacementBlueprint + where THitObject : CatchHitObject, new() + { + protected new THitObject HitObject => (THitObject)base.HitObject; + + protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; + + [Resolved] + private Playfield playfield { get; set; } + + public CatchPlacementBlueprint() + : base(new THitObject()) + { + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs new file mode 100644 index 0000000000..7e566c810c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.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.Allocation; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public abstract class CatchSelectionBlueprint : HitObjectSelectionBlueprint + where THitObject : CatchHitObject + { + protected override bool AlwaysShowWhenSelected => true; + + public override Vector2 ScreenSpaceSelectionPoint + { + get + { + Vector2 position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + return HitObjectContainer.ToScreenSpace(position + new Vector2(0, HitObjectContainer.DrawHeight)); + } + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => SelectionQuad.Contains(screenSpacePos); + + protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; + + [Resolved] + private Playfield playfield { get; set; } + + protected CatchSelectionBlueprint(THitObject hitObject) + : base(hitObject) + { + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs new file mode 100644 index 0000000000..0c03068e26 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Skinning.Default; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class FruitOutline : CompositeDrawable + { + public FruitOutline() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.Centre; + InternalChild = new BorderPiece(); + } + + [BackgroundDependencyLoader] + private void load(OsuColour osuColour) + { + Colour = osuColour.Yellow; + } + + public void UpdateFrom(CatchHitObject hitObject) + { + Scale = new Vector2(hitObject.Scale); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs new file mode 100644 index 0000000000..cf916b27a4 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.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.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.UI.Scrolling; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class NestedOutlineContainer : CompositeDrawable + { + private readonly List nestedHitObjects = new List(); + + public NestedOutlineContainer() + { + Anchor = Anchor.BottomLeft; + } + + public void UpdateNestedObjectsFrom(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject parentHitObject) + { + nestedHitObjects.Clear(); + nestedHitObjects.AddRange(parentHitObject.NestedHitObjects + .OfType() + .Where(h => !(h is TinyDroplet))); + + while (nestedHitObjects.Count < InternalChildren.Count) + RemoveInternal(InternalChildren[^1]); + + while (InternalChildren.Count < nestedHitObjects.Count) + AddInternal(new FruitOutline()); + + for (int i = 0; i < nestedHitObjects.Count; i++) + { + var hitObject = nestedHitObjects[i]; + var outline = (FruitOutline)InternalChildren[i]; + outline.Position = CatchHitObjectUtils.GetStartPosition(hitObjectContainer, hitObject) - Position; + outline.UpdateFrom(hitObject); + outline.Scale *= hitObject is Droplet ? 0.5f : 1; + } + } + + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs new file mode 100644 index 0000000000..109bf61ea5 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs @@ -0,0 +1,77 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Framework.Graphics.Primitives; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class ScrollingPath : CompositeDrawable + { + private readonly Path drawablePath; + + private readonly List<(double Distance, float X)> vertices = new List<(double, float)>(); + + public ScrollingPath() + { + Anchor = Anchor.BottomLeft; + + InternalChildren = new Drawable[] + { + drawablePath = new SmoothPath + { + PathRadius = 2, + Alpha = 0.5f + }, + }; + } + + public void UpdatePathFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject) + { + double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity); + + computeDistanceXs(hitObject); + drawablePath.Vertices = vertices + .Select(v => new Vector2(v.X, (float)(v.Distance * distanceToYFactor))) + .ToArray(); + drawablePath.OriginPosition = drawablePath.PositionInBoundingBox(Vector2.Zero); + } + + private void computeDistanceXs(JuiceStream hitObject) + { + vertices.Clear(); + + var sliderVertices = new List(); + hitObject.Path.GetPathToProgress(sliderVertices, 0, 1); + + if (sliderVertices.Count == 0) + return; + + double distance = 0; + Vector2 lastPosition = Vector2.Zero; + + for (int repeat = 0; repeat < hitObject.RepeatCount + 1; repeat++) + { + foreach (var position in sliderVertices) + { + distance += Vector2.Distance(lastPosition, position); + lastPosition = position; + + vertices.Add((distance, position.X)); + } + + sliderVertices.Reverse(); + } + } + + // Because this has 0x0 size, the contents are otherwise masked away if the start position is outside the screen. + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.cs new file mode 100644 index 0000000000..65dfce0493 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/TimeSpanOutline.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class TimeSpanOutline : CompositeDrawable + { + private const float border_width = 4; + + private const float opacity_when_empty = 0.5f; + + private bool isEmpty = true; + + public TimeSpanOutline() + { + Anchor = Origin = Anchor.BottomLeft; + RelativeSizeAxes = Axes.X; + + Masking = true; + BorderThickness = border_width; + Alpha = opacity_when_empty; + + // A box is needed to make the border visible. + InternalChild = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Transparent + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour osuColour) + { + BorderColour = osuColour.Yellow; + } + + public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, BananaShower hitObject) + { + float startY = hitObjectContainer.PositionAtTime(hitObject.StartTime); + float endY = hitObjectContainer.PositionAtTime(hitObject.EndTime); + + Y = Math.Max(startY, endY); + float height = Math.Abs(startY - endY); + + bool wasEmpty = isEmpty; + isEmpty = height == 0; + if (wasEmpty != isEmpty) + this.FadeTo(isEmpty ? opacity_when_empty : 1f, 150); + + Height = Math.Max(height, border_width); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs new file mode 100644 index 0000000000..e169e3b75c --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.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.Input.Events; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class FruitPlacementBlueprint : CatchPlacementBlueprint + { + private readonly FruitOutline outline; + + public FruitPlacementBlueprint() + { + InternalChild = outline = new FruitOutline(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + BeginPlacement(); + } + + protected override void Update() + { + base.Update(); + + outline.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + outline.UpdateFrom(HitObject); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button != MouseButton.Left) return base.OnMouseDown(e); + + EndPlacement(true); + return true; + } + + public override void UpdateTimeAndPosition(SnapResult result) + { + base.UpdateTimeAndPosition(result); + + HitObject.X = ToLocalSpace(result.ScreenSpacePosition).X; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs new file mode 100644 index 0000000000..150297badb --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.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.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class FruitSelectionBlueprint : CatchSelectionBlueprint + { + private readonly FruitOutline outline; + + public FruitSelectionBlueprint(Fruit hitObject) + : base(hitObject) + { + InternalChild = outline = new FruitOutline(); + } + + protected override void Update() + { + base.Update(); + + if (!IsSelected) return; + + outline.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + outline.UpdateFrom(HitObject); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs new file mode 100644 index 0000000000..0614c4c24d --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class JuiceStreamSelectionBlueprint : CatchSelectionBlueprint + { + public override Quad SelectionQuad => HitObjectContainer.ToScreenSpace(getBoundingBox().Offset(new Vector2(0, HitObjectContainer.DrawHeight))); + + private float minNestedX; + private float maxNestedX; + + private readonly ScrollingPath scrollingPath; + + private readonly NestedOutlineContainer nestedOutlineContainer; + + private readonly Cached pathCache = new Cached(); + + public JuiceStreamSelectionBlueprint(JuiceStream hitObject) + : base(hitObject) + { + InternalChildren = new Drawable[] + { + scrollingPath = new ScrollingPath(), + nestedOutlineContainer = new NestedOutlineContainer() + }; + } + + [BackgroundDependencyLoader] + private void load() + { + HitObject.DefaultsApplied += onDefaultsApplied; + computeObjectBounds(); + } + + protected override void Update() + { + base.Update(); + + if (!IsSelected) return; + + nestedOutlineContainer.Position = scrollingPath.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + + if (pathCache.IsValid) return; + + scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject); + nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject); + + pathCache.Validate(); + } + + private void onDefaultsApplied(HitObject _) + { + computeObjectBounds(); + pathCache.Invalidate(); + } + + private void computeObjectBounds() + { + minNestedX = HitObject.NestedHitObjects.OfType().Min(nested => nested.OriginalX) - HitObject.OriginalX; + maxNestedX = HitObject.NestedHitObjects.OfType().Max(nested => nested.OriginalX) - HitObject.OriginalX; + } + + private RectangleF getBoundingBox() + { + float left = HitObject.OriginalX + minNestedX; + float right = HitObject.OriginalX + maxNestedX; + float top = HitObjectContainer.PositionAtTime(HitObject.EndTime); + float bottom = HitObjectContainer.PositionAtTime(HitObject.StartTime); + float objectRadius = CatchHitObject.OBJECT_RADIUS * HitObject.Scale; + return new RectangleF(left, top, right - left, bottom - top).Inflate(objectRadius); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + HitObject.DefaultsApplied -= onDefaultsApplied; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs new file mode 100644 index 0000000000..7f2782a474 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchBlueprintContainer.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class CatchBlueprintContainer : ComposeBlueprintContainer + { + public CatchBlueprintContainer(CatchHitObjectComposer composer) + : base(composer) + { + } + + protected override SelectionHandler CreateSelectionHandler() => new CatchSelectionHandler(); + + public override HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) + { + switch (hitObject) + { + case Fruit fruit: + return new FruitSelectionBlueprint(fruit); + + case JuiceStream juiceStream: + return new JuiceStreamSelectionBlueprint(juiceStream); + + case BananaShower bananaShower: + return new BananaShowerSelectionBlueprint(bananaShower); + } + + return base.CreateHitObjectBlueprintFor(hitObject); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs new file mode 100644 index 0000000000..d383eb9ba6 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.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.Game.Beatmaps; +using osu.Game.Rulesets.Catch.UI; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class CatchEditorPlayfield : CatchPlayfield + { + // TODO fixme: the size of the catcher is not changed when circle size is changed in setup screen. + public CatchEditorPlayfield(BeatmapDifficulty difficulty) + : base(difficulty) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // TODO: honor "hit animation" setting? + CatcherArea.MovableCatcher.CatchFruitOnPlate = false; + + // TODO: disable hit lighting as well + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs new file mode 100644 index 0000000000..d360274aa6 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class CatchHitObjectComposer : HitObjectComposer + { + public CatchHitObjectComposer(CatchRuleset ruleset) + : base(ruleset) + { + } + + [BackgroundDependencyLoader] + private void load() + { + LayerBelowRuleset.Add(new PlayfieldBorder + { + RelativeSizeAxes = Axes.Both, + PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } + }); + } + + protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) => + new DrawableCatchEditorRuleset(ruleset, beatmap, mods); + + protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] + { + new FruitCompositionTool(), + new BananaShowerCompositionTool() + }; + + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) + { + var result = base.SnapScreenSpacePositionToValidTime(screenSpacePosition); + // TODO: implement position snap + result.ScreenSpacePosition.X = screenSpacePosition.X; + return result; + } + + protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this); + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs new file mode 100644 index 0000000000..beffdf0362 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.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.Catch.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit +{ + /// + /// Utility functions used by the editor. + /// + public static class CatchHitObjectUtils + { + /// + /// Get the position of the hit object in the playfield based on and . + /// + public static Vector2 GetStartPosition(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject hitObject) + { + return new Vector2(hitObject.OriginalX, hitObjectContainer.PositionAtTime(hitObject.StartTime)); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs new file mode 100644 index 0000000000..7eebf04ca2 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.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; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.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.Catch.Edit +{ + public class CatchSelectionHandler : EditorSelectionHandler + { + protected ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; + + [Resolved] + private Playfield playfield { get; set; } + + public override bool HandleMovement(MoveSelectionEvent moveEvent) + { + var blueprint = moveEvent.Blueprint; + Vector2 originalPosition = HitObjectContainer.ToLocalSpace(blueprint.ScreenSpaceSelectionPoint); + Vector2 targetPosition = HitObjectContainer.ToLocalSpace(blueprint.ScreenSpaceSelectionPoint + moveEvent.ScreenSpaceDelta); + + float deltaX = targetPosition.X - originalPosition.X; + deltaX = limitMovement(deltaX, EditorBeatmap.SelectedHitObjects); + + if (deltaX == 0) + { + // Even if there is no positional change, there may be a time change. + return true; + } + + EditorBeatmap.PerformOnSelection(h => + { + if (!(h is CatchHitObject hitObject)) return; + + hitObject.OriginalX += deltaX; + + // Move the nested hit objects to give an instant result before nested objects are recreated. + foreach (var nested in hitObject.NestedHitObjects.OfType()) + nested.OriginalX += deltaX; + }); + + return true; + } + + /// + /// Limit positional movement of the objects by the constraint that moved objects should stay in bounds. + /// + /// The positional movement. + /// The objects to be moved. + /// The positional movement with the restriction applied. + private float limitMovement(float deltaX, IEnumerable movingObjects) + { + float minX = float.PositiveInfinity; + float maxX = float.NegativeInfinity; + + foreach (float x in movingObjects.SelectMany(getOriginalPositions)) + { + minX = Math.Min(minX, x); + maxX = Math.Max(maxX, x); + } + + // To make an object with position `x` stay in bounds after `deltaX` movement, `0 <= x + deltaX <= WIDTH` should be satisfied. + // Subtracting `x`, we get `-x <= deltaX <= WIDTH - x`. + // We only need to apply the inequality to extreme values of `x`. + float lowerBound = -minX; + float upperBound = CatchPlayfield.WIDTH - maxX; + // The inequality may be unsatisfiable if the objects were already out of bounds. + // In that case, don't move objects at all. + if (lowerBound > upperBound) + return 0; + + return Math.Clamp(deltaX, lowerBound, upperBound); + } + + /// + /// Enumerate X positions that should be contained in-bounds after move offset is applied. + /// + private IEnumerable getOriginalPositions(HitObject hitObject) + { + switch (hitObject) + { + case Fruit fruit: + yield return fruit.OriginalX; + + break; + + case JuiceStream juiceStream: + foreach (var nested in juiceStream.NestedHitObjects.OfType()) + { + // Even if `OriginalX` is outside the playfield, tiny droplets can be moved inside the playfield after the random offset application. + if (!(nested is TinyDroplet)) + yield return nested.OriginalX; + } + + break; + + case BananaShower _: + // A banana shower occupies the whole screen width. + // If the selection contains a banana shower, the selection cannot be moved horizontally. + yield return 0; + yield return CatchPlayfield.WIDTH; + + break; + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.cs new file mode 100644 index 0000000000..0344709d45 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/DrawableCatchEditorRuleset.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.Beatmaps; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class DrawableCatchEditorRuleset : DrawableCatchRuleset + { + public DrawableCatchEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) + : base(ruleset, beatmap, mods) + { + } + + protected override Playfield CreatePlayfield() => new CatchEditorPlayfield(Beatmap.BeatmapInfo.BaseDifficulty); + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/FruitCompositionTool.cs b/osu.Game.Rulesets.Catch/Edit/FruitCompositionTool.cs new file mode 100644 index 0000000000..f776fe39c1 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/FruitCompositionTool.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.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class FruitCompositionTool : HitObjectCompositionTool + { + public FruitCompositionTool() + : base(nameof(Fruit)) + { + } + + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); + + public override PlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint(); + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs index e1eceea606..f1b51e51d0 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModAutoplay.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; 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; @@ -11,7 +10,7 @@ using osu.Game.Users; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModAutoplay : ModAutoplay + public class CatchModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs index 5f1736450a..e59a0a0431 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModDifficultyAdjust.cs @@ -5,39 +5,35 @@ using System.Linq; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModDifficultyAdjust : ModDifficultyAdjust + public class CatchModDifficultyAdjust : ModDifficultyAdjust, IApplicableToBeatmapProcessor { - [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1)] - public BindableNumber CircleSize { get; } = new BindableFloatWithLimitExtension + [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1, SettingControlType = typeof(DifficultyAdjustSettingsControl))] + public DifficultyBindable CircleSize { get; } = new DifficultyBindable { Precision = 0.1f, MinValue = 1, MaxValue = 10, - Default = 5, - Value = 5, + ExtendedMaxValue = 11, + ReadCurrentFromDifficulty = diff => diff.CircleSize, }; - [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)] - public BindableNumber ApproachRate { get; } = new BindableFloatWithLimitExtension + [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1, SettingControlType = typeof(DifficultyAdjustSettingsControl))] + public DifficultyBindable ApproachRate { get; } = new DifficultyBindable { Precision = 0.1f, MinValue = 1, MaxValue = 10, - Default = 5, - Value = 5, + ExtendedMaxValue = 11, + ReadCurrentFromDifficulty = diff => diff.ApproachRate, }; - protected override void ApplyLimits(bool extended) - { - base.ApplyLimits(extended); - - CircleSize.MaxValue = extended ? 11 : 10; - ApproachRate.MaxValue = extended ? 11 : 10; - } + [SettingSource("Spicy Patterns", "Adjust the patterns as if Hard Rock is enabled.")] + public BindableBool HardRockOffsets { get; } = new BindableBool(); public override string SettingDescription { @@ -45,30 +41,30 @@ namespace osu.Game.Rulesets.Catch.Mods { string circleSize = CircleSize.IsDefault ? string.Empty : $"CS {CircleSize.Value:N1}"; string approachRate = ApproachRate.IsDefault ? string.Empty : $"AR {ApproachRate.Value:N1}"; + string spicyPatterns = HardRockOffsets.IsDefault ? string.Empty : "Spicy patterns"; return string.Join(", ", new[] { circleSize, base.SettingDescription, - approachRate + approachRate, + spicyPatterns, }.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); - ApplySetting(CircleSize, cs => difficulty.CircleSize = cs); - ApplySetting(ApproachRate, ar => difficulty.ApproachRate = ar); + if (CircleSize.Value != null) difficulty.CircleSize = CircleSize.Value.Value; + if (ApproachRate.Value != null) difficulty.ApproachRate = ApproachRate.Value.Value; + } + + public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor) + { + var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor; + catchProcessor.HardRockOffsets = HardRockOffsets.Value; } } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs index ced1900ba9..68b6ce96a3 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHardRock.cs @@ -7,11 +7,14 @@ using osu.Game.Rulesets.Catch.Beatmaps; namespace osu.Game.Rulesets.Catch.Mods { - public class CatchModHardRock : ModHardRock, IApplicableToBeatmap + public class CatchModHardRock : ModHardRock, IApplicableToBeatmapProcessor { public override double ScoreMultiplier => 1.12; - public override bool Ranked => true; - public void ApplyToBeatmap(IBeatmap beatmap) => CatchBeatmapProcessor.ApplyPositionOffsets(beatmap, this); + public void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor) + { + var catchProcessor = (CatchBeatmapProcessor)beatmapProcessor; + catchProcessor.HardRockOffsets = true; + } } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs index 7bad4c79cb..f9e106f097 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs @@ -29,8 +29,7 @@ namespace osu.Game.Rulesets.Catch.Mods } protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) - { - } + => ApplyNormalVisibilityState(hitObject, state); protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) { diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs index 1e42c6a240..73b60f51a4 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs @@ -33,13 +33,13 @@ namespace osu.Game.Rulesets.Catch.Mods private class MouseInputHelper : Drawable, IKeyBindingHandler, IRequireHighFrequencyMousePosition { - private readonly Catcher catcher; + private readonly CatcherArea catcherArea; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; public MouseInputHelper(CatchPlayfield playfield) { - catcher = playfield.CatcherArea.MovableCatcher; + catcherArea = playfield.CatcherArea; RelativeSizeAxes = Axes.Both; } @@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Catch.Mods protected override bool OnMouseMove(MouseMoveEvent e) { - catcher.UpdatePosition(e.MousePosition.X / DrawSize.X * CatchPlayfield.WIDTH); + catcherArea.SetCatcherPosition(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 178306b3bc..e5a36d08db 100644 --- a/osu.Game.Rulesets.Catch/Objects/Banana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Banana.cs @@ -9,6 +9,7 @@ using osu.Game.Audio; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; using osu.Game.Utils; using osuTK.Graphics; @@ -31,7 +32,7 @@ namespace osu.Game.Rulesets.Catch.Objects } // override any external colour changes with banananana - Color4 IHasComboInformation.GetComboColour(IReadOnlyList comboColours) => getBananaColour(); + Color4 IHasComboInformation.GetComboColour(ISkin skin) => getBananaColour(); private Color4 getBananaColour() { diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index ae45182960..f979e3e0ca 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.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 Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -8,10 +9,11 @@ using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Catch.Objects { - public abstract class CatchHitObject : HitObject, IHasXPosition, IHasComboInformation + public abstract class CatchHitObject : HitObject, IHasPosition, IHasComboInformation { public const float OBJECT_RADIUS = 64; @@ -20,13 +22,16 @@ namespace osu.Game.Rulesets.Catch.Objects /// /// The horizontal position of the hit object between 0 and . /// + /// + /// Only setter is exposed. + /// Use or to get the horizontal position. + /// + [JsonIgnore] public float X { set => OriginalXBindable.Value = value; } - float IHasXPosition.X => OriginalXBindable.Value; - public readonly Bindable XOffsetBindable = new Bindable(); /// @@ -34,6 +39,7 @@ namespace osu.Game.Rulesets.Catch.Objects /// public float XOffset { + get => XOffsetBindable.Value; set => XOffsetBindable.Value = value; } @@ -44,7 +50,11 @@ namespace osu.Game.Rulesets.Catch.Objects /// This value is the original value specified in the beatmap, not affected by the beatmap processing. /// Use for a gameplay. /// - public float OriginalX => OriginalXBindable.Value; + public float OriginalX + { + get => OriginalXBindable.Value; + set => OriginalXBindable.Value = value; + } /// /// The effective horizontal position of the hit object between 0 and . @@ -53,9 +63,9 @@ namespace osu.Game.Rulesets.Catch.Objects /// This value is the original value plus the offset applied by the beatmap processing. /// Use if a value not affected by the offset is desired. /// - public float EffectiveX => OriginalXBindable.Value + XOffsetBindable.Value; + public float EffectiveX => OriginalX + XOffset; - public double TimePreempt = 1000; + public double TimePreempt { get; set; } = 1000; public readonly Bindable IndexInBeatmapBindable = new Bindable(); @@ -120,5 +130,24 @@ namespace osu.Game.Rulesets.Catch.Objects } protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + #region Hit object conversion + + // The half of the height of the osu! playfield. + public const float DEFAULT_LEGACY_CONVERT_Y = 192; + + /// + /// The Y position of the hit object is not used in the normal osu!catch gameplay. + /// It is preserved to maximize the backward compatibility with the legacy editor, in which the mappers use the Y position to organize the patterns. + /// + public float LegacyConvertedY { get; set; } = DEFAULT_LEGACY_CONVERT_Y; + + float IHasXPosition.X => OriginalX; + + float IHasYPosition.Y => LegacyConvertedY; + + Vector2 IHasPosition.Position => new Vector2(OriginalX, LegacyConvertedY); + + #endregion } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs index 140b411c88..7c88090a20 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtFruit.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 osu.Framework.Bindables; using osu.Game.Rulesets.Catch.Skinning.Default; namespace osu.Game.Rulesets.Catch.Objects.Drawables @@ -9,21 +8,11 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables /// /// Represents a caught by the catcher. /// - public class CaughtFruit : CaughtObject, IHasFruitState + public class CaughtFruit : CaughtObject { - public Bindable VisualRepresentation { get; } = new Bindable(); - public CaughtFruit() : base(CatchSkinComponents.Fruit, _ => new FruitPiece()) { } - - public override void CopyStateFrom(IHasCatchObjectState objectState) - { - base.CopyStateFrom(objectState); - - var fruitState = (IHasFruitState)objectState; - VisualRepresentation.Value = fruitState.VisualRepresentation.Value; - } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs index 524505d588..d8bce9bb6d 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/CaughtObject.cs @@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables public PalpableCatchHitObject HitObject { get; private set; } public Bindable AccentColour { get; } = new Bindable(); public Bindable HyperDash { get; } = new Bindable(); + public Bindable IndexInBeatmap { get; } = new Bindable(); public Vector2 DisplaySize => Size * Scale; @@ -51,6 +52,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables Rotation = objectState.DisplayRotation; AccentColour.Value = objectState.AccentColour.Value; HyperDash.Value = objectState.HyperDash.Value; + IndexInBeatmap.Value = objectState.IndexInBeatmap.Value; } protected override void FreeAfterUse() diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs index 0b89c46480..0af7ee6c30 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs @@ -3,17 +3,14 @@ using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Catch.Skinning.Default; using osu.Game.Skinning; namespace osu.Game.Rulesets.Catch.Objects.Drawables { - public class DrawableFruit : DrawablePalpableCatchHitObject, IHasFruitState + public class DrawableFruit : DrawablePalpableCatchHitObject { - public Bindable VisualRepresentation { get; } = new Bindable(); - public DrawableFruit() : this(null) { @@ -27,11 +24,6 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables [BackgroundDependencyLoader] private void load() { - IndexInBeatmap.BindValueChanged(change => - { - VisualRepresentation.Value = (FruitVisualRepresentation)(change.NewValue % 4); - }, true); - ScalingContainer.Child = new SkinnableDrawable( new CatchSkinComponent(CatchSkinComponents.Fruit), _ => new FruitPiece()); @@ -44,12 +36,4 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables ScalingContainer.RotateTo((RandomSingle(1) - 0.5f) * 40); } } - - public enum FruitVisualRepresentation - { - Pear, - Grape, - Pineapple, - Raspberry, - } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs index 81b61f0959..be0ee2821e 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/IHasCatchObjectState.cs @@ -18,6 +18,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables Bindable HyperDash { get; } + Bindable IndexInBeatmap { get; } + Vector2 DisplaySize { get; } float DisplayRotation { get; } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/IHasFruitState.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/IHasFruitState.cs deleted file mode 100644 index 2d4de543c3..0000000000 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/IHasFruitState.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.Bindables; - -namespace osu.Game.Rulesets.Catch.Objects.Drawables -{ - /// - /// Provides a visual state of a . - /// - public interface IHasFruitState : IHasCatchObjectState - { - Bindable VisualRepresentation { get; } - } -} diff --git a/osu.Game.Rulesets.Catch/Objects/Fruit.cs b/osu.Game.Rulesets.Catch/Objects/Fruit.cs index 43486796ad..4818fe2cad 100644 --- a/osu.Game.Rulesets.Catch/Objects/Fruit.cs +++ b/osu.Game.Rulesets.Catch/Objects/Fruit.cs @@ -9,5 +9,7 @@ namespace osu.Game.Rulesets.Catch.Objects public class Fruit : PalpableCatchHitObject { public override Judgement CreateJudgement() => new CatchJudgement(); + + public static FruitVisualRepresentation GetVisualRepresentation(int indexInBeatmap) => (FruitVisualRepresentation)(indexInBeatmap % 4); } } diff --git a/osu.Game.Rulesets.Catch/Objects/FruitVisualRepresentation.cs b/osu.Game.Rulesets.Catch/Objects/FruitVisualRepresentation.cs new file mode 100644 index 0000000000..7ec7050245 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/FruitVisualRepresentation.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.Catch.Objects +{ + public enum FruitVisualRepresentation + { + Pear, + Grape, + Pineapple, + Raspberry, + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 35fd58826e..3088d024d1 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using Newtonsoft.Json; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -25,7 +26,10 @@ namespace osu.Game.Rulesets.Catch.Objects public int RepeatCount { get; set; } + [JsonIgnore] public double Velocity { get; private set; } + + [JsonIgnore] public double TickDistance { get; private set; } /// @@ -113,6 +117,7 @@ namespace osu.Game.Rulesets.Catch.Objects public float EndX => OriginalX + this.CurvePositionAt(1).X; + [JsonIgnore] public double Duration { get => this.SpanCount() * Path.Distance / Velocity; diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs new file mode 100644 index 0000000000..f1cdb39e91 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs @@ -0,0 +1,340 @@ +// Copyright (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.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +#nullable enable + +namespace osu.Game.Rulesets.Catch.Objects +{ + /// + /// Represents the path of a juice stream. + /// + /// A holds a legacy as the representation of the path. + /// However, the representation is difficult to work with. + /// This represents the path in a more convenient way, a polyline connecting list of s. + /// + /// + /// The path can be regarded as a function from the closed interval [Vertices[0].Distance, Vertices[^1].Distance] to the x position, given by . + /// To ensure the path is convertible to a , the slope of the function must not be more than 1 everywhere, + /// and this slope condition is always maintained as an invariant. + /// + /// + public class JuiceStreamPath + { + /// + /// The height of legacy osu!standard playfield. + /// The sliders converted by are vertically contained in this height. + /// + internal const float OSU_PLAYFIELD_HEIGHT = 384; + + /// + /// The list of vertices of the path, which is represented as a polyline connecting the vertices. + /// + public IReadOnlyList Vertices => vertices; + + /// + /// The current version number. + /// This starts from 1 and incremented whenever this is modified. + /// + public int InvalidationID { get; private set; } = 1; + + /// + /// The difference between first vertex's and last vertex's . + /// + public double Distance => vertices[^1].Distance - vertices[0].Distance; + + /// + /// This list should always be non-empty. + /// + private readonly List vertices = new List + { + new JuiceStreamPathVertex() + }; + + /// + /// Compute the x-position of the path at the given . + /// + /// + /// When the given distance is outside of the path, the x position at the corresponding endpoint is returned, + /// + public float PositionAtDistance(double distance) + { + int index = vertexIndexAtDistance(distance); + return positionAtDistance(distance, index); + } + + /// + /// Remove all vertices of this path, then add a new vertex (0, 0). + /// + public void Clear() + { + vertices.Clear(); + vertices.Add(new JuiceStreamPathVertex()); + invalidate(); + } + + /// + /// Insert a vertex at given . + /// The is used as the position of the new vertex. + /// Thus, the set of points of the path is not changed (up to floating-point precision). + /// + /// The index of the new vertex. + public int InsertVertex(double distance) + { + if (!double.IsFinite(distance)) + throw new ArgumentOutOfRangeException(nameof(distance)); + + int index = vertexIndexAtDistance(distance); + float x = positionAtDistance(distance, index); + vertices.Insert(index, new JuiceStreamPathVertex(distance, x)); + + invalidate(); + return index; + } + + /// + /// Move the vertex of given to the given position . + /// When the distances between vertices are too small for the new vertex positions, the adjacent vertices are moved towards . + /// + public void SetVertexPosition(int index, float newX) + { + if (index < 0 || index >= vertices.Count) + throw new ArgumentOutOfRangeException(nameof(index)); + + if (!float.IsFinite(newX)) + throw new ArgumentOutOfRangeException(nameof(newX)); + + var newVertex = new JuiceStreamPathVertex(vertices[index].Distance, newX); + + for (int i = index - 1; i >= 0 && !canConnect(vertices[i], newVertex); i--) + { + float clampedX = clampToConnectablePosition(newVertex, vertices[i]); + vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX); + } + + for (int i = index + 1; i < vertices.Count; i++) + { + float clampedX = clampToConnectablePosition(newVertex, vertices[i]); + vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX); + } + + vertices[index] = newVertex; + + invalidate(); + } + + /// + /// Add a new vertex at given and position. + /// Adjacent vertices are moved when necessary in the same way as . + /// + public void Add(double distance, float x) + { + int index = InsertVertex(distance); + SetVertexPosition(index, x); + } + + /// + /// Remove all vertices that satisfy the given . + /// + /// + /// If all vertices are removed, a new vertex (0, 0) is added. + /// + /// The predicate to determine whether a vertex should be removed given the vertex and its index in the path. + /// The number of removed vertices. + public int RemoveVertices(Func predicate) + { + int index = 0; + int removeCount = vertices.RemoveAll(vertex => predicate(vertex, index++)); + + if (vertices.Count == 0) + vertices.Add(new JuiceStreamPathVertex()); + + if (removeCount != 0) + invalidate(); + + return removeCount; + } + + /// + /// Recreate this path by using difference set of vertices at given distances. + /// In addition to the given , the first vertex and the last vertex are always added to the new path. + /// New vertices use the positions on the original path. Thus, s at are preserved. + /// + public void ResampleVertices(IEnumerable sampleDistances) + { + var sampledVertices = new List(); + + foreach (double distance in sampleDistances) + { + if (!double.IsFinite(distance)) + throw new ArgumentOutOfRangeException(nameof(sampleDistances)); + + double clampedDistance = Math.Clamp(distance, vertices[0].Distance, vertices[^1].Distance); + float x = PositionAtDistance(clampedDistance); + sampledVertices.Add(new JuiceStreamPathVertex(clampedDistance, x)); + } + + sampledVertices.Sort(); + + // The first vertex and the last vertex are always used in the result. + vertices.RemoveRange(1, vertices.Count - (vertices.Count == 1 ? 1 : 2)); + vertices.InsertRange(1, sampledVertices); + + invalidate(); + } + + /// + /// Convert a to list of vertices and write the result to this . + /// + /// + /// Duplicated vertices are automatically removed. + /// + public void ConvertFromSliderPath(SliderPath sliderPath) + { + var sliderPathVertices = new List(); + sliderPath.GetPathToProgress(sliderPathVertices, 0, 1); + + double distance = 0; + + vertices.Clear(); + vertices.Add(new JuiceStreamPathVertex(0, sliderPathVertices.FirstOrDefault().X)); + + for (int i = 1; i < sliderPathVertices.Count; i++) + { + distance += Vector2.Distance(sliderPathVertices[i - 1], sliderPathVertices[i]); + + if (!Precision.AlmostEquals(vertices[^1].Distance, distance)) + vertices.Add(new JuiceStreamPathVertex(distance, sliderPathVertices[i].X)); + } + + invalidate(); + } + + /// + /// Convert the path of this to a and write the result to . + /// The resulting slider is "folded" to make it vertically contained in the playfield `(0..)` assuming the slider start position is . + /// + public void ConvertToSliderPath(SliderPath sliderPath, float sliderStartY) + { + const float margin = 1; + + // Note: these two variables and `sliderPath` are modified by the local functions. + double currentDistance = 0; + Vector2 lastPosition = new Vector2(vertices[0].X, 0); + + sliderPath.ControlPoints.Clear(); + sliderPath.ControlPoints.Add(new PathControlPoint(lastPosition)); + + for (int i = 1; i < vertices.Count; i++) + { + sliderPath.ControlPoints[^1].Type.Value = PathType.Linear; + + float deltaX = vertices[i].X - lastPosition.X; + double length = vertices[i].Distance - currentDistance; + + // Should satisfy `deltaX^2 + deltaY^2 = length^2`. + // By invariants, the expression inside the `sqrt` is (almost) non-negative. + double deltaY = Math.Sqrt(Math.Max(0, length * length - (double)deltaX * deltaX)); + + // When `deltaY` is small, one segment is always enough. + // This case is handled separately to prevent divide-by-zero. + if (deltaY <= OSU_PLAYFIELD_HEIGHT / 2 - margin) + { + float nextX = vertices[i].X; + float nextY = (float)(lastPosition.Y + getYDirection() * deltaY); + addControlPoint(nextX, nextY); + continue; + } + + // When `deltaY` is large or when the slider velocity is fast, the segment must be partitioned to subsegments to stay in bounds. + for (double currentProgress = 0; currentProgress < deltaY;) + { + double nextProgress = Math.Min(currentProgress + getMaxDeltaY(), deltaY); + float nextX = (float)(vertices[i - 1].X + nextProgress / deltaY * deltaX); + float nextY = (float)(lastPosition.Y + getYDirection() * (nextProgress - currentProgress)); + addControlPoint(nextX, nextY); + currentProgress = nextProgress; + } + } + + int getYDirection() + { + float lastSliderY = sliderStartY + lastPosition.Y; + return lastSliderY < OSU_PLAYFIELD_HEIGHT / 2 ? 1 : -1; + } + + float getMaxDeltaY() + { + float lastSliderY = sliderStartY + lastPosition.Y; + return Math.Max(lastSliderY, OSU_PLAYFIELD_HEIGHT - lastSliderY) - margin; + } + + void addControlPoint(float nextX, float nextY) + { + Vector2 nextPosition = new Vector2(nextX, nextY); + sliderPath.ControlPoints.Add(new PathControlPoint(nextPosition)); + currentDistance += Vector2.Distance(lastPosition, nextPosition); + lastPosition = nextPosition; + } + } + + /// + /// Find the index at which a new vertex with can be inserted. + /// + private int vertexIndexAtDistance(double distance) + { + // The position of `(distance, Infinity)` is uniquely determined because infinite positions are not allowed. + int i = vertices.BinarySearch(new JuiceStreamPathVertex(distance, float.PositiveInfinity)); + return i < 0 ? ~i : i; + } + + /// + /// Compute the position at the given , assuming is the vertex index returned by . + /// + private float positionAtDistance(double distance, int index) + { + if (index <= 0) + return vertices[0].X; + if (index >= vertices.Count) + return vertices[^1].X; + + double length = vertices[index].Distance - vertices[index - 1].Distance; + if (Precision.AlmostEquals(length, 0)) + return vertices[index].X; + + float deltaX = vertices[index].X - vertices[index - 1].X; + + return (float)(vertices[index - 1].X + deltaX * ((distance - vertices[index - 1].Distance) / length)); + } + + /// + /// Check the two vertices can connected directly while satisfying the slope condition. + /// + private bool canConnect(JuiceStreamPathVertex vertex1, JuiceStreamPathVertex vertex2, float allowance = 0) + { + double xDistance = Math.Abs((double)vertex2.X - vertex1.X); + float length = (float)Math.Abs(vertex2.Distance - vertex1.Distance); + return xDistance <= length + allowance; + } + + /// + /// Move the position of towards the position of + /// until the vertex pair satisfies the condition . + /// + /// The resulting position of . + private float clampToConnectablePosition(JuiceStreamPathVertex fixedVertex, JuiceStreamPathVertex movableVertex) + { + float length = (float)Math.Abs(movableVertex.Distance - fixedVertex.Distance); + return Math.Clamp(movableVertex.X, fixedVertex.X - length, fixedVertex.X + length); + } + + private void invalidate() => InvalidationID++; + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs new file mode 100644 index 0000000000..58c50603c4 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +#nullable enable + +namespace osu.Game.Rulesets.Catch.Objects +{ + /// + /// A vertex of a . + /// + public readonly struct JuiceStreamPathVertex : IComparable + { + public readonly double Distance; + + public readonly float X; + + public JuiceStreamPathVertex(double distance, float x) + { + Distance = distance; + X = x; + } + + public int CompareTo(JuiceStreamPathVertex other) + { + int c = Distance.CompareTo(other.Distance); + return c != 0 ? c : X.CompareTo(other.X); + } + + public override string ToString() => $"({Distance}, {X})"; + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs index 0cd3af01df..4001a4ea76 100644 --- a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs @@ -1,9 +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.Collections.Generic; +using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Objects @@ -33,6 +34,7 @@ namespace osu.Game.Rulesets.Catch.Objects /// /// The target fruit if we are to initiate a hyperdash. /// + [JsonIgnore] public CatchHitObject HyperDashTarget { get => hyperDashTarget; @@ -43,6 +45,6 @@ namespace osu.Game.Rulesets.Catch.Objects } } - Color4 IHasComboInformation.GetComboColour(IReadOnlyList comboColours) => comboColours[(IndexInBeatmap + 1) % comboColours.Count]; + Color4 IHasComboInformation.GetComboColour(ISkin skin) => IHasComboInformation.GetSkinComboColour(this, skin, IndexInBeatmap + 1); } } diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchHitWindows.cs b/osu.Game.Rulesets.Catch/Scoring/CatchHitWindows.cs deleted file mode 100644 index 0a444d923e..0000000000 --- a/osu.Game.Rulesets.Catch/Scoring/CatchHitWindows.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Game.Rulesets.Scoring; - -namespace osu.Game.Rulesets.Catch.Scoring -{ - public class CatchHitWindows : HitWindows - { - public override bool IsHitResultAllowed(HitResult result) - { - switch (result) - { - case HitResult.Great: - case HitResult.Miss: - return true; - } - - return false; - } - } -} diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchSkinConfiguration.cs b/osu.Game.Rulesets.Catch/Skinning/CatchSkinConfiguration.cs new file mode 100644 index 0000000000..ea8d742b1a --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/CatchSkinConfiguration.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.Catch.Skinning +{ + public enum CatchSkinConfiguration + { + /// + /// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed. + /// + FlipCatcherPlate + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs index 51c06c8e37..2db3bae034 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/CatchHitObjectPiece.cs @@ -15,6 +15,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default { public readonly Bindable AccentColour = new Bindable(); public readonly Bindable HyperDash = new Bindable(); + public readonly Bindable IndexInBeatmap = new Bindable(); [Resolved] protected IHasCatchObjectState ObjectState { get; private set; } @@ -37,6 +38,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default AccentColour.BindTo(ObjectState.AccentColour); HyperDash.BindTo(ObjectState.HyperDash); + IndexInBeatmap.BindTo(ObjectState.IndexInBeatmap); HyperDash.BindValueChanged(hyper => { diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/DefaultCatcher.cs b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultCatcher.cs new file mode 100644 index 0000000000..e423f21b98 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Default/DefaultCatcher.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 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.Rulesets.Catch.UI; + +namespace osu.Game.Rulesets.Catch.Skinning.Default +{ + public class DefaultCatcher : CompositeDrawable + { + public Bindable CurrentState { get; } = new Bindable(); + + private readonly Sprite sprite; + + private readonly Dictionary textures = new Dictionary(); + + public DefaultCatcher() + { + RelativeSizeAxes = Axes.Both; + InternalChild = sprite = new Sprite + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit + }; + } + + [BackgroundDependencyLoader] + private void load(TextureStore store, Bindable currentState) + { + CurrentState.BindTo(currentState); + + textures[CatcherAnimationState.Idle] = store.Get(@"Gameplay/catch/fruit-catcher-idle"); + textures[CatcherAnimationState.Fail] = store.Get(@"Gameplay/catch/fruit-catcher-fail"); + textures[CatcherAnimationState.Kiai] = store.Get(@"Gameplay/catch/fruit-catcher-kiai"); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + CurrentState.BindValueChanged(state => sprite.Texture = textures[state.NewValue], true); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs index 49f128c960..cfe0df0c97 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPiece.cs @@ -3,7 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.Objects; namespace osu.Game.Rulesets.Catch.Skinning.Default { @@ -39,8 +39,10 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default { base.LoadComplete(); - var fruitState = (IHasFruitState)ObjectState; - VisualRepresentation.BindTo(fruitState.VisualRepresentation); + IndexInBeatmap.BindValueChanged(index => + { + VisualRepresentation.Value = Fruit.GetVisualRepresentation(index.NewValue); + }, true); } } } diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs index 88e0b5133a..f097361d2a 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Default/FruitPulpFormation.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.Objects; using osuTK; namespace osu.Game.Rulesets.Catch.Skinning.Default diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs index 8c9e602cd4..5e744ec001 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs @@ -17,8 +17,8 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy /// private bool providesComboCounter => this.HasFont(LegacyFont.Combo); - public CatchLegacySkinTransformer(ISkinSource source) - : base(source) + public CatchLegacySkinTransformer(ISkin skin) + : base(skin) { } @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy switch (targetComponent.Target) { case SkinnableTarget.MainHUDComponents: - var components = Source.GetDrawableComponent(component) as SkinnableTargetComponentsContainer; + var components = base.GetDrawableComponent(component) as SkinnableTargetComponentsContainer; if (providesComboCounter && components != null) { @@ -65,27 +65,31 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy return null; - case CatchSkinComponents.CatcherIdle: - return this.GetAnimation("fruit-catcher-idle", true, true, true) ?? - this.GetAnimation("fruit-ryuuta", true, true, true); + case CatchSkinComponents.Catcher: + var version = GetConfig(LegacySkinConfiguration.LegacySetting.Version)?.Value ?? 1; - case CatchSkinComponents.CatcherFail: - return this.GetAnimation("fruit-catcher-fail", true, true, true) ?? - this.GetAnimation("fruit-ryuuta", true, true, true); + if (version < 2.3m) + { + if (GetTexture(@"fruit-ryuuta") != null || + GetTexture(@"fruit-ryuuta-0") != null) + return new LegacyCatcherOld(); + } - case CatchSkinComponents.CatcherKiai: - return this.GetAnimation("fruit-catcher-kiai", true, true, true) ?? - this.GetAnimation("fruit-ryuuta", true, true, true); + if (GetTexture(@"fruit-catcher-idle") != null || + GetTexture(@"fruit-catcher-idle-0") != null) + return new LegacyCatcherNew(); + + return null; case CatchSkinComponents.CatchComboCounter: if (providesComboCounter) - return new LegacyCatchComboCounter(Source); + return new LegacyCatchComboCounter(Skin); return null; } } - return Source.GetDrawableComponent(component); + return base.GetDrawableComponent(component); } public override IBindable GetConfig(TLookup lookup) @@ -93,15 +97,28 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy switch (lookup) { case CatchSkinColour colour: - var result = (Bindable)Source.GetConfig(new SkinCustomColourLookup(colour)); + var result = (Bindable)base.GetConfig(new SkinCustomColourLookup(colour)); if (result == null) return null; result.Value = LegacyColourCompatibility.DisallowZeroAlpha(result.Value); return (IBindable)result; + + case CatchSkinConfiguration config: + switch (config) + { + case CatchSkinConfiguration.FlipCatcherPlate: + // Don't flip catcher plate contents if the catcher is provided by this legacy skin. + if (GetDrawableComponent(new CatchSkinComponent(CatchSkinComponents.Catcher)) != null) + return (IBindable)new Bindable(); + + break; + } + + break; } - return Source.GetConfig(lookup); + return base.GetConfig(lookup); } } } diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyBananaPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs similarity index 91% rename from osu.Game.Rulesets.Catch/Skinning/LegacyBananaPiece.cs rename to osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs index f80e50c8c0..5bd5b0d4bb 100644 --- a/osu.Game.Rulesets.Catch/Skinning/LegacyBananaPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyBananaPiece.cs @@ -3,7 +3,7 @@ using osu.Framework.Graphics.Textures; -namespace osu.Game.Rulesets.Catch.Skinning +namespace osu.Game.Rulesets.Catch.Skinning.Legacy { public class LegacyBananaPiece : LegacyCatchHitObjectPiece { diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyCatchHitObjectPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchHitObjectPiece.cs similarity index 94% rename from osu.Game.Rulesets.Catch/Skinning/LegacyCatchHitObjectPiece.cs rename to osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchHitObjectPiece.cs index 4b1f5a4724..f78724615a 100644 --- a/osu.Game.Rulesets.Catch/Skinning/LegacyCatchHitObjectPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatchHitObjectPiece.cs @@ -13,12 +13,13 @@ using osu.Game.Skinning; using osuTK; using osuTK.Graphics; -namespace osu.Game.Rulesets.Catch.Skinning +namespace osu.Game.Rulesets.Catch.Skinning.Legacy { public abstract class LegacyCatchHitObjectPiece : PoolableDrawable { public readonly Bindable AccentColour = new Bindable(); public readonly Bindable HyperDash = new Bindable(); + public readonly Bindable IndexInBeatmap = new Bindable(); private readonly Sprite colouredSprite; private readonly Sprite overlaySprite; @@ -64,6 +65,7 @@ namespace osu.Game.Rulesets.Catch.Skinning AccentColour.BindTo(ObjectState.AccentColour); HyperDash.BindTo(ObjectState.HyperDash); + IndexInBeatmap.BindTo(ObjectState.IndexInBeatmap); hyperSprite.Colour = Skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value ?? Skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherNew.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherNew.cs new file mode 100644 index 0000000000..9df87c92ea --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherNew.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Skinning.Legacy +{ + public class LegacyCatcherNew : CompositeDrawable + { + [Resolved] + private Bindable currentState { get; set; } + + private readonly Dictionary drawables = new Dictionary(); + + private Drawable currentDrawable; + + public LegacyCatcherNew() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + foreach (var state in Enum.GetValues(typeof(CatcherAnimationState)).Cast()) + { + AddInternal(drawables[state] = getDrawableFor(state).With(d => + { + d.Anchor = Anchor.TopCentre; + d.Origin = Anchor.TopCentre; + d.RelativeSizeAxes = Axes.Both; + d.Size = Vector2.One; + d.FillMode = FillMode.Fit; + d.Alpha = 0; + })); + } + + currentDrawable = drawables[CatcherAnimationState.Idle]; + + Drawable getDrawableFor(CatcherAnimationState state) => + skin.GetAnimation(@$"fruit-catcher-{state.ToString().ToLowerInvariant()}", true, true, true) ?? + skin.GetAnimation(@"fruit-catcher-idle", true, true, true); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + currentState.BindValueChanged(state => + { + currentDrawable.Alpha = 0; + currentDrawable = drawables[state.NewValue]; + currentDrawable.Alpha = 1; + + (currentDrawable as IFramedAnimation)?.GotoFrame(0); + }, true); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherOld.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherOld.cs new file mode 100644 index 0000000000..3e679171b2 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyCatcherOld.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Skinning.Legacy +{ + public class LegacyCatcherOld : CompositeDrawable + { + public LegacyCatcherOld() + { + RelativeSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + InternalChild = skin.GetAnimation(@"fruit-ryuuta", true, true, true).With(d => + { + d.Anchor = Anchor.TopCentre; + d.Origin = Anchor.TopCentre; + d.RelativeSizeAxes = Axes.Both; + d.Size = Vector2.One; + d.FillMode = FillMode.Fit; + }); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyDropletPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs similarity index 93% rename from osu.Game.Rulesets.Catch/Skinning/LegacyDropletPiece.cs rename to osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs index 8f4331d2a3..2c5cbe1e41 100644 --- a/osu.Game.Rulesets.Catch/Skinning/LegacyDropletPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyDropletPiece.cs @@ -4,7 +4,7 @@ using osu.Framework.Graphics.Textures; using osuTK; -namespace osu.Game.Rulesets.Catch.Skinning +namespace osu.Game.Rulesets.Catch.Skinning.Legacy { public class LegacyDropletPiece : LegacyCatchHitObjectPiece { diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs index 969cc38e5b..f002bab219 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/LegacyFruitPiece.cs @@ -1,23 +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.Bindables; -using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.Objects; namespace osu.Game.Rulesets.Catch.Skinning.Legacy { internal class LegacyFruitPiece : LegacyCatchHitObjectPiece { - public readonly Bindable VisualRepresentation = new Bindable(); - protected override void LoadComplete() { base.LoadComplete(); - var fruitState = (IHasFruitState)ObjectState; - VisualRepresentation.BindTo(fruitState.VisualRepresentation); - - VisualRepresentation.BindValueChanged(visual => setTexture(visual.NewValue), true); + IndexInBeatmap.BindValueChanged(index => + { + setTexture(Fruit.GetVisualRepresentation(index.NewValue)); + }, true); } private void setTexture(FruitVisualRepresentation visualRepresentation) diff --git a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs index 75feb21298..ad344ff2dd 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs @@ -25,9 +25,9 @@ namespace osu.Game.Rulesets.Catch.UI { } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void SkinChanged(ISkinSource skin) { - base.SkinChanged(skin, allowFallback); + base.SkinChanged(skin); ComboCounter?.UpdateCombo(currentCombo); } diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index 0e1ef90737..05cd29dff5 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -1,10 +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 osu.Framework.Allocation; 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.Drawables; @@ -28,20 +26,18 @@ namespace osu.Game.Rulesets.Catch.UI /// public const float CENTER_X = WIDTH / 2; + [Cached] + private readonly DroppedObjectContainer droppedObjectContainer; + 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) + public CatchPlayfield(BeatmapDifficulty difficulty) { - var droppedObjectContainer = new Container - { - RelativeSizeAxes = Axes.Both, - }; - - CatcherArea = new CatcherArea(droppedObjectContainer, difficulty) + CatcherArea = new CatcherArea(difficulty) { Anchor = Anchor.BottomLeft, Origin = Anchor.TopLeft, @@ -49,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.UI InternalChildren = new[] { - droppedObjectContainer, + droppedObjectContainer = new DroppedObjectContainer(), CatcherArea.MovableCatcher.CreateProxiedContent(), HitObjectContainer.CreateProxy(), // This ordering (`CatcherArea` before `HitObjectContainer`) is important to diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 0d6a577d1e..57523d3505 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -7,10 +7,8 @@ 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.Graphics.Pooling; -using osu.Framework.Input.Bindings; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -25,7 +23,7 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.UI { - public class Catcher : SkinReloadableDrawable, IKeyBindingHandler + public class Catcher : SkinReloadableDrawable { /// /// The default colour used to tint hyper-dash fruit, along with the moving catcher, its trail @@ -54,9 +52,9 @@ namespace osu.Game.Rulesets.Catch.UI public const double BASE_SPEED = 1.0; /// - /// The amount by which caught fruit should be offset from the plate surface to make them look visually "caught". + /// The current speed of the catcher. /// - public const float CAUGHT_FRUIT_VERTICAL_OFFSET = -5; + public double Speed => (Dashing ? 1 : 0.5) * BASE_SPEED * hyperDashModifier; /// /// The amount by which caught fruit should be scaled down to fit on the plate. @@ -76,26 +74,26 @@ namespace osu.Game.Rulesets.Catch.UI /// /// Contains objects dropped from the plate. /// - private readonly Container droppedObjectTarget; + [Resolved] + private DroppedObjectContainer droppedObjectTarget { get; set; } - public CatcherAnimationState CurrentState { get; private set; } + public CatcherAnimationState CurrentState + { + get => Body.AnimationState.Value; + private set => Body.AnimationState.Value = value; + } /// /// 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 + set { if (value == dashing) return; @@ -105,38 +103,40 @@ namespace osu.Game.Rulesets.Catch.UI } } + /// + /// The currently facing direction. + /// + public Direction VisualDirection { get; set; } = Direction.Right; + + /// + /// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed. + /// + private bool flipCatcherPlate; + /// /// Width of the area that can be used to attempt catches during gameplay. /// private readonly float catchWidth; - private readonly CatcherSprite catcherIdle; - private readonly CatcherSprite catcherKiai; - private readonly CatcherSprite catcherFail; - - private CatcherSprite currentCatcher; + internal readonly SkinnableCatcher Body; 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; - private readonly DrawablePool hitExplosionPool; - private readonly Container hitExplosionContainer; + private readonly HitExplosionContainer hitExplosionContainer; private readonly DrawablePool caughtFruitPool; private readonly DrawablePool caughtBananaPool; private readonly DrawablePool caughtDropletPool; - public Catcher([NotNull] Container trailsTarget, [NotNull] Container droppedObjectTarget, BeatmapDifficulty difficulty = null) + public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null) { this.trailsTarget = trailsTarget; - this.droppedObjectTarget = droppedObjectTarget; Origin = Anchor.TopCentre; @@ -148,7 +148,6 @@ namespace osu.Game.Rulesets.Catch.UI InternalChildren = new Drawable[] { - hitExplosionPool = new DrawablePool(10), caughtFruitPool = new DrawablePool(50), caughtBananaPool = new DrawablePool(100), // less capacity is needed compared to fruit because droplet is not stacked @@ -157,23 +156,11 @@ namespace osu.Game.Rulesets.Catch.UI { Anchor = Anchor.TopCentre, Origin = Anchor.BottomCentre, + // offset fruit vertically to better place "above" the plate. + Y = -5 }, - 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, - }, - hitExplosionContainer = new Container + Body = new SkinnableCatcher(), + hitExplosionContainer = new HitExplosionContainer { Anchor = Anchor.TopCentre, Origin = Anchor.BottomCentre, @@ -186,8 +173,6 @@ namespace osu.Game.Rulesets.Catch.UI { hitLighting = config.GetBindable(OsuSetting.HitLighting); trails = new CatcherTrailDisplay(this); - - updateCatcher(); } protected override void LoadComplete() @@ -275,17 +260,16 @@ namespace osu.Game.Rulesets.Catch.UI SetHyperDashState(); if (result.IsHit) - updateState(hitObject.Kiai ? CatcherAnimationState.Kiai : CatcherAnimationState.Idle); + CurrentState = hitObject.Kiai ? CatcherAnimationState.Kiai : CatcherAnimationState.Idle; else if (!(hitObject is Banana)) - updateState(CatcherAnimationState.Fail); + CurrentState = CatcherAnimationState.Fail; } public void OnRevertResult(DrawableCatchHitObject drawableObject, JudgementResult result) { var catchResult = (CatchJudgementResult)result; - if (CurrentState != catchResult.CatcherAnimationState) - updateState(catchResult.CatcherAnimationState); + CurrentState = catchResult.CatcherAnimationState; if (HyperDashing != catchResult.CatcherHyperDash) { @@ -297,7 +281,6 @@ namespace osu.Game.Rulesets.Catch.UI caughtObjectContainer.RemoveAll(d => d.HitObject == drawableObject.HitObject); droppedObjectTarget.RemoveAll(d => d.HitObject == drawableObject.HitObject); - hitExplosionContainer.RemoveAll(d => d.HitObject == drawableObject.HitObject); } /// @@ -331,55 +314,6 @@ namespace osu.Game.Rulesets.Catch.UI } } - 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; - } - - 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; - } - } - /// /// Drop any fruit off the plate. /// @@ -399,9 +333,9 @@ namespace osu.Game.Rulesets.Catch.UI private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void SkinChanged(ISkinSource skin) { - base.SkinChanged(skin, allowFallback); + base.SkinChanged(skin); hyperDashColour = skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? @@ -414,6 +348,8 @@ namespace osu.Game.Rulesets.Catch.UI trails.HyperDashTrailsColour = hyperDashColour; trails.EndGlowSpritesColour = hyperDashEndGlowColour; + flipCatcherPlate = skin.GetConfig(CatchSkinConfiguration.FlipCatcherPlate)?.Value ?? true; + runHyperDashStateTransition(HyperDashing); } @@ -421,14 +357,9 @@ namespace osu.Game.Rulesets.Catch.UI { 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)); + var scaleFromDirection = new Vector2((int)VisualDirection, 1); + Body.Scale = scaleFromDirection; + caughtObjectContainer.Scale = hitExplosionContainer.Scale = flipCatcherPlate ? scaleFromDirection : Vector2.One; // Correct overshooting. if ((hyperDashDirection > 0 && hyperDashTargetPosition < X) || @@ -439,38 +370,6 @@ namespace osu.Game.Rulesets.Catch.UI } } - 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 placeCaughtObject(DrawablePalpableCatchHitObject drawableObject, Vector2 position) { var caughtObject = getCaughtObject(drawableObject.HitObject); @@ -496,9 +395,6 @@ namespace osu.Game.Rulesets.Catch.UI float adjustedRadius = displayRadius * lenience_adjust; float checkDistance = MathF.Pow(adjustedRadius, 2); - // offset fruit vertically to better place "above" the plate. - position.Y += CAUGHT_FRUIT_VERTICAL_OFFSET; - while (caughtObjectContainer.Any(f => Vector2Extensions.DistanceSquared(f.Position, position) < checkDistance)) { position.X += RNG.NextSingle(-adjustedRadius, adjustedRadius); @@ -508,15 +404,8 @@ namespace osu.Game.Rulesets.Catch.UI return position; } - private void addLighting(CatchHitObject hitObject, float x, Color4 colour) - { - HitExplosion hitExplosion = hitExplosionPool.Get(); - hitExplosion.HitObject = hitObject; - hitExplosion.X = x; - hitExplosion.Scale = new Vector2(hitObject.Scale); - hitExplosion.ObjectColour = colour; - hitExplosionContainer.Add(hitExplosion); - } + private void addLighting(CatchHitObject hitObject, float x, Color4 colour) => + hitExplosionContainer.Add(new HitExplosionEntry(Time.Current, x, hitObject.Scale, colour, hitObject.RandomSeed)); private CaughtObject getCaughtObject(PalpableCatchHitObject source) { @@ -580,7 +469,7 @@ namespace osu.Game.Rulesets.Catch.UI break; case DroppedObjectAnimation.Explode: - var originalX = droppedObjectTarget.ToSpaceOfOtherDrawable(d.DrawPosition, caughtObjectContainer).X * Scale.X; + float originalX = droppedObjectTarget.ToSpaceOfOtherDrawable(d.DrawPosition, caughtObjectContainer).X * caughtObjectContainer.Scale.X; d.MoveToY(d.Y - 50, 250, Easing.OutSine).Then().MoveToY(d.Y + 50, 500, Easing.InSine); d.MoveToX(d.X + originalX * 6, 1000); d.FadeOut(750); diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index 44adbd5512..fea314df8d 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -1,8 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Catch.Objects.Drawables; @@ -14,14 +16,21 @@ using osuTK; namespace osu.Game.Rulesets.Catch.UI { - public class CatcherArea : Container + public class CatcherArea : Container, IKeyBindingHandler { public const float CATCHER_SIZE = 106.75f; public readonly Catcher MovableCatcher; private readonly CatchComboDisplay comboDisplay; - public CatcherArea(Container droppedObjectContainer, BeatmapDifficulty difficulty = null) + /// + /// -1 when only left button is pressed. + /// 1 when only right button is pressed. + /// 0 when none or both left and right buttons are pressed. + /// + private int currentDirection; + + public CatcherArea(BeatmapDifficulty difficulty = null) { Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); Children = new Drawable[] @@ -35,7 +44,7 @@ namespace osu.Game.Rulesets.Catch.UI Margin = new MarginPadding { Bottom = 350f }, X = CatchPlayfield.CENTER_X }, - MovableCatcher = new Catcher(this, droppedObjectContainer, difficulty) { X = CatchPlayfield.CENTER_X }, + MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }, }; } @@ -63,16 +72,73 @@ namespace osu.Game.Rulesets.Catch.UI MovableCatcher.OnRevertResult(hitObject, result); } + protected override void Update() + { + base.Update(); + + var replayState = (GetContainingInputManager().CurrentState as RulesetInputManagerInputState)?.LastReplayState as CatchFramedReplayInputHandler.CatchReplayState; + + SetCatcherPosition( + replayState?.CatcherX ?? + (float)(MovableCatcher.X + MovableCatcher.Speed * currentDirection * Clock.ElapsedFrameTime)); + } + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - var state = (GetContainingInputManager().CurrentState as RulesetInputManagerInputState)?.LastReplayState as CatchFramedReplayInputHandler.CatchReplayState; - - if (state?.CatcherX != null) - MovableCatcher.X = state.CatcherX.Value; - comboDisplay.X = MovableCatcher.X; } + + public void SetCatcherPosition(float X) + { + float lastPosition = MovableCatcher.X; + float newPosition = Math.Clamp(X, 0, CatchPlayfield.WIDTH); + + MovableCatcher.X = newPosition; + + if (lastPosition < newPosition) + MovableCatcher.VisualDirection = Direction.Right; + else if (lastPosition > newPosition) + MovableCatcher.VisualDirection = Direction.Left; + } + + public bool OnPressed(CatchAction action) + { + switch (action) + { + case CatchAction.MoveLeft: + currentDirection--; + return true; + + case CatchAction.MoveRight: + currentDirection++; + return true; + + case CatchAction.Dash: + MovableCatcher.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: + MovableCatcher.Dashing = false; + break; + } + } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs b/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs deleted file mode 100644 index ef69e3d2d1..0000000000 --- a/osu.Game.Rulesets.Catch/UI/CatcherSprite.cs +++ /dev/null @@ -1,59 +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.Sprites; -using osu.Framework.Graphics.Textures; -using osu.Game.Skinning; -using osuTK; - -namespace osu.Game.Rulesets.Catch.UI -{ - public class CatcherSprite : SkinnableDrawable - { - 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.5f, 0.06f) * CatcherArea.CATCHER_SIZE; - } - - private static CatchSkinComponents componentFromState(CatcherAnimationState state) - { - switch (state) - { - 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/CatcherTrail.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs new file mode 100644 index 0000000000..c961d98dc5 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrail.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.Pooling; +using osu.Framework.Timing; +using osuTK; + +namespace osu.Game.Rulesets.Catch.UI +{ + /// + /// A trail of the catcher. + /// It also represents a hyper dash afterimage. + /// + public class CatcherTrail : PoolableDrawable + { + public CatcherAnimationState AnimationState + { + set => body.AnimationState.Value = value; + } + + private readonly SkinnableCatcher body; + + public CatcherTrail() + { + Size = new Vector2(CatcherArea.CATCHER_SIZE); + Origin = Anchor.TopCentre; + Blending = BlendingParameters.Additive; + InternalChild = body = new SkinnableCatcher + { + // Using a frozen clock because trails should not be animated when the skin has an animated catcher. + // TODO: The animation should be frozen at the animation frame at the time of the trail generation. + Clock = new FramedClock(new ManualClock()), + }; + } + + protected override void FreeAfterUse() + { + ClearTransforms(); + Alpha = 1; + base.FreeAfterUse(); + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs index fa65190032..b59fabcb70 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs @@ -4,10 +4,8 @@ using System; using JetBrains.Annotations; using osu.Framework.Graphics; -using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; -using osu.Framework.Graphics.Sprites; using osuTK; using osuTK.Graphics; @@ -21,11 +19,11 @@ namespace osu.Game.Rulesets.Catch.UI { private readonly Catcher catcher; - private readonly DrawablePool trailPool; + private readonly DrawablePool trailPool; - private readonly Container dashTrails; - private readonly Container hyperDashTrails; - private readonly Container endGlowSprites; + private readonly Container dashTrails; + private readonly Container hyperDashTrails; + private readonly Container endGlowSprites; private Color4 hyperDashTrailsColour = Catcher.DEFAULT_HYPER_DASH_COLOUR; @@ -85,10 +83,10 @@ namespace osu.Game.Rulesets.Catch.UI InternalChildren = new Drawable[] { - trailPool = new DrawablePool(30), - 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 }, + trailPool = new DrawablePool(30), + 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 }, }; } @@ -118,17 +116,12 @@ namespace osu.Game.Rulesets.Catch.UI Scheduler.AddDelayed(displayTrail, catcher.HyperDashing ? 25 : 50); } - private CatcherTrailSprite createTrailSprite(Container target) + private CatcherTrail createTrailSprite(Container target) { - var texture = (catcher.CurrentDrawableCatcher as TextureAnimation)?.CurrentFrame ?? ((Sprite)catcher.CurrentDrawableCatcher).Texture; + CatcherTrail sprite = trailPool.Get(); - CatcherTrailSprite sprite = trailPool.Get(); - - sprite.Texture = texture; - sprite.Anchor = catcher.Anchor; - sprite.Scale = catcher.Scale; - sprite.Blending = BlendingParameters.Additive; - sprite.RelativePositionAxes = catcher.RelativePositionAxes; + sprite.AnimationState = catcher.CurrentState; + sprite.Scale = catcher.Scale * catcher.Body.Scale; sprite.Position = catcher.Position; target.Add(sprite); diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.cs deleted file mode 100644 index 0e3e409fac..0000000000 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrailSprite.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.Framework.Graphics; -using osu.Framework.Graphics.Pooling; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.Textures; -using osuTK; - -namespace osu.Game.Rulesets.Catch.UI -{ - public class CatcherTrailSprite : PoolableDrawable - { - public Texture Texture - { - set => sprite.Texture = value; - } - - private readonly Sprite sprite; - - public CatcherTrailSprite() - { - InternalChild = sprite = new Sprite - { - RelativeSizeAxes = Axes.Both - }; - - 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; - } - - protected override void FreeAfterUse() - { - ClearTransforms(); - base.FreeAfterUse(); - } - } -} diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePosition.cs b/osu.Game.Rulesets.Catch/UI/Direction.cs similarity index 60% rename from osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePosition.cs rename to osu.Game.Rulesets.Catch/UI/Direction.cs index 219dad566d..65f064b7fb 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePosition.cs +++ b/osu.Game.Rulesets.Catch/UI/Direction.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. -namespace osu.Game.Rulesets.Mania.Edit.Blueprints +namespace osu.Game.Rulesets.Catch.UI { - public enum HoldNotePosition + public enum Direction { - Start, - End + Right = 1, + Left = -1 } } diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs index 9389fa803b..8b6a074426 100644 --- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Catch.UI protected override ReplayRecorder CreateReplayRecorder(Score score) => new CatchReplayRecorder(score, (CatchPlayfield)Playfield); - protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty, CreateDrawableRepresentation); + protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty); public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchPlayfieldAdjustmentContainer(); diff --git a/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs b/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.cs new file mode 100644 index 0000000000..b44b0caae4 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/DroppedObjectContainer.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; +using osu.Game.Rulesets.Catch.Objects.Drawables; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class DroppedObjectContainer : Container + { + public DroppedObjectContainer() + { + RelativeSizeAxes = Axes.Both; + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs index 26627422e1..d9ab428231 100644 --- a/osu.Game.Rulesets.Catch/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Catch/UI/HitExplosion.cs @@ -5,31 +5,16 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Pooling; using osu.Framework.Utils; -using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Objects.Pooling; +using osu.Game.Utils; using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.UI { - public class HitExplosion : PoolableDrawable + public class HitExplosion : PoolableDrawableWithLifetime { - private Color4 objectColour; - public CatchHitObject HitObject; - - public Color4 ObjectColour - { - get => objectColour; - set - { - if (objectColour == value) return; - - objectColour = value; - onColourChanged(); - } - } - private readonly CircularContainer largeFaint; private readonly CircularContainer smallFaint; private readonly CircularContainer directionalGlow1; @@ -83,9 +68,19 @@ namespace osu.Game.Rulesets.Catch.UI }; } - protected override void PrepareForUse() + protected override void OnApply(HitExplosionEntry entry) { - base.PrepareForUse(); + X = entry.Position; + Scale = new Vector2(entry.Scale); + setColour(entry.ObjectColour); + + using (BeginAbsoluteSequence(entry.LifetimeStart)) + applyTransforms(entry.RNGSeed); + } + + private void applyTransforms(int randomSeed) + { + ClearTransforms(true); const double duration = 400; @@ -96,14 +91,13 @@ namespace osu.Game.Rulesets.Catch.UI .FadeOut(duration * 2); const float angle_variangle = 15; // should be less than 45 - directionalGlow1.Rotation = RNG.NextSingle(-angle_variangle, angle_variangle); - directionalGlow2.Rotation = RNG.NextSingle(-angle_variangle, angle_variangle); + directionalGlow1.Rotation = StatelessRNG.NextSingle(-angle_variangle, angle_variangle, randomSeed, 4); + directionalGlow2.Rotation = StatelessRNG.NextSingle(-angle_variangle, angle_variangle, randomSeed, 5); - this.FadeInFromZero(50).Then().FadeOut(duration, Easing.Out); - Expire(true); + this.FadeInFromZero(50).Then().FadeOut(duration, Easing.Out).Expire(); } - private void onColourChanged() + private void setColour(Color4 objectColour) { const float roundness = 100; diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs b/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.cs new file mode 100644 index 0000000000..094d88243a --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/HitExplosionContainer.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.Pooling; +using osu.Game.Rulesets.Objects.Pooling; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class HitExplosionContainer : PooledDrawableWithLifetimeContainer + { + protected override bool RemoveRewoundEntry => true; + + private readonly DrawablePool pool; + + public HitExplosionContainer() + { + AddInternal(pool = new DrawablePool(10)); + } + + protected override HitExplosion GetDrawable(HitExplosionEntry entry) => pool.Get(d => d.Apply(entry)); + } +} diff --git a/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs b/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.cs new file mode 100644 index 0000000000..b142962a8a --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/HitExplosionEntry.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.Performance; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class HitExplosionEntry : LifetimeEntry + { + public readonly float Position; + public readonly float Scale; + public readonly Color4 ObjectColour; + public readonly int RNGSeed; + + public HitExplosionEntry(double startTime, float position, float scale, Color4 objectColour, int rngSeed) + { + LifetimeStart = startTime; + Position = position; + Scale = scale; + ObjectColour = objectColour; + RNGSeed = rngSeed; + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs b/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs new file mode 100644 index 0000000000..fc34ba4c8b --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Catch.Skinning.Default; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Rulesets.Catch.UI +{ + /// + /// The visual representation of the . + /// It includes the body part of the catcher and the catcher plate. + /// + public class SkinnableCatcher : SkinnableDrawable + { + /// + /// This is used by skin elements to determine which texture of the catcher is used. + /// + [Cached] + public readonly Bindable AnimationState = new Bindable(); + + public SkinnableCatcher() + : base(new CatchSkinComponent(CatchSkinComponents.Catcher), _ => new DefaultCatcher()) + { + Anchor = Anchor.TopCentre; + // 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.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs index 176fbba921..124e1a35f9 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/ManiaSelectionBlueprintTestScene.cs @@ -1,31 +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 osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Timing; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Tests.Visual; -using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Tests.Editor { public abstract class ManiaSelectionBlueprintTestScene : SelectionBlueprintTestScene { - [Cached(Type = typeof(IAdjustableClock))] - private readonly IAdjustableClock clock = new StopwatchClock(); + protected override Container Content => blueprints ?? base.Content; - protected ManiaSelectionBlueprintTestScene() + private readonly Container blueprints; + + [Cached(typeof(Playfield))] + public Playfield Playfield { get; } + + private readonly ScrollingTestContainer scrollingTestContainer; + + protected ScrollingDirection Direction { - Add(new Column(0) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AccentColour = Color4.OrangeRed, - Clock = new FramedClock(new StopwatchClock()), // No scroll - }); + set => scrollingTestContainer.Direction = value; } - public ManiaPlayfield Playfield => null; + protected ManiaSelectionBlueprintTestScene(int columns) + { + var stageDefinitions = new List { new StageDefinition { Columns = columns } }; + base.Content.Child = scrollingTestContainer = new ScrollingTestContainer(ScrollingDirection.Up) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + Playfield = new ManiaPlayfield(stageDefinitions) + { + RelativeSizeAxes = Axes.Both, + }, + blueprints = new Container + { + RelativeSizeAxes = Axes.Both + } + } + }; + + AddToggleStep("Downward scroll", b => Direction = b ? ScrollingDirection.Down : ScrollingDirection.Up); + } } } diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs index 5e99264d7d..9953b8e3c0 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneHoldNoteSelectionBlueprint.cs @@ -1,55 +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.Beatmaps.ControlPoints; -using osu.Game.Graphics; 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.Editor { public class TestSceneHoldNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene { - private readonly DrawableHoldNote drawableObject; - - protected override Container Content => content ?? base.Content; - private readonly Container content; - public TestSceneHoldNoteSelectionBlueprint() + : base(4) { - var holdNote = new HoldNote { Column = 0, Duration = 1000 }; - holdNote.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - - base.Content.Child = content = new ScrollingTestContainer(ScrollingDirection.Down) + for (int i = 0; i < 4; i++) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Y, - Width = 50, - Child = drawableObject = new DrawableHoldNote(holdNote) + var holdNote = new HoldNote { - Height = 300, - AccentColour = { Value = OsuColour.Gray(0.3f) } - } - }; + Column = i, + StartTime = i * 100, + Duration = 500 + }; + holdNote.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - AddBlueprint(new HoldNoteSelectionBlueprint(holdNote), drawableObject); - } - - protected override void Update() - { - base.Update(); - - foreach (var nested in drawableObject.NestedHitObjects) - { - double finalPosition = (nested.HitObject.StartTime - drawableObject.HitObject.StartTime) / drawableObject.HitObject.Duration; - nested.Y = (float)(-finalPosition * content.DrawHeight); + var drawableHitObject = new DrawableHoldNote(holdNote); + Playfield.Add(drawableHitObject); + AddBlueprint(new HoldNoteSelectionBlueprint(holdNote), drawableHitObject); } } } diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs index 8474279b01..01d80881fa 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaHitObjectComposer.cs @@ -12,7 +12,7 @@ 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.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.Mania.Skinning.Default; @@ -184,8 +184,8 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor AddAssert("head note positioned correctly", () => Precision.AlmostEquals(holdNote.ScreenSpaceDrawQuad.BottomLeft, holdNote.Head.ScreenSpaceDrawQuad.BottomLeft)); AddAssert("tail note positioned correctly", () => Precision.AlmostEquals(holdNote.ScreenSpaceDrawQuad.TopLeft, holdNote.Tail.ScreenSpaceDrawQuad.BottomLeft)); - AddAssert("head blueprint positioned correctly", () => this.ChildrenOfType().ElementAt(0).DrawPosition == holdNote.Head.DrawPosition); - AddAssert("tail blueprint positioned correctly", () => this.ChildrenOfType().ElementAt(1).DrawPosition == holdNote.Tail.DrawPosition); + AddAssert("head blueprint positioned correctly", () => this.ChildrenOfType().ElementAt(0).DrawPosition == holdNote.Head.DrawPosition); + AddAssert("tail blueprint positioned correctly", () => this.ChildrenOfType().ElementAt(1).DrawPosition == holdNote.Tail.DrawPosition); } private void setScrollStep(ScrollingDirection direction) diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs index 9c3ad0b4ff..3586eecc44 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneNoteSelectionBlueprint.cs @@ -1,40 +1,32 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; 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; -using osuTK; namespace osu.Game.Rulesets.Mania.Tests.Editor { public class TestSceneNoteSelectionBlueprint : ManiaSelectionBlueprintTestScene { - protected override Container Content => content ?? base.Content; - private readonly Container content; - public TestSceneNoteSelectionBlueprint() + : base(4) { - var note = new Note { Column = 0 }; - note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - - DrawableNote drawableObject; - - base.Content.Child = content = new ScrollingTestContainer(ScrollingDirection.Down) + for (int i = 0; i < 4; i++) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(50, 20), - Child = drawableObject = new DrawableNote(note) - }; + var note = new Note + { + Column = i, + StartTime = i * 200, + }; + note.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); - AddBlueprint(new NoteSelectionBlueprint(note), drawableObject); + var drawableHitObject = new DrawableNote(note); + Playfield.Add(drawableHitObject); + AddBlueprint(new NoteSelectionBlueprint(note), drawableHitObject); + } } } } 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 b2a0912d19..6df555617b 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 @@ -4,7 +4,7 @@ - + diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs index 0b58d1efc6..628d77107f 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs @@ -7,7 +7,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty { public class ManiaDifficultyAttributes : DifficultyAttributes { - public double GreatHitWindow; - public double ScoreMultiplier; + public double GreatHitWindow { get; set; } + public double ScoreMultiplier { get; set; } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index 8c0b9ed8b7..a7a6677b68 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -68,7 +68,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty // 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, Mod[] mods) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { new Strain(mods, ((ManiaBeatmap)beatmap).TotalColumns) }; diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index 00bec18a45..b04ff3548f 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -37,15 +36,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty { mods = Score.Mods; scaledScore = Score.TotalScore; - 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; + countPerfect = Score.Statistics.GetValueOrDefault(HitResult.Perfect); + countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); + countGood = Score.Statistics.GetValueOrDefault(HitResult.Good); + countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); IEnumerable scoreIncreaseMods = Ruleset.GetModsFor(ModType.DifficultyIncrease); diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteOverlay.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteOverlay.cs deleted file mode 100644 index 6933571be8..0000000000 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteNoteOverlay.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; -using osu.Game.Rulesets.Mania.Objects.Drawables; - -namespace osu.Game.Rulesets.Mania.Edit.Blueprints -{ - public class HoldNoteNoteOverlay : CompositeDrawable - { - private readonly HoldNoteSelectionBlueprint holdNoteBlueprint; - private readonly HoldNotePosition position; - - public HoldNoteNoteOverlay(HoldNoteSelectionBlueprint holdNoteBlueprint, HoldNotePosition position) - { - this.holdNoteBlueprint = holdNoteBlueprint; - this.position = position; - - InternalChild = new EditNotePiece { RelativeSizeAxes = Axes.X }; - } - - protected override void Update() - { - base.Update(); - - var drawableObject = holdNoteBlueprint.DrawableObject; - - // Todo: This shouldn't exist, mania should not reference the drawable hitobject directly. - if (drawableObject.IsLoaded) - { - DrawableNote note = position == HoldNotePosition.Start ? (DrawableNote)drawableObject.Head : drawableObject.Tail; - - Anchor = note.Anchor; - Origin = note.Origin; - - Size = note.DrawSize; - Position = note.DrawPosition; - } - } - } -} diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs index d04c5cd4aa..5259fcbd5f 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNoteSelectionBlueprint.cs @@ -2,14 +2,13 @@ // 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.Primitives; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; +using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables; using osu.Game.Rulesets.UI.Scrolling; using osuTK; @@ -17,13 +16,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public class HoldNoteSelectionBlueprint : ManiaSelectionBlueprint { - public new DrawableHoldNote DrawableObject => (DrawableHoldNote)base.DrawableObject; - - private readonly IBindable direction = new Bindable(); - [Resolved] private OsuColour colours { get; set; } + private EditNotePiece head; + private EditNotePiece tail; + public HoldNoteSelectionBlueprint(HoldNote hold) : base(hold) { @@ -32,12 +30,10 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints [BackgroundDependencyLoader] private void load(IScrollingInfo scrollingInfo) { - direction.BindTo(scrollingInfo.Direction); - InternalChildren = new Drawable[] { - new HoldNoteNoteOverlay(this, HoldNotePosition.Start), - new HoldNoteNoteOverlay(this, HoldNotePosition.End), + head = new EditNotePiece { RelativeSizeAxes = Axes.X }, + tail = new EditNotePiece { RelativeSizeAxes = Axes.X }, new Container { RelativeSizeAxes = Axes.Both, @@ -58,21 +54,13 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.Update(); - // 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 -= DrawableObject.Tail.DrawHeight; - } + head.Y = HitObjectContainer.PositionAtTime(HitObject.Head.StartTime, HitObject.StartTime); + tail.Y = HitObjectContainer.PositionAtTime(HitObject.Tail.StartTime, HitObject.StartTime); + Height = HitObjectContainer.LengthAtTime(HitObject.StartTime, HitObject.EndTime) + tail.DrawHeight; } public override Quad SelectionQuad => ScreenSpaceDrawQuad; - public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.Head.ScreenSpaceDrawQuad.Centre; + public override Vector2 ScreenSpaceSelectionPoint => head.ScreenSpaceDrawQuad.Centre; } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs index e744bd3c83..955336db57 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaSelectionBlueprint.cs @@ -5,20 +5,23 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects; -using osu.Game.Rulesets.Mania.Objects.Drawables; +using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; -using osuTK; namespace osu.Game.Rulesets.Mania.Edit.Blueprints { public abstract class ManiaSelectionBlueprint : HitObjectSelectionBlueprint where T : ManiaHitObject { - public new DrawableManiaHitObject DrawableObject => (DrawableManiaHitObject)base.DrawableObject; + [Resolved] + private Playfield playfield { get; set; } [Resolved] private IScrollingInfo scrollingInfo { get; set; } + protected ScrollingHitObjectContainer HitObjectContainer => ((ManiaPlayfield)playfield).GetColumn(HitObject.Column).HitObjectContainer; + protected ManiaSelectionBlueprint(T hitObject) : base(hitObject) { @@ -29,19 +32,13 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { base.Update(); - Position = Parent.ToLocalSpace(DrawableObject.ToScreenSpace(Vector2.Zero)); - } + var anchor = scrollingInfo.Direction.Value == ScrollingDirection.Up ? Anchor.TopCentre : Anchor.BottomCentre; + Anchor = Origin = anchor; + foreach (var child in InternalChildren) + child.Anchor = child.Origin = anchor; - public override void Show() - { - DrawableObject.AlwaysAlive = true; - base.Show(); - } - - public override void Hide() - { - DrawableObject.AlwaysAlive = false; - base.Hide(); + Position = Parent.ToLocalSpace(HitObjectContainer.ScreenSpacePositionAtTime(HitObject.StartTime)) - AnchorPosition; + Width = HitObjectContainer.DrawWidth; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs index e2b6ee0048..e7a03905d2 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NoteSelectionBlueprint.cs @@ -14,14 +14,5 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints { AddInternal(new EditNotePiece { RelativeSizeAxes = Axes.X }); } - - protected override void Update() - { - base.Update(); - - // 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/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index fbb9b3c466..fe736766d9 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets.Mania public override HitObjectComposer CreateHitObjectComposer() => new ManiaHitObjectComposer(this); - public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new ManiaLegacySkinTransformer(source, beatmap); + public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new ManiaLegacySkinTransformer(skin, beatmap); public override IEnumerable ConvertFromLegacyMods(LegacyMods mods) { diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index 1c89d9cd00..36fa336d0c 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mania.Configuration; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Mania { public class ManiaSettingsSubsection : RulesetSettingsSubsection { - protected override string Header => "osu!mania"; + protected override LocalisableString Header => "osu!mania"; public ManiaSettingsSubsection(ManiaRuleset ruleset) : base(ruleset) @@ -47,7 +48,7 @@ namespace osu.Game.Rulesets.Mania private class TimeSlider : OsuSliderBar { - public override string TooltipText => Current.Value.ToString("N0") + "ms"; + public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms"; } } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs index 8fd5950dfb..050b302bd8 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs @@ -15,7 +15,6 @@ namespace osu.Game.Rulesets.Mania.Mods public abstract int KeyCount { get; } public override ModType Type => ModType.Conversion; public override double ScoreMultiplier => 1; // TODO: Implement the mania key mod score multiplier - public override bool Ranked => true; public void ApplyToBeatmapConverter(IBeatmapConverter beatmapConverter) { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs index 105d88129c..6ae854e7f3 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModAutoplay.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; 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; @@ -12,7 +11,7 @@ using osu.Game.Users; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModAutoplay : ModAutoplay + public class ManiaModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs index 078394b1d8..614ef76a3b 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs @@ -24,8 +24,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override ModType Type => ModType.Conversion; - public override bool Ranked => false; - public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { var maniaRuleset = (DrawableManiaRuleset)drawableRuleset; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs index 12f379bddb..cf404cc98e 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs @@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override ModType Type => ModType.Conversion; public override string Description => "Notes are flipped horizontally."; public override double ScoreMultiplier => 1; - public override bool Ranked => true; public void ApplyToBeatmap(IBeatmap beatmap) { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs index 699c58c373..6f2d4fe91e 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Utils; @@ -17,8 +18,11 @@ namespace osu.Game.Rulesets.Mania.Mods public void ApplyToBeatmap(IBeatmap beatmap) { + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + var availableColumns = ((ManiaBeatmap)beatmap).TotalColumns; - var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(item => RNG.Next()).ToList(); + var shuffledColumns = Enumerable.Range(0, availableColumns).OrderBy(item => rng.Next()).ToList(); beatmap.HitObjects.OfType().ForEach(h => h.Column = shuffledColumns[h.Column]); } diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index 380ab35339..5aff4e200b 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -22,6 +22,10 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables protected readonly IBindable Direction = new Bindable(); + // Leaving the default (10s) makes hitobjects not appear, as this offset is used for the initial state transforms. + // Calculated as DrawableManiaRuleset.MAX_TIME_RANGE + some additional allowance for velocity < 1. + protected override double InitialLifetimeOffset => 30000; + [Resolved(canBeNull: true)] private ManiaPlayfield playfield { get; set; } @@ -85,63 +89,6 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables AccentColour.UnbindFrom(ParentHitObject.AccentColour); } - 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; diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index 261b8b1fad..814a737034 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -50,29 +50,25 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy { HitResult.Miss, "mania-hit0" } }; - private Lazy isLegacySkin; + private readonly Lazy isLegacySkin; /// /// Whether texture for the keys exists. /// Used to determine if the mania ruleset is skinned. /// - private Lazy hasKeyTexture; + private readonly Lazy hasKeyTexture; - public ManiaLegacySkinTransformer(ISkinSource source, IBeatmap beatmap) - : base(source) + public ManiaLegacySkinTransformer(ISkin skin, IBeatmap beatmap) + : base(skin) { 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); + isLegacySkin = new Lazy(() => GetConfig(LegacySkinConfiguration.LegacySetting.Version) != null); + hasKeyTexture = new Lazy(() => + { + var keyImage = this.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.KeyImage, 0)?.Value ?? "mania-key1"; + return this.GetAnimation(keyImage, true, true) != null; + }); } public override Drawable GetDrawableComponent(ISkinComponent component) @@ -125,7 +121,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy break; } - return Source.GetDrawableComponent(component); + return base.GetDrawableComponent(component); } private Drawable getResult(HitResult result) @@ -146,15 +142,15 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample && legacySample.IsLayered) return new SampleVirtual(); - return Source.GetSample(sampleInfo); + return base.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 base.GetConfig(new LegacyManiaSkinConfigurationLookup(beatmap.TotalColumns, maniaLookup.Lookup, maniaLookup.TargetColumn)); - return Source.GetConfig(lookup); + return base.GetConfig(lookup); } } } diff --git a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs index 8f7880dafa..b75b586ecf 100644 --- a/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs +++ b/osu.Game.Rulesets.Mania/UI/Components/HitObjectArea.cs @@ -33,9 +33,9 @@ namespace osu.Game.Rulesets.Mania.UI.Components Direction.BindValueChanged(onDirectionChanged, true); } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void SkinChanged(ISkinSource skin) { - base.SkinChanged(skin, allowFallback); + base.SkinChanged(skin); UpdateHitPosition(); } diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index e497646a13..614a7b00c7 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -33,12 +33,12 @@ namespace osu.Game.Rulesets.Mania.UI /// /// The minimum time range. This occurs at a of 40. /// - public const double MIN_TIME_RANGE = 340; + public const double MIN_TIME_RANGE = 290; /// /// The maximum time range. This occurs at a of 1. /// - public const double MAX_TIME_RANGE = 13720; + public const double MAX_TIME_RANGE = 11485; protected new ManiaPlayfield Playfield => (ManiaPlayfield)base.Playfield; @@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Mania.UI protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod; private readonly Bindable configDirection = new Bindable(); - private readonly Bindable configTimeRange = new BindableDouble(); + private readonly BindableDouble configTimeRange = new BindableDouble(); // Stores the current speed adjustment active in gameplay. private readonly Track speedAdjustmentTrack = new TrackVirtual(0); @@ -103,6 +103,8 @@ namespace osu.Game.Rulesets.Mania.UI configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true); Config.BindWith(ManiaRulesetSetting.ScrollTime, configTimeRange); + TimeRange.MinValue = configTimeRange.MinValue; + TimeRange.MaxValue = configTimeRange.MaxValue; } protected override void AdjustScrollSpeed(int amount) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs new file mode 100644 index 0000000000..fd17d11d10 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckLowDiffOverlapsTest.cs @@ -0,0 +1,260 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit.Checks; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks +{ + [TestFixture] + public class CheckLowDiffOverlapsTest + { + private CheckLowDiffOverlaps check; + + [SetUp] + public void Setup() + { + check = new CheckLowDiffOverlaps(); + } + + [Test] + public void TestNoOverlapFarApart() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(200, 0) } + } + }); + } + + [Test] + public void TestNoOverlapClose() + { + assertShouldProbablyOverlap(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 167, Position = new Vector2(200, 0) } + } + }); + } + + [Test] + public void TestNoOverlapTooClose() + { + assertShouldOverlap(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 100, Position = new Vector2(200, 0) } + } + }); + } + + [Test] + public void TestNoOverlapTooCloseExpert() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 100, Position = new Vector2(200, 0) } + } + }, DifficultyRating.Expert); + } + + [Test] + public void TestOverlapClose() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 167, Position = new Vector2(20, 0) } + } + }); + } + + [Test] + public void TestOverlapFarApart() + { + assertShouldNotOverlap(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(20, 0) } + } + }); + } + + [Test] + public void TestAlmostOverlapFarApart() + { + assertOk(new Beatmap + { + HitObjects = new List + { + // Default circle diameter is 128 px, but part of that is the fade/border of the circle. + // We want this to only be a problem when it actually looks like an overlap. + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(125, 0) } + } + }); + } + + [Test] + public void TestAlmostNotOverlapFarApart() + { + assertShouldNotOverlap(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(110, 0) } + } + }); + } + + [Test] + public void TestOverlapFarApartExpert() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(20, 0) } + } + }, DifficultyRating.Expert); + } + + [Test] + public void TestOverlapTooFarApart() + { + // Far apart enough to where the objects are not visible at the same time, and so overlapping is fine. + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 2000, Position = new Vector2(20, 0) } + } + }); + } + + [Test] + public void TestSliderTailOverlapFarApart() + { + assertShouldNotOverlap(new Beatmap + { + HitObjects = new List + { + getSliderMock(startTime: 0, endTime: 500, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object, + new HitCircle { StartTime = 1000, Position = new Vector2(120, 0) } + } + }); + } + + [Test] + public void TestSliderTailOverlapClose() + { + assertOk(new Beatmap + { + HitObjects = new List + { + getSliderMock(startTime: 0, endTime: 900, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object, + new HitCircle { StartTime = 1000, Position = new Vector2(120, 0) } + } + }); + } + + [Test] + public void TestSliderTailNoOverlapFarApart() + { + assertOk(new Beatmap + { + HitObjects = new List + { + getSliderMock(startTime: 0, endTime: 500, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object, + new HitCircle { StartTime = 1000, Position = new Vector2(300, 0) } + } + }); + } + + [Test] + public void TestSliderTailNoOverlapClose() + { + // If these were circles they would need to overlap, but overlapping with slider tails is not required. + assertOk(new Beatmap + { + HitObjects = new List + { + getSliderMock(startTime: 0, endTime: 900, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object, + new HitCircle { StartTime = 1000, Position = new Vector2(300, 0) } + } + }); + } + + private Mock getSliderMock(double startTime, double endTime, Vector2 startPosition, Vector2 endPosition) + { + var mockSlider = new Mock(); + mockSlider.SetupGet(s => s.StartTime).Returns(startTime); + mockSlider.SetupGet(s => s.Position).Returns(startPosition); + mockSlider.SetupGet(s => s.EndPosition).Returns(endPosition); + mockSlider.As().Setup(d => d.EndTime).Returns(endTime); + + return mockSlider; + } + + private void assertOk(IBeatmap beatmap, DifficultyRating difficultyRating = DifficultyRating.Easy) + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating); + Assert.That(check.Run(context), Is.Empty); + } + + private void assertShouldProbablyOverlap(IBeatmap beatmap, int count = 1) + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldProbablyOverlap)); + } + + private void assertShouldOverlap(IBeatmap beatmap, int count = 1) + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldOverlap)); + } + + private void assertShouldNotOverlap(IBeatmap beatmap, int count = 1) + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldNotOverlap)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs new file mode 100644 index 0000000000..49a6fd12fa --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTimeDistanceEqualityTest.cs @@ -0,0 +1,324 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Edit.Checks; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks +{ + [TestFixture] + public class CheckTimeDistanceEqualityTest + { + private CheckTimeDistanceEquality check; + + [SetUp] + public void Setup() + { + check = new CheckTimeDistanceEquality(); + } + + [Test] + public void TestCirclesEquidistant() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(100, 0) }, + new HitCircle { StartTime = 1500, Position = new Vector2(150, 0) } + } + }); + } + + [Test] + public void TestCirclesOneSlightlyOff() + { + assertWarning(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(80, 0) }, // Distance a quite low compared to previous. + new HitCircle { StartTime = 1500, Position = new Vector2(130, 0) } + } + }); + } + + [Test] + public void TestCirclesOneOff() + { + assertProblem(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Twice the regular spacing. + new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) } + } + }); + } + + [Test] + public void TestCirclesTwoOff() + { + assertProblem(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Twice the regular spacing. + new HitCircle { StartTime = 1500, Position = new Vector2(250, 0) } // Also twice the regular spacing. + } + }, count: 2); + } + + [Test] + public void TestCirclesStacked() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(50, 0) }, // Stacked, is fine. + new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) } + } + }); + } + + [Test] + public void TestCirclesStacking() + { + assertWarning(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(50, 0), StackHeight = 1 }, + new HitCircle { StartTime = 1500, Position = new Vector2(50, 0), StackHeight = 2 }, + new HitCircle { StartTime = 2000, Position = new Vector2(50, 0), StackHeight = 3 }, + new HitCircle { StartTime = 2500, Position = new Vector2(50, 0), StackHeight = 4 }, // Ends up far from (50; 0), causing irregular spacing. + new HitCircle { StartTime = 3000, Position = new Vector2(100, 0) } + } + }); + } + + [Test] + public void TestCirclesHalfStack() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(55, 0) }, // Basically stacked, so is fine. + new HitCircle { StartTime = 1500, Position = new Vector2(105, 0) } + } + }); + } + + [Test] + public void TestCirclesPartialOverlap() + { + assertProblem(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(65, 0) }, // Really low distance compared to previous. + new HitCircle { StartTime = 1500, Position = new Vector2(115, 0) } + } + }); + } + + [Test] + public void TestCirclesSlightlyDifferent() + { + assertOk(new Beatmap + { + HitObjects = new List + { + // Does not need to be perfect, as long as the distance is approximately correct it's sight-readable. + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(52, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(97, 0) }, + new HitCircle { StartTime = 1500, Position = new Vector2(165, 0) } + } + }); + } + + [Test] + public void TestCirclesSlowlyChanging() + { + const float multiplier = 1.2f; + + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(50 + 50 * multiplier, 0) }, + // This gap would be a warning if it weren't for the previous pushing the average spacing up. + new HitCircle { StartTime = 1500, Position = new Vector2(50 + 50 * multiplier + 50 * multiplier * multiplier, 0) } + } + }); + } + + [Test] + public void TestCirclesQuicklyChanging() + { + const float multiplier = 1.6f; + + var beatmap = new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(50 + 50 * multiplier, 0) }, // Warning + new HitCircle { StartTime = 1500, Position = new Vector2(50 + 50 * multiplier + 50 * multiplier * multiplier, 0) } // Problem + } + }; + + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(2)); + Assert.That(issues.First().Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingWarning); + Assert.That(issues.Last().Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingProblem); + } + + [Test] + public void TestCirclesTooFarApart() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 4000, Position = new Vector2(200, 0) }, // 2 seconds apart from previous, so can start from wherever. + new HitCircle { StartTime = 4500, Position = new Vector2(250, 0) } + } + }); + } + + [Test] + public void TestCirclesOneOffExpert() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Jumps are allowed in higher difficulties. + new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) } + } + }, DifficultyRating.Expert); + } + + [Test] + public void TestSpinner() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + new Spinner { StartTime = 500, EndTime = 1000 }, // Distance to and from the spinner should be ignored. If it isn't this should give a problem. + new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }, + new HitCircle { StartTime = 2000, Position = new Vector2(150, 0) } + } + }); + } + + [Test] + public void TestSliders() + { + assertOk(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + getSliderMock(startTime: 1000, endTime: 1500, startPosition: new Vector2(100, 0), endPosition: new Vector2(150, 0)).Object, + getSliderMock(startTime: 2000, endTime: 2500, startPosition: new Vector2(200, 0), endPosition: new Vector2(250, 0)).Object, + new HitCircle { StartTime = 2500, Position = new Vector2(300, 0) } + } + }); + } + + [Test] + public void TestSlidersOneOff() + { + assertProblem(new Beatmap + { + HitObjects = new List + { + new HitCircle { StartTime = 0, Position = new Vector2(0) }, + new HitCircle { StartTime = 500, Position = new Vector2(50, 0) }, + getSliderMock(startTime: 1000, endTime: 1500, startPosition: new Vector2(100, 0), endPosition: new Vector2(150, 0)).Object, + getSliderMock(startTime: 2000, endTime: 2500, startPosition: new Vector2(250, 0), endPosition: new Vector2(300, 0)).Object, // Twice the spacing. + new HitCircle { StartTime = 2500, Position = new Vector2(300, 0) } + } + }); + } + + private Mock getSliderMock(double startTime, double endTime, Vector2 startPosition, Vector2 endPosition) + { + var mockSlider = new Mock(); + mockSlider.SetupGet(s => s.StartTime).Returns(startTime); + mockSlider.SetupGet(s => s.Position).Returns(startPosition); + mockSlider.SetupGet(s => s.EndPosition).Returns(endPosition); + mockSlider.As().Setup(d => d.EndTime).Returns(endTime); + + return mockSlider; + } + + private void assertOk(IBeatmap beatmap, DifficultyRating difficultyRating = DifficultyRating.Easy) + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating); + Assert.That(check.Run(context), Is.Empty); + } + + private void assertWarning(IBeatmap beatmap, int count = 1) + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingWarning)); + } + + private void assertProblem(IBeatmap beatmap, int count = 1) + { + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingProblem)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs new file mode 100644 index 0000000000..2eab5a4ce6 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Edit.Checks; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks +{ + [TestFixture] + public class CheckTooShortSlidersTest + { + private CheckTooShortSliders check; + + [SetUp] + public void Setup() + { + check = new CheckTooShortSliders(); + } + + [Test] + public void TestLongSlider() + { + Slider slider = new Slider + { + StartTime = 0, + RepeatCount = 0, + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0)), + new PathControlPoint(new Vector2(100, 0)) + }) + }; + + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + assertOk(new List { slider }); + } + + [Test] + public void TestShortSlider() + { + Slider slider = new Slider + { + StartTime = 0, + RepeatCount = 0, + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0)), + new PathControlPoint(new Vector2(25, 0)) + }) + }; + + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + assertOk(new List { slider }); + } + + [Test] + public void TestTooShortSliderExpert() + { + Slider slider = new Slider + { + StartTime = 0, + RepeatCount = 0, + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0)), + new PathControlPoint(new Vector2(10, 0)) + }) + }; + + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + assertOk(new List { slider }, DifficultyRating.Expert); + } + + [Test] + public void TestTooShortSlider() + { + Slider slider = new Slider + { + StartTime = 0, + RepeatCount = 0, + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0)), + new PathControlPoint(new Vector2(10, 0)) + }) + }; + + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + assertTooShort(new List { slider }); + } + + [Test] + public void TestTooShortSliderWithRepeats() + { + // Would be ok if we looked at the duration, but not if we look at the span duration. + Slider slider = new Slider + { + StartTime = 0, + RepeatCount = 2, + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0)), + new PathControlPoint(new Vector2(10, 0)) + }) + }; + + slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + assertTooShort(new List { slider }); + } + + private void assertOk(List hitObjects, DifficultyRating difficultyRating = DifficultyRating.Easy) + { + Assert.That(check.Run(getContext(hitObjects, difficultyRating)), Is.Empty); + } + + private void assertTooShort(List hitObjects, DifficultyRating difficultyRating = DifficultyRating.Easy) + { + var issues = check.Run(getContext(hitObjects, difficultyRating)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.First().Template is CheckTooShortSliders.IssueTemplateTooShort); + } + + private BeatmapVerifierContext getContext(List hitObjects, DifficultyRating difficultyRating) + { + var beatmap = new Beatmap { HitObjects = hitObjects }; + + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs new file mode 100644 index 0000000000..6a3f168ee1 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Edit.Checks; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks +{ + [TestFixture] + public class CheckTooShortSpinnersTest + { + private CheckTooShortSpinners check; + private BeatmapDifficulty difficulty; + + [SetUp] + public void Setup() + { + check = new CheckTooShortSpinners(); + difficulty = new BeatmapDifficulty(); + } + + [Test] + public void TestLongSpinner() + { + Spinner spinner = new Spinner { StartTime = 0, Duration = 4000 }; + spinner.ApplyDefaults(new ControlPointInfo(), difficulty); + + assertOk(new List { spinner }, difficulty); + } + + [Test] + public void TestShortSpinner() + { + Spinner spinner = new Spinner { StartTime = 0, Duration = 750 }; + spinner.ApplyDefaults(new ControlPointInfo(), difficulty); + + assertOk(new List { spinner }, difficulty); + } + + [Test] + public void TestVeryShortSpinner() + { + // Spinners at a certain duration only get 1000 points if approached by auto at a certain angle, making it difficult to determine. + Spinner spinner = new Spinner { StartTime = 0, Duration = 475 }; + spinner.ApplyDefaults(new ControlPointInfo(), difficulty); + + assertVeryShort(new List { spinner }, difficulty); + } + + [Test] + public void TestTooShortSpinner() + { + Spinner spinner = new Spinner { StartTime = 0, Duration = 400 }; + spinner.ApplyDefaults(new ControlPointInfo(), difficulty); + + assertTooShort(new List { spinner }, difficulty); + } + + [Test] + public void TestTooShortSpinnerVaryingOd() + { + const double duration = 450; + + var difficultyLowOd = new BeatmapDifficulty { OverallDifficulty = 1 }; + Spinner spinnerLowOd = new Spinner { StartTime = 0, Duration = duration }; + spinnerLowOd.ApplyDefaults(new ControlPointInfo(), difficultyLowOd); + + var difficultyHighOd = new BeatmapDifficulty { OverallDifficulty = 10 }; + Spinner spinnerHighOd = new Spinner { StartTime = 0, Duration = duration }; + spinnerHighOd.ApplyDefaults(new ControlPointInfo(), difficultyHighOd); + + assertOk(new List { spinnerLowOd }, difficultyLowOd); + assertTooShort(new List { spinnerHighOd }, difficultyHighOd); + } + + private void assertOk(List hitObjects, BeatmapDifficulty beatmapDifficulty) + { + Assert.That(check.Run(getContext(hitObjects, beatmapDifficulty)), Is.Empty); + } + + private void assertVeryShort(List hitObjects, BeatmapDifficulty beatmapDifficulty) + { + var issues = check.Run(getContext(hitObjects, beatmapDifficulty)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.First().Template is CheckTooShortSpinners.IssueTemplateVeryShort); + } + + private void assertTooShort(List hitObjects, BeatmapDifficulty beatmapDifficulty) + { + var issues = check.Run(getContext(hitObjects, beatmapDifficulty)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.First().Template is CheckTooShortSpinners.IssueTemplateTooShort); + } + + private BeatmapVerifierContext getContext(List hitObjects, BeatmapDifficulty beatmapDifficulty) + { + var beatmap = new Beatmap + { + HitObjects = hitObjects, + BeatmapInfo = new BeatmapInfo { BaseDifficulty = beatmapDifficulty } + }; + + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorHitAnimations.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorHitAnimations.cs new file mode 100644 index 0000000000..7ffa2c1f94 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuEditorHitAnimations.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 System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Configuration; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + [TestFixture] + public class TestSceneOsuEditorHitAnimations : TestSceneOsuEditor + { + [Resolved] + private OsuConfigManager config { get; set; } + + [Test] + public void TestHitCircleAnimationDisable() + { + HitCircle hitCircle = null; + DrawableHitCircle drawableHitCircle = null; + + AddStep("retrieve first hit circle", () => hitCircle = getHitCircle(0)); + toggleAnimations(true); + seekSmoothlyTo(() => hitCircle.StartTime + 10); + + AddStep("retrieve drawable", () => drawableHitCircle = (DrawableHitCircle)getDrawableObjectFor(hitCircle)); + assertFutureTransforms(() => drawableHitCircle.CirclePiece, true); + + AddStep("retrieve second hit circle", () => hitCircle = getHitCircle(1)); + toggleAnimations(false); + seekSmoothlyTo(() => hitCircle.StartTime + 10); + + AddStep("retrieve drawable", () => drawableHitCircle = (DrawableHitCircle)getDrawableObjectFor(hitCircle)); + assertFutureTransforms(() => drawableHitCircle.CirclePiece, false); + AddAssert("hit circle has longer fade-out applied", () => + { + var alphaTransform = drawableHitCircle.Transforms.Last(t => t.TargetMember == nameof(Alpha)); + return alphaTransform.EndTime - alphaTransform.StartTime == DrawableOsuEditorRuleset.EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION; + }); + } + + [Test] + public void TestSliderAnimationDisable() + { + Slider slider = null; + DrawableSlider drawableSlider = null; + DrawableSliderRepeat sliderRepeat = null; + + AddStep("retrieve first slider with repeats", () => slider = getSliderWithRepeats(0)); + toggleAnimations(true); + seekSmoothlyTo(() => slider.StartTime + slider.SpanDuration + 10); + + retrieveDrawables(); + assertFutureTransforms(() => sliderRepeat, true); + + AddStep("retrieve second slider with repeats", () => slider = getSliderWithRepeats(1)); + toggleAnimations(false); + seekSmoothlyTo(() => slider.StartTime + slider.SpanDuration + 10); + + retrieveDrawables(); + assertFutureTransforms(() => sliderRepeat.Arrow, false); + seekSmoothlyTo(() => slider.GetEndTime()); + AddAssert("slider has longer fade-out applied", () => + { + var alphaTransform = drawableSlider.Transforms.Last(t => t.TargetMember == nameof(Alpha)); + return alphaTransform.EndTime - alphaTransform.StartTime == DrawableOsuEditorRuleset.EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION; + }); + + void retrieveDrawables() => + AddStep("retrieve drawables", () => + { + drawableSlider = (DrawableSlider)getDrawableObjectFor(slider); + sliderRepeat = (DrawableSliderRepeat)getDrawableObjectFor(slider.NestedHitObjects.OfType().First()); + }); + } + + private HitCircle getHitCircle(int index) + => EditorBeatmap.HitObjects.OfType().ElementAt(index); + + private Slider getSliderWithRepeats(int index) + => EditorBeatmap.HitObjects.OfType().Where(s => s.RepeatCount >= 1).ElementAt(index); + + private DrawableHitObject getDrawableObjectFor(HitObject hitObject) + => this.ChildrenOfType().Single(ho => ho.HitObject == hitObject); + + private IEnumerable getTransformsRecursively(Drawable drawable) + => drawable.ChildrenOfType().SelectMany(d => d.Transforms); + + private void toggleAnimations(bool enabled) + => AddStep($"toggle animations {(enabled ? "on" : "off")}", () => config.SetValue(OsuSetting.EditorHitAnimations, enabled)); + + private void seekSmoothlyTo(Func targetTime) + { + AddStep("seek smoothly", () => EditorClock.SeekSmoothlyTo(targetTime.Invoke())); + AddUntilStep("wait for seek", () => Precision.AlmostEquals(targetTime.Invoke(), EditorClock.CurrentTime)); + } + + private void assertFutureTransforms(Func getDrawable, bool hasFutureTransforms) + => AddAssert($"object {(hasFutureTransforms ? "has" : "has no")} future transforms", + () => getTransformsRecursively(getDrawable()).Any(t => t.EndTime >= EditorClock.CurrentTime) == hasFutureTransforms); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/SampleLookups/osu-hitobject-beatmap-custom-sample-bank.osu b/osu.Game.Rulesets.Osu.Tests/Resources/SampleLookups/osu-hitobject-beatmap-custom-sample-bank.osu new file mode 100644 index 0000000000..a84fc08bb8 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Resources/SampleLookups/osu-hitobject-beatmap-custom-sample-bank.osu @@ -0,0 +1,10 @@ +osu file format v14 + +[General] +Mode: 0 + +[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.Osu.Tests/Resources/old-skin/skin.ini b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini index 89bcd68343..06dfa6b7be 100644 --- a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini +++ b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/skin.ini @@ -1,6 +1,6 @@ [General] -Version: 1.0 +// no version specified means v1 [Fonts] HitCircleOverlap: 3 -ScoreOverlap: 3 \ No newline at end of file +ScoreOverlap: 3 diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs index 0ba97fac54..211b0e8145 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs @@ -2,6 +2,7 @@ // 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.Sample; @@ -39,18 +40,28 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestLegacySmoothCursorTrail() { - createTest(() => new LegacySkinContainer(false) + createTest(() => { - Child = new LegacyCursorTrail() + var skinContainer = new LegacySkinContainer(false); + var legacyCursorTrail = new LegacyCursorTrail(skinContainer); + + skinContainer.Child = legacyCursorTrail; + + return skinContainer; }); } [Test] public void TestLegacyDisjointCursorTrail() { - createTest(() => new LegacySkinContainer(true) + createTest(() => { - Child = new LegacyCursorTrail() + var skinContainer = new LegacySkinContainer(true); + var legacyCursorTrail = new LegacyCursorTrail(skinContainer); + + skinContainer.Child = legacyCursorTrail; + + return skinContainer; }); } @@ -102,6 +113,10 @@ namespace osu.Game.Rulesets.Osu.Tests public IBindable GetConfig(TLookup lookup) => null; + public ISkin FindProvider(Func lookupFunction) => lookupFunction(this) ? this : null; + + public IEnumerable AllSources => new[] { this }; + public event Action SourceChanged { add { } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index a95159ce4c..2326a0c391 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -113,6 +113,7 @@ namespace osu.Game.Rulesets.Osu.Tests public Drawable GetDrawableComponent(ISkinComponent component) => null; public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; public ISample GetSample(ISampleInfo sampleInfo) => null; + public ISkin FindProvider(Func lookupFunction) => null; public IBindable GetConfig(TLookup lookup) { @@ -120,7 +121,7 @@ namespace osu.Game.Rulesets.Osu.Tests { case OsuSkinConfiguration osuLookup: if (osuLookup == OsuSkinConfiguration.CursorCentre) - return SkinUtils.As(new BindableBool(false)); + return SkinUtils.As(new BindableBool()); break; } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs index 58e46b6687..f6e8a771ed 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircle.cs @@ -21,20 +21,37 @@ namespace osu.Game.Rulesets.Osu.Tests private int depthIndex; [Test] - public void TestVariousHitCircles() + public void TestHits() + { + 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("Hit Big Stream", () => SetContents(_ => testStream(2, true))); + AddStep("Hit Medium Stream", () => SetContents(_ => testStream(5, true))); + AddStep("Hit Small Stream", () => SetContents(_ => testStream(7, true))); + } + + [Test] + public void TestHittingEarly() + { + AddStep("Hit stream early", () => SetContents(_ => testStream(5, true, -150))); + } + + [Test] + public void TestMisses() { 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))); + } + + [Test] + public void TestHittingLate() + { + AddStep("Hit stream late", () => SetContents(_ => testStream(5, true, 150))); } private Drawable testSingle(float circleSize, bool auto = false, double timeOffset = 0, Vector2? positionOffset = null) @@ -46,7 +63,7 @@ namespace osu.Game.Rulesets.Osu.Tests return playfield; } - private Drawable testStream(float circleSize, bool auto = false) + private Drawable testStream(float circleSize, bool auto = false, double hitOffset = 0) { var playfield = new TestOsuPlayfield(); @@ -54,14 +71,14 @@ namespace osu.Game.Rulesets.Osu.Tests for (int i = 0; i <= 1000; i += 100) { - playfield.Add(createSingle(circleSize, auto, i, pos)); + playfield.Add(createSingle(circleSize, auto, i, pos, hitOffset)); pos.X += 50; } return playfield; } - private TestDrawableHitCircle createSingle(float circleSize, bool auto, double timeOffset, Vector2? positionOffset) + private TestDrawableHitCircle createSingle(float circleSize, bool auto, double timeOffset, Vector2? positionOffset, double hitOffset = 0) { positionOffset ??= Vector2.Zero; @@ -73,14 +90,14 @@ namespace osu.Game.Rulesets.Osu.Tests circle.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize }); - var drawable = CreateDrawableHitCircle(circle, auto); + var drawable = CreateDrawableHitCircle(circle, auto, hitOffset); - foreach (var mod in SelectedMods.Value.OfType()) - mod.ApplyToDrawableHitObjects(new[] { drawable }); + foreach (var mod in SelectedMods.Value.OfType()) + mod.ApplyToDrawableHitObject(drawable); return drawable; } - protected virtual TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto) => new TestDrawableHitCircle(circle, auto) + protected virtual TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto, double hitOffset = 0) => new TestDrawableHitCircle(circle, auto, hitOffset) { Depth = depthIndex++ }; @@ -88,18 +105,20 @@ namespace osu.Game.Rulesets.Osu.Tests protected class TestDrawableHitCircle : DrawableHitCircle { private readonly bool auto; + private readonly double hitOffset; - public TestDrawableHitCircle(HitCircle h, bool auto) + public TestDrawableHitCircle(HitCircle h, bool auto, double hitOffset) : base(h) { this.auto = auto; + this.hitOffset = hitOffset; } - public void TriggerJudgement() => UpdateResult(true); + public void TriggerJudgement() => Schedule(() => UpdateResult(true)); protected override void CheckForResult(bool userTriggered, double timeOffset) { - if (auto && !userTriggered && timeOffset > 0) + if (auto && !userTriggered && timeOffset > hitOffset && CheckHittable?.Invoke(this, Time.Current) != false) { // force success ApplyResult(r => r.Type = HitResult.Great); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.cs index 5695462859..ff600172d2 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleComboChange.cs @@ -16,11 +16,11 @@ namespace osu.Game.Rulesets.Osu.Tests Scheduler.AddDelayed(() => comboIndex.Value++, 250, true); } - protected override TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto) + protected override TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto, double hitOffset = 0) { circle.ComboIndexBindable.BindTo(comboIndex); circle.IndexInCurrentComboBindable.BindTo(comboIndex); - return base.CreateDrawableHitCircle(circle, auto); + return base.CreateDrawableHitCircle(circle, auto, hitOffset); } } } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleKiai.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleKiai.cs new file mode 100644 index 0000000000..2bce8fa7f2 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneHitCircleKiai.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 NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + public class TestSceneHitCircleKiai : TestSceneHitCircle + { + [SetUp] + public void SetUp() => Schedule(() => + { + var controlPointInfo = new ControlPointInfo(); + + controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 500 }); + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + + Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + ControlPointInfo = controlPointInfo + }); + + // track needs to be playing for BeatSyncedContainer to work. + Beatmap.Value.Track.Start(); + }); + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.cs new file mode 100644 index 0000000000..e8d98ce3b8 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOsuHitObjectSamples.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.Osu.Tests +{ + public class TestSceneOsuHitObjectSamples : HitObjectSampleTest + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + protected override IResourceStore RulesetResources => new DllResourceStore(Assembly.GetAssembly(typeof(TestSceneOsuHitObjectSamples))); + + [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] + public void TestDefaultCustomSampleFromBeatmap(string expectedSample) + { + SetupSkins(expectedSample, expectedSample); + + CreateTestWithBeatmap("osu-hitobject-beatmap-custom-sample-bank.osu"); + + AssertBeatmapLookup(expectedSample); + } + + [TestCase("normal-hitnormal")] + [TestCase("hitnormal")] + public void TestDefaultCustomSampleFromUserSkinFallback(string expectedSample) + { + SetupSkins(string.Empty, expectedSample); + + CreateTestWithBeatmap("osu-hitobject-beatmap-custom-sample-bank.osu"); + + AssertUserLookup(expectedSample); + } + + [TestCase("normal-hitnormal2")] + public void TestUserSkinLookupIgnoresSampleBank(string unwantedSample) + { + SetupSkins(string.Empty, unwantedSample); + + CreateTestWithBeatmap("osu-hitobject-beatmap-custom-sample-bank.osu"); + + AssertNoLookup(unwantedSample); + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs index 7e973d0971..43900c9a5c 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneShaking.cs @@ -26,9 +26,9 @@ namespace osu.Game.Rulesets.Osu.Tests return base.CreateBeatmapForSkinProvider(); } - protected override TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto) + protected override TestDrawableHitCircle CreateDrawableHitCircle(HitCircle circle, bool auto, double hitOffset = 0) { - var drawableHitObject = base.CreateDrawableHitCircle(circle, auto); + var drawableHitObject = base.CreateDrawableHitCircle(circle, auto, hitOffset); Debug.Assert(drawableHitObject.HitObject.HitWindows != null); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs index 6c6f05c5c5..662cbaee68 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSkinFallbacks.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -164,9 +165,12 @@ namespace osu.Game.Rulesets.Osu.Tests public ISample GetSample(ISampleInfo sampleInfo) => null; - public TValue GetValue(Func query) where TConfiguration : SkinConfiguration => default; public IBindable GetConfig(TLookup lookup) => null; + public ISkin FindProvider(Func lookupFunction) => lookupFunction(this) ? this : null; + + public IEnumerable AllSources => new[] { this }; + public event Action SourceChanged; private bool enabled = true; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index fc5fcf2358..81902c25af 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -335,8 +335,8 @@ namespace osu.Game.Rulesets.Osu.Tests var drawable = CreateDrawableSlider(slider); - foreach (var mod in SelectedMods.Value.OfType()) - mod.ApplyToDrawableHitObjects(new[] { drawable }); + foreach (var mod in SelectedMods.Value.OfType()) + mod.ApplyToDrawableHitObject(drawable); drawable.OnNewResult += onNewResult; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs index e111bb1054..3252e6d912 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderSnaking.cs @@ -37,11 +37,13 @@ namespace osu.Game.Rulesets.Osu.Tests private readonly BindableBool snakingIn = new BindableBool(); private readonly BindableBool snakingOut = new BindableBool(); + private IBeatmap beatmap; + 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); + => new ClockBackedTestWorkingBeatmap(this.beatmap = beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager); [BackgroundDependencyLoader] private void load(RulesetConfigCache configCache) @@ -51,8 +53,16 @@ namespace osu.Game.Rulesets.Osu.Tests config.BindWith(OsuRulesetSetting.SnakingOutSliders, snakingOut); } + private Slider slider; private DrawableSlider drawableSlider; + [SetUp] + public void Setup() => Schedule(() => + { + slider = null; + drawableSlider = null; + }); + [SetUpSteps] public override void SetUpSteps() { @@ -67,21 +77,19 @@ namespace osu.Game.Rulesets.Osu.Tests base.SetUpSteps(); AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); - double startTime = hitObjects[sliderIndex].StartTime; - addSeekStep(startTime); - retrieveDrawableSlider((Slider)hitObjects[sliderIndex]); + retrieveSlider(sliderIndex); setSnaking(true); - ensureSnakingIn(startTime + fade_in_modifier); + addEnsureSnakingInSteps(() => slider.StartTime + fade_in_modifier); for (int i = 0; i < sliderIndex; i++) { // non-final repeats should not snake out - ensureNoSnakingOut(startTime, i); + addEnsureNoSnakingOutStep(() => slider.StartTime, i); } // final repeat should snake out - ensureSnakingOut(startTime, sliderIndex); + addEnsureSnakingOutSteps(() => slider.StartTime, sliderIndex); } [TestCase(0)] @@ -93,17 +101,15 @@ namespace osu.Game.Rulesets.Osu.Tests base.SetUpSteps(); AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); - double startTime = hitObjects[sliderIndex].StartTime; - addSeekStep(startTime); - retrieveDrawableSlider((Slider)hitObjects[sliderIndex]); + retrieveSlider(sliderIndex); setSnaking(false); - ensureNoSnakingIn(startTime + fade_in_modifier); + addEnsureNoSnakingInSteps(() => slider.StartTime + fade_in_modifier); for (int i = 0; i <= sliderIndex; i++) { // no snaking out ever, including final repeat - ensureNoSnakingOut(startTime, i); + addEnsureNoSnakingOutStep(() => slider.StartTime, i); } } @@ -116,7 +122,7 @@ namespace osu.Game.Rulesets.Osu.Tests // 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); + addCheckPositionChangeSteps(() => 16600, getSliderRepeat, positionAlmostSame); } [Test] @@ -126,38 +132,41 @@ namespace osu.Game.Rulesets.Osu.Tests setSnaking(true); base.SetUpSteps(); - checkPositionChange(16600, sliderRepeat, positionDecreased); + addCheckPositionChangeSteps(() => 16600, getSliderRepeat, positionDecreased); } - private void retrieveDrawableSlider(Slider slider) => AddUntilStep($"retrieve slider @ {slider.StartTime}", () => - (drawableSlider = (DrawableSlider)Player.DrawableRuleset.Playfield.AllHitObjects.SingleOrDefault(d => d.HitObject == slider)) != null); - - 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) + private void retrieveSlider(int index) { - var repeatTime = timeAtRepeat(startTime, repeatIndex); + AddStep("retrieve slider at index", () => slider = (Slider)beatmap.HitObjects[index]); + addSeekStep(() => slider); + AddUntilStep("retrieve drawable slider", () => + (drawableSlider = (DrawableSlider)Player.DrawableRuleset.Playfield.AllHitObjects.SingleOrDefault(d => d.HitObject == slider)) != null); + } + private void addEnsureSnakingInSteps(Func startTime) => addCheckPositionChangeSteps(startTime, getSliderEnd, positionIncreased); + private void addEnsureNoSnakingInSteps(Func startTime) => addCheckPositionChangeSteps(startTime, getSliderEnd, positionRemainsSame); + + private void addEnsureSnakingOutSteps(Func startTime, int repeatIndex) + { if (repeatIndex % 2 == 0) - checkPositionChange(repeatTime, sliderStart, positionIncreased); + addCheckPositionChangeSteps(timeAtRepeat(startTime, repeatIndex), getSliderStart, positionIncreased); else - checkPositionChange(repeatTime, sliderEnd, positionDecreased); + addCheckPositionChangeSteps(timeAtRepeat(startTime, repeatIndex), getSliderEnd, positionDecreased); } - private void ensureNoSnakingOut(double startTime, int repeatIndex) => - checkPositionChange(timeAtRepeat(startTime, repeatIndex), positionAtRepeat(repeatIndex), positionRemainsSame); + private void addEnsureNoSnakingOutStep(Func startTime, int repeatIndex) + => addCheckPositionChangeSteps(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 Func timeAtRepeat(Func startTime, int repeatIndex) => () => startTime() + 100 + duration_of_span * repeatIndex; + private Func positionAtRepeat(int repeatIndex) => repeatIndex % 2 == 0 ? (Func)getSliderStart : getSliderEnd; - private List sliderCurve => ((PlaySliderBody)drawableSlider.Body.Drawable).CurrentCurve; - private Vector2 sliderStart() => sliderCurve.First(); - private Vector2 sliderEnd() => sliderCurve.Last(); + private List getSliderCurve() => ((PlaySliderBody)drawableSlider.Body.Drawable).CurrentCurve; + private Vector2 getSliderStart() => getSliderCurve().First(); + private Vector2 getSliderEnd() => getSliderCurve().Last(); - private Vector2 sliderRepeat() + private Vector2 getSliderRepeat() { - var drawable = Player.DrawableRuleset.Playfield.AllHitObjects.SingleOrDefault(d => d.HitObject == hitObjects[1]); + var drawable = Player.DrawableRuleset.Playfield.AllHitObjects.SingleOrDefault(d => d.HitObject == beatmap.HitObjects[1]); var repeat = drawable.ChildrenOfType>().First().Children.First(); return repeat.Position; } @@ -167,7 +176,7 @@ namespace osu.Game.Rulesets.Osu.Tests 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) + private void addCheckPositionChangeSteps(Func startTime, Func positionToCheck, Func positionAssertion) { Vector2 previousPosition = Vector2.Zero; @@ -176,7 +185,7 @@ namespace osu.Game.Rulesets.Osu.Tests addSeekStep(startTime); AddStep($"save {positionDescription} position", () => previousPosition = positionToCheck.Invoke()); - addSeekStep(startTime + 100); + addSeekStep(() => startTime() + 100); AddAssert($"{positionDescription} {assertionDescription}", () => { var currentPosition = positionToCheck.Invoke(); @@ -193,19 +202,21 @@ namespace osu.Game.Rulesets.Osu.Tests }); } - private void addSeekStep(double time) + private void addSeekStep(Func slider) { - AddStep($"seek to {time}", () => MusicController.SeekTo(time)); - - AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); + AddStep("seek to slider", () => Player.GameplayClockContainer.Seek(slider().StartTime)); + AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(slider().StartTime, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); } - protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap + private void addSeekStep(Func time) { - HitObjects = hitObjects - }; + AddStep("seek to time", () => Player.GameplayClockContainer.Seek(time())); + AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time(), Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); + } - private readonly List hitObjects = new List + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new Beatmap { HitObjects = createHitObjects() }; + + private static List createHitObjects() => new List { new Slider { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs index b21b7a6f4a..2dea9837f3 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs @@ -85,8 +85,8 @@ namespace osu.Game.Rulesets.Osu.Tests Scale = new Vector2(0.75f) }; - foreach (var mod in SelectedMods.Value.OfType()) - mod.ApplyToDrawableHitObjects(new[] { drawableSpinner }); + foreach (var mod in SelectedMods.Value.OfType()) + mod.ApplyToDrawableHitObject(drawableSpinner); return drawableSpinner; } diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index 8ff21057b5..9da583a073 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -217,7 +217,7 @@ namespace osu.Game.Rulesets.Osu.Tests private void addSeekStep(double time) { - AddStep($"seek to {time}", () => MusicController.SeekTo(time)); + AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time)); AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); } 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 ebe642803b..68be34d153 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 @@ -3,8 +3,9 @@ + - + diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index e8ac60bc5e..141138c125 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -7,11 +7,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty { public class OsuDifficultyAttributes : DifficultyAttributes { - public double AimStrain; - public double SpeedStrain; - public double ApproachRate; - public double OverallDifficulty; - public int HitCircleCount; - public int SpinnerCount; + public double AimStrain { get; set; } + public double SpeedStrain { get; set; } + public double ApproachRate { get; set; } + public double OverallDifficulty { get; set; } + public int HitCircleCount { get; set; } + public int SpinnerCount { get; set; } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 75d6786d95..e47f82fb39 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -79,7 +79,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty } } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { new Aim(mods), new Speed(mods) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 74840853c0..e6ab978dfb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; @@ -36,14 +35,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty mods = Score.Mods; accuracy = Score.Accuracy; scoreMaxCombo = Score.MaxCombo; - 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; + countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); + countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); // Custom multipliers for NoFail and SpunOut. double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things @@ -102,11 +97,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty double approachRateFactor = 0.0; if (Attributes.ApproachRate > 10.33) - approachRateFactor += 0.4 * (Attributes.ApproachRate - 10.33); + approachRateFactor = Attributes.ApproachRate - 10.33; else if (Attributes.ApproachRate < 8.0) - approachRateFactor += 0.01 * (8.0 - Attributes.ApproachRate); + approachRateFactor = 0.025 * (8.0 - Attributes.ApproachRate); - approachRateFactor = 1.0 + Math.Min(approachRateFactor, approachRateFactor * (totalHits / 1000.0)); + double approachRateTotalHitsFactor = 1.0 / (1.0 + Math.Exp(-(0.007 * (totalHits - 400)))); + + double approachRateBonus = 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * 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)) @@ -124,7 +121,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty : 0.0); } - aimValue *= Math.Max(flashlightBonus, approachRateFactor); + aimValue *= Math.Max(flashlightBonus, approachRateBonus); // Scale the aim value with accuracy _slightly_ aimValue *= 0.5 + accuracy / 2.0; @@ -153,9 +150,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty double approachRateFactor = 0.0; if (Attributes.ApproachRate > 10.33) - approachRateFactor += 0.4 * (Attributes.ApproachRate - 10.33); + approachRateFactor = Attributes.ApproachRate - 10.33; - speedValue *= 1.0 + Math.Min(approachRateFactor, approachRateFactor * (totalHits / 1000.0)); + double approachRateTotalHitsFactor = 1.0 / (1.0 + Math.Exp(-(0.007 * (totalHits - 400)))); + + speedValue *= 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; if (mods.Any(m => m is OsuModHidden)) speedValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); 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 48e4db11ca..5b476526c9 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; @@ -283,6 +284,6 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } } - public string TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty; + public LocalisableString TooltipText => ControlPoint.Type.Value.ToString() ?? string.Empty; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs new file mode 100644 index 0000000000..1dd859b5b8 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckLowDiffOverlaps.cs @@ -0,0 +1,109 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Edit.Checks +{ + public class CheckLowDiffOverlaps : ICheck + { + // For the lowest difficulties, the osu! Ranking Criteria encourages overlapping ~180 BPM 1/2, but discourages ~180 BPM 1/1. + private const double should_overlap_threshold = 150; // 200 BPM 1/2 + private const double should_probably_overlap_threshold = 175; // 170 BPM 1/2 + private const double should_not_overlap_threshold = 250; // 120 BPM 1/2 = 240 BPM 1/1 + + /// + /// Objects need to overlap this much before being treated as an overlap, else it may just be the borders slightly touching. + /// + private const double overlap_leniency = 5; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Missing or unexpected overlaps"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateShouldOverlap(this), + new IssueTemplateShouldProbablyOverlap(this), + new IssueTemplateShouldNotOverlap(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + // TODO: This should also apply to *lowest difficulty* Normals - they are skipped for now. + if (context.InterpretedDifficulty > DifficultyRating.Easy) + yield break; + + var hitObjects = context.Beatmap.HitObjects; + + for (int i = 0; i < hitObjects.Count - 1; ++i) + { + if (!(hitObjects[i] is OsuHitObject hitObject) || hitObject is Spinner) + continue; + + if (!(hitObjects[i + 1] is OsuHitObject nextHitObject) || nextHitObject is Spinner) + continue; + + var deltaTime = nextHitObject.StartTime - hitObject.GetEndTime(); + if (deltaTime >= hitObject.TimeFadeIn + hitObject.TimePreempt) + // The objects are not visible at the same time (without mods), hence skipping. + continue; + + var distanceSq = (hitObject.StackedEndPosition - nextHitObject.StackedPosition).LengthSquared; + var diameter = (hitObject.Radius - overlap_leniency) * 2; + var diameterSq = diameter * diameter; + + bool areOverlapping = distanceSq < diameterSq; + + // Slider ends do not need to be overlapped because of slider leniency. + if (!areOverlapping && !(hitObject is Slider)) + { + if (deltaTime < should_overlap_threshold) + yield return new IssueTemplateShouldOverlap(this).Create(deltaTime, hitObject, nextHitObject); + else if (deltaTime < should_probably_overlap_threshold) + yield return new IssueTemplateShouldProbablyOverlap(this).Create(deltaTime, hitObject, nextHitObject); + } + + if (areOverlapping && deltaTime > should_not_overlap_threshold) + yield return new IssueTemplateShouldNotOverlap(this).Create(deltaTime, hitObject, nextHitObject); + } + } + + public abstract class IssueTemplateOverlap : IssueTemplate + { + protected IssueTemplateOverlap(ICheck check, IssueType issueType, string unformattedMessage) + : base(check, issueType, unformattedMessage) + { + } + + public Issue Create(double deltaTime, params HitObject[] hitObjects) => new Issue(hitObjects, this, deltaTime); + } + + public class IssueTemplateShouldOverlap : IssueTemplateOverlap + { + public IssueTemplateShouldOverlap(ICheck check) + : base(check, IssueType.Problem, "These are {0} ms apart and so should be overlapping.") + { + } + } + + public class IssueTemplateShouldProbablyOverlap : IssueTemplateOverlap + { + public IssueTemplateShouldProbablyOverlap(ICheck check) + : base(check, IssueType.Warning, "These are {0} ms apart and so should probably be overlapping.") + { + } + } + + public class IssueTemplateShouldNotOverlap : IssueTemplateOverlap + { + public IssueTemplateShouldNotOverlap(ICheck check) + : base(check, IssueType.Problem, "These are {0} ms apart and so should NOT be overlapping.") + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs new file mode 100644 index 0000000000..6420d9558e --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTimeDistanceEquality.cs @@ -0,0 +1,179 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Edit.Checks +{ + public class CheckTimeDistanceEquality : ICheck + { + /// + /// Two objects this many ms apart or more are skipped. (200 BPM 2/1) + /// + private const double pattern_lifetime = 600; + + /// + /// Two objects this distance apart or less are skipped. + /// + private const double stack_leniency = 12; + + /// + /// How long an observation is relevant for comparison. (120 BPM 8/1) + /// + private const double observation_lifetime = 4000; + + /// + /// How different two delta times can be to still be compared. (240 BPM 1/16) + /// + private const double similar_time_leniency = 16; + + /// + /// How many pixels are subtracted from the difference between current and expected distance. + /// + private const double distance_leniency_absolute_warning = 10; + + /// + /// How much of the current distance that the difference can make out. + /// + private const double distance_leniency_percent_warning = 0.15; + + private const double distance_leniency_absolute_problem = 20; + private const double distance_leniency_percent_problem = 0.3; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Object too close or far away from previous"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateIrregularSpacingProblem(this), + new IssueTemplateIrregularSpacingWarning(this) + }; + + /// + /// Represents an observation of the time and distance between two objects. + /// + private readonly struct ObservedTimeDistance + { + public readonly double ObservationTime; + public readonly double DeltaTime; + public readonly double Distance; + + public ObservedTimeDistance(double observationTime, double deltaTime, double distance) + { + ObservationTime = observationTime; + DeltaTime = deltaTime; + Distance = distance; + } + } + + public IEnumerable Run(BeatmapVerifierContext context) + { + if (context.InterpretedDifficulty > DifficultyRating.Normal) + yield break; + + var prevObservedTimeDistances = new List(); + var hitObjects = context.Beatmap.HitObjects; + + for (int i = 0; i < hitObjects.Count - 1; ++i) + { + if (!(hitObjects[i] is OsuHitObject hitObject) || hitObject is Spinner) + continue; + + if (!(hitObjects[i + 1] is OsuHitObject nextHitObject) || nextHitObject is Spinner) + continue; + + var deltaTime = nextHitObject.StartTime - hitObject.GetEndTime(); + + // Ignore objects that are far enough apart in time to not be considered the same pattern. + if (deltaTime > pattern_lifetime) + continue; + + // Relying on FastInvSqrt is probably good enough here. We'll be taking the difference between distances later, hence square not being sufficient. + var distance = (hitObject.StackedEndPosition - nextHitObject.StackedPosition).LengthFast; + + // Ignore stacks and half-stacks, as these are close enough to where they can't be confused for being time-distanced. + if (distance < stack_leniency) + continue; + + var observedTimeDistance = new ObservedTimeDistance(nextHitObject.StartTime, deltaTime, distance); + var expectedDistance = getExpectedDistance(prevObservedTimeDistances, observedTimeDistance); + + if (expectedDistance == 0) + { + // There was nothing relevant to compare to. + prevObservedTimeDistances.Add(observedTimeDistance); + continue; + } + + if ((Math.Abs(expectedDistance - distance) - distance_leniency_absolute_problem) / distance > distance_leniency_percent_problem) + yield return new IssueTemplateIrregularSpacingProblem(this).Create(expectedDistance, distance, hitObject, nextHitObject); + else if ((Math.Abs(expectedDistance - distance) - distance_leniency_absolute_warning) / distance > distance_leniency_percent_warning) + yield return new IssueTemplateIrregularSpacingWarning(this).Create(expectedDistance, distance, hitObject, nextHitObject); + else + { + // We use `else` here to prevent issues from cascading; an object spaced too far could cause regular spacing to be considered "too short" otherwise. + prevObservedTimeDistances.Add(observedTimeDistance); + } + } + } + + private double getExpectedDistance(IEnumerable prevObservedTimeDistances, ObservedTimeDistance observedTimeDistance) + { + var observations = prevObservedTimeDistances.Count(); + + int count = 0; + double sum = 0; + + // Looping this in reverse allows us to break before going through all elements, as we're only interested in the most recent ones. + for (int i = observations - 1; i >= 0; --i) + { + var prevObservedTimeDistance = prevObservedTimeDistances.ElementAt(i); + + // Only consider observations within the last few seconds - this allows the map to build spacing up/down over time, but prevents it from being too sudden. + if (observedTimeDistance.ObservationTime - prevObservedTimeDistance.ObservationTime > observation_lifetime) + break; + + // Only consider observations which have a similar time difference - this leniency allows handling of multi-BPM maps which speed up/down slowly. + if (Math.Abs(observedTimeDistance.DeltaTime - prevObservedTimeDistance.DeltaTime) > similar_time_leniency) + break; + + count += 1; + sum += prevObservedTimeDistance.Distance / Math.Max(prevObservedTimeDistance.DeltaTime, 1); + } + + return sum / Math.Max(count, 1) * observedTimeDistance.DeltaTime; + } + + public abstract class IssueTemplateIrregularSpacing : IssueTemplate + { + protected IssueTemplateIrregularSpacing(ICheck check, IssueType issueType) + : base(check, issueType, "Expected {0:0} px spacing like previous objects, currently {1:0}.") + { + } + + public Issue Create(double expected, double actual, params HitObject[] hitObjects) => new Issue(hitObjects, this, expected, actual); + } + + public class IssueTemplateIrregularSpacingProblem : IssueTemplateIrregularSpacing + { + public IssueTemplateIrregularSpacingProblem(ICheck check) + : base(check, IssueType.Problem) + { + } + } + + public class IssueTemplateIrregularSpacingWarning : IssueTemplateIrregularSpacing + { + public IssueTemplateIrregularSpacingWarning(ICheck check) + : base(check, IssueType.Warning) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSliders.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSliders.cs new file mode 100644 index 0000000000..159498c479 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSliders.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.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Edit.Checks +{ + public class CheckTooShortSliders : ICheck + { + /// + /// The shortest acceptable duration between the head and tail of the slider (so ignoring repeats). + /// + private const double span_duration_threshold = 125; // 240 BPM 1/2 + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Too short sliders"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateTooShort(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + if (context.InterpretedDifficulty > DifficultyRating.Easy) + yield break; + + foreach (var hitObject in context.Beatmap.HitObjects) + { + if (hitObject is Slider slider && slider.SpanDuration < span_duration_threshold) + yield return new IssueTemplateTooShort(this).Create(slider); + } + } + + public class IssueTemplateTooShort : IssueTemplate + { + public IssueTemplateTooShort(ICheck check) + : base(check, IssueType.Problem, "This slider is too short ({0:0} ms), expected at least {1:0} ms.") + { + } + + public Issue Create(Slider slider) => new Issue(slider, this, slider.SpanDuration, span_duration_threshold); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSpinners.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSpinners.cs new file mode 100644 index 0000000000..0d0c3d9e69 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSpinners.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.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Edit.Checks +{ + public class CheckTooShortSpinners : ICheck + { + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Too short spinners"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateTooShort(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + double od = context.Beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty; + + // These are meant to reflect the duration necessary for auto to score at least 1000 points on the spinner. + // It's difficult to eliminate warnings here, as auto achieving 1000 points depends on the approach angle on some spinners. + double warningThreshold = 500 + (od < 5 ? (5 - od) * -21.8 : (od - 5) * 20); // Anything above this is always ok. + double problemThreshold = 450 + (od < 5 ? (5 - od) * -17 : (od - 5) * 17); // Anything below this is never ok. + + foreach (var hitObject in context.Beatmap.HitObjects) + { + if (!(hitObject is Spinner spinner)) + continue; + + if (spinner.Duration < problemThreshold) + yield return new IssueTemplateTooShort(this).Create(spinner); + else if (spinner.Duration < warningThreshold) + yield return new IssueTemplateVeryShort(this).Create(spinner); + } + } + + public class IssueTemplateTooShort : IssueTemplate + { + public IssueTemplateTooShort(ICheck check) + : base(check, IssueType.Problem, "This spinner is too short. Auto cannot achieve 1000 points on this.") + { + } + + public Issue Create(Spinner spinner) => new Issue(spinner, this); + } + + public class IssueTemplateVeryShort : IssueTemplate + { + public IssueTemplateVeryShort(ICheck check) + : base(check, IssueType.Warning, "This spinner may be too short. Ensure auto can achieve 1000 points on this.") + { + } + + public Issue Create(Spinner spinner) => new Issue(spinner, this); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs index aeeae84d14..0e61c02e2d 100644 --- a/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs +++ b/osu.Game.Rulesets.Osu/Edit/DrawableOsuEditorRuleset.cs @@ -20,6 +20,12 @@ namespace osu.Game.Rulesets.Osu.Edit { public class DrawableOsuEditorRuleset : DrawableOsuRuleset { + /// + /// 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. + /// + public const double EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION = 700; + public DrawableOsuEditorRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) : base(ruleset, beatmap, mods) { @@ -46,12 +52,6 @@ namespace osu.Game.Rulesets.Osu.Edit d.ApplyCustomUpdateState += updateState; } - /// - /// Hit objects are intentionally made to fade out at a constant slower rate than in gameplay. - /// This allows a mapper to gain better historical context and use recent hitobjects as reference / snap points. - /// - private const double editor_hit_object_fade_out_extension = 700; - private void updateState(DrawableHitObject hitObject, ArmedState state) { if (state == ArmedState.Idle || hitAnimations.Value) @@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Edit if (hitObject is DrawableHitCircle circle) { circle.ApproachCircle - .FadeOutFromOne(editor_hit_object_fade_out_extension * 4) + .FadeOutFromOne(EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION * 4) .Expire(); circle.ApproachCircle.ScaleTo(1.1f, 300, Easing.OutQuint); @@ -69,14 +69,20 @@ namespace osu.Game.Rulesets.Osu.Edit if (hitObject is IHasMainCirclePiece mainPieceContainer) { // clear any explode animation logic. - mainPieceContainer.CirclePiece.ApplyTransformsAt(hitObject.HitStateUpdateTime, true); - mainPieceContainer.CirclePiece.ClearTransformsAfter(hitObject.HitStateUpdateTime, true); + // this is scheduled after children to ensure that the clear happens after invocations of ApplyCustomUpdateState on the circle piece's nested skinnables. + ScheduleAfterChildren(() => + { + if (hitObject.HitObject == null) return; + + mainPieceContainer.CirclePiece.ApplyTransformsAt(hitObject.StateUpdateTime, true); + mainPieceContainer.CirclePiece.ClearTransformsAfter(hitObject.StateUpdateTime, true); + }); } if (hitObject is DrawableSliderRepeat repeat) { - repeat.Arrow.ApplyTransformsAt(hitObject.HitStateUpdateTime, true); - repeat.Arrow.ClearTransformsAfter(hitObject.HitStateUpdateTime, true); + repeat.Arrow.ApplyTransformsAt(hitObject.StateUpdateTime, true); + repeat.Arrow.ClearTransformsAfter(hitObject.StateUpdateTime, true); } // adjust the visuals of top-level object types to make them stay on screen for longer than usual. @@ -93,7 +99,7 @@ namespace osu.Game.Rulesets.Osu.Edit hitObject.RemoveTransform(existing); using (hitObject.BeginAbsoluteSequence(hitObject.HitStateUpdateTime)) - hitObject.FadeOut(editor_hit_object_fade_out_extension).Expire(); + hitObject.FadeOut(EDITOR_HIT_OBJECT_FADE_OUT_EXTENSION).Expire(); break; } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs index 04e881fbf3..221723e4cd 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs @@ -13,7 +13,14 @@ namespace osu.Game.Rulesets.Osu.Edit { private readonly List checks = new List { - new CheckOffscreenObjects() + // Compose + new CheckOffscreenObjects(), + new CheckTooShortSpinners(), + + // Spread + new CheckTimeDistanceEquality(), + new CheckLowDiffOverlaps(), + new CheckTooShortSliders(), }; public IEnumerable Run(BeatmapVerifierContext context) diff --git a/osu.Game.Rulesets.Osu/Mods/IHidesApproachCircles.cs b/osu.Game.Rulesets.Osu/Mods/IHidesApproachCircles.cs new file mode 100644 index 0000000000..4a3b187e83 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/IHidesApproachCircles.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.Osu.Mods +{ + /// + /// Marker interface for any mod which completely hides the approach circles. + /// Used for incompatibility with . + /// + /// + /// Note that this is only a marker interface for incompatibility purposes, it does not change any gameplay behaviour. + /// + public interface IHidesApproachCircles + { + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/IRequiresApproachCircles.cs b/osu.Game.Rulesets.Osu/Mods/IRequiresApproachCircles.cs new file mode 100644 index 0000000000..1458abfe05 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/IRequiresApproachCircles.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.Osu.Mods +{ + /// + /// Marker interface for any mod which requires the approach circles to be visible. + /// Used for incompatibility with . + /// + /// + /// Note that this is only a marker interface for incompatibility purposes, it does not change any gameplay behaviour. + /// + public interface IRequiresApproachCircles + { + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs new file mode 100644 index 0000000000..d832411104 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.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 osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +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 OsuModApproachDifferent : Mod, IApplicableToDrawableHitObject, IRequiresApproachCircles + { + public override string Name => "Approach Different"; + public override string Acronym => "AD"; + public override string Description => "Never trust the approach circles..."; + public override double ScoreMultiplier => 1; + public override IconUsage? Icon { get; } = FontAwesome.Regular.Circle; + + public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles) }; + + [SettingSource("Initial size", "Change the initial size of the approach circle, relative to hit circles.", 0)] + public BindableFloat Scale { get; } = new BindableFloat(4) + { + Precision = 0.1f, + MinValue = 2, + MaxValue = 10, + }; + + [SettingSource("Style", "Change the animation style of the approach circles.", 1)] + public Bindable Style { get; } = new Bindable(); + + public void ApplyToDrawableHitObject(DrawableHitObject drawable) + { + drawable.ApplyCustomUpdateState += (drawableObject, state) => + { + if (!(drawableObject is DrawableHitCircle drawableHitCircle)) return; + + var hitCircle = drawableHitCircle.HitObject; + + drawableHitCircle.ApproachCircle.ClearTransforms(targetMember: nameof(Scale)); + + using (drawableHitCircle.BeginAbsoluteSequence(hitCircle.StartTime - hitCircle.TimePreempt)) + drawableHitCircle.ApproachCircle.ScaleTo(Scale.Value).ScaleTo(1f, hitCircle.TimePreempt, getEasing(Style.Value)); + }; + } + + private Easing getEasing(AnimationStyle style) + { + switch (style) + { + default: + return Easing.None; + + case AnimationStyle.Accelerate1: + return Easing.In; + + case AnimationStyle.Accelerate2: + return Easing.InCubic; + + case AnimationStyle.Accelerate3: + return Easing.InQuint; + + case AnimationStyle.Gravity: + return Easing.InBack; + + case AnimationStyle.Decelerate1: + return Easing.Out; + + case AnimationStyle.Decelerate2: + return Easing.OutCubic; + + case AnimationStyle.Decelerate3: + return Easing.OutQuint; + + case AnimationStyle.InOut1: + return Easing.InOutCubic; + + case AnimationStyle.InOut2: + return Easing.InOutQuint; + } + } + + public enum AnimationStyle + { + Gravity, + InOut1, + InOut2, + Accelerate1, + Accelerate2, + Accelerate3, + Decelerate1, + Decelerate2, + Decelerate3, + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs index 3b1f271d41..652da7123e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutoplay.cs @@ -6,14 +6,13 @@ using System.Collections.Generic; 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 OsuModAutoplay : ModAutoplay + public class OsuModAutoplay : ModAutoplay { public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).Append(typeof(OsuModSpunOut)).ToArray(); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs index 9ae9653e9b..9e71f657ce 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBarrelRoll.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -9,22 +8,19 @@ using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModBarrelRoll : ModBarrelRoll, IApplicableToDrawableHitObjects + public class OsuModBarrelRoll : ModBarrelRoll, IApplicableToDrawableHitObject { - public void ApplyToDrawableHitObjects(IEnumerable drawables) + public void ApplyToDrawableHitObject(DrawableHitObject d) { - foreach (var d in drawables) + d.OnUpdate += _ => { - d.OnUpdate += _ => + switch (d) { - switch (d) - { - case DrawableHitCircle circle: - circle.CirclePiece.Rotation = -CurrentRotation; - break; - } - }; - } + case DrawableHitCircle circle: + circle.CirclePiece.Rotation = -CurrentRotation; + break; + } + }; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs index 6841ecd23c..636cd63c69 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs @@ -27,8 +27,6 @@ namespace osu.Game.Rulesets.Osu.Mods public override IconUsage? Icon => FontAwesome.Solid.Adjust; public override ModType Type => ModType.DifficultyIncrease; - public override bool Ranked => false; - public override double ScoreMultiplier => 1.12; private DrawableOsuBlinds blinds; @@ -160,17 +158,17 @@ namespace osu.Game.Rulesets.Osu.Mods var firstObj = beatmap.HitObjects[0]; var startDelay = firstObj.StartTime - firstObj.TimePreempt; - using (BeginAbsoluteSequence(startDelay + break_close_late, true)) + using (BeginAbsoluteSequence(startDelay + break_close_late)) leaveBreak(); foreach (var breakInfo in beatmap.Breaks) { if (breakInfo.HasEffect) { - using (BeginAbsoluteSequence(breakInfo.StartTime - break_open_early, true)) + using (BeginAbsoluteSequence(breakInfo.StartTime - break_open_early)) { enterBreak(); - using (BeginDelayedSequence(breakInfo.Duration + break_open_early + break_close_late, true)) + using (BeginDelayedSequence(breakInfo.Duration + break_open_early + break_close_late)) leaveBreak(); } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs index 77dea5b0dc..e04a30d06c 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; using osu.Game.Configuration; @@ -15,7 +14,7 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModClassic : ModClassic, IApplicableToHitObject, IApplicableToDrawableHitObjects, IApplicableToDrawableRuleset + public class OsuModClassic : ModClassic, IApplicableToHitObject, IApplicableToDrawableHitObject, IApplicableToDrawableRuleset { [SettingSource("No slider head accuracy requirement", "Scores sliders proportionally to the number of ticks hit.")] public Bindable NoSliderHeadAccuracy { get; } = new BindableBool(true); @@ -54,24 +53,21 @@ namespace osu.Game.Rulesets.Osu.Mods osuRuleset.Playfield.HitPolicy = new ObjectOrderedHitPolicy(); } - public void ApplyToDrawableHitObjects(IEnumerable drawables) + public void ApplyToDrawableHitObject(DrawableHitObject obj) { - foreach (var obj in drawables) + switch (obj) { - switch (obj) - { - case DrawableSlider slider: - slider.Ball.InputTracksVisualSize = !FixedFollowCircleHitArea.Value; - break; + case DrawableSlider slider: + slider.Ball.InputTracksVisualSize = !FixedFollowCircleHitArea.Value; + break; - case DrawableSliderHead head: - head.TrackFollowCircle = !NoSliderHeadMovement.Value; - break; + case DrawableSliderHead head: + head.TrackFollowCircle = !NoSliderHeadMovement.Value; + break; - case DrawableSliderTail tail: - tail.SamplePlaysOnlyOnHit = !AlwaysPlayTailSample.Value; - break; - } + case DrawableSliderTail tail: + tail.SamplePlaysOnlyOnHit = !AlwaysPlayTailSample.Value; + break; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs index 1cb25edecf..3a6b232f9f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDifficultyAdjust.cs @@ -2,7 +2,6 @@ // 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; @@ -11,34 +10,26 @@ 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 BindableFloatWithLimitExtension + [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1, SettingControlType = typeof(DifficultyAdjustSettingsControl))] + public DifficultyBindable CircleSize { get; } = new DifficultyBindable { Precision = 0.1f, MinValue = 0, MaxValue = 10, - Default = 5, - Value = 5, + ExtendedMaxValue = 11, + ReadCurrentFromDifficulty = diff => diff.CircleSize, }; - [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1)] - public BindableNumber ApproachRate { get; } = new BindableFloatWithLimitExtension + [SettingSource("Approach Rate", "Override a beatmap's set AR.", LAST_SETTING_ORDER + 1, SettingControlType = typeof(DifficultyAdjustSettingsControl))] + public DifficultyBindable ApproachRate { get; } = new DifficultyBindable { Precision = 0.1f, MinValue = 0, MaxValue = 10, - Default = 5, - Value = 5, + ExtendedMaxValue = 11, + ReadCurrentFromDifficulty = diff => diff.ApproachRate, }; - protected override void ApplyLimits(bool extended) - { - base.ApplyLimits(extended); - - CircleSize.MaxValue = extended ? 11 : 10; - ApproachRate.MaxValue = extended ? 11 : 10; - } - public override string SettingDescription { get @@ -55,20 +46,12 @@ namespace osu.Game.Rulesets.Osu.Mods } } - 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); - ApplySetting(CircleSize, cs => difficulty.CircleSize = cs); - ApplySetting(ApproachRate, ar => difficulty.ApproachRate = ar); + if (CircleSize.Value != null) difficulty.CircleSize = CircleSize.Value.Value; + if (ApproachRate.Value != null) difficulty.ApproachRate = ApproachRate.Value.Value; } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs index 683b35f282..300a9d48aa 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFlashlight.cs @@ -2,8 +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.Bindables; using osu.Framework.Graphics; using osu.Framework.Input; @@ -19,7 +17,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModFlashlight : ModFlashlight, IApplicableToDrawableHitObjects + public class OsuModFlashlight : ModFlashlight, IApplicableToDrawableHitObject { public override double ScoreMultiplier => 1.12; @@ -31,12 +29,10 @@ namespace osu.Game.Rulesets.Osu.Mods public override Flashlight CreateFlashlight() => flashlight = new OsuFlashlight(); - public void ApplyToDrawableHitObjects(IEnumerable drawables) + public void ApplyToDrawableHitObject(DrawableHitObject drawable) { - foreach (var s in drawables.OfType()) - { + if (drawable is DrawableSlider s) s.Tracking.ValueChanged += flashlight.OnSliderTrackingChange; - } } public override void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs index e0577dd464..16c166257a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs @@ -14,7 +14,6 @@ namespace osu.Game.Rulesets.Osu.Mods public class OsuModHardRock : ModHardRock, IApplicableToHitObject { public override double ScoreMultiplier => 1.06; - public override bool Ranked => true; public void ApplyToHitObject(HitObject hitObject) { diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index 2752feb0a1..9c7784a00a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -11,15 +11,16 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Skinning; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModHidden : ModHidden + public class OsuModHidden : ModHidden, IHidesApproachCircles { 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) }; + public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn) }; private const double fade_in_duration_multiplier = 0.4; private const double fade_out_duration_multiplier = 0.3; @@ -110,6 +111,9 @@ namespace osu.Game.Rulesets.Osu.Mods // hide elements we don't care about. // todo: hide background + spinner.Body.OnSkinChanged += () => hideSpinnerApproachCircle(spinner); + hideSpinnerApproachCircle(spinner); + using (spinner.BeginAbsoluteSequence(fadeStartTime)) spinner.FadeOut(fadeDuration); @@ -160,5 +164,15 @@ namespace osu.Game.Rulesets.Osu.Mods } } } + + private static void hideSpinnerApproachCircle(DrawableSpinner spinner) + { + var approachCircle = (spinner.Body.Drawable as IHasApproachCircle)?.ApproachCircle; + if (approachCircle == null) + return; + + using (spinner.BeginAbsoluteSequence(spinner.HitObject.StartTime - spinner.HitObject.TimePreempt)) + approachCircle.Hide(); + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs index d1be162f73..778447e444 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModObjectScaleTween.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Mods /// /// Adjusts the size of hit objects during their fade in animation. /// - public abstract class OsuModObjectScaleTween : ModWithVisibilityAdjustment + public abstract class OsuModObjectScaleTween : ModWithVisibilityAdjustment, IHidesApproachCircles { public override ModType Type => ModType.Fun; @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods protected virtual float EndScale => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModSpinIn), typeof(OsuModTraceable) }; + public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn) }; protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) { diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs new file mode 100644 index 0000000000..1a2e5d92b4 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -0,0 +1,306 @@ +// Copyright (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.Primitives; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Osu.Utils; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Mods +{ + /// + /// Mod that randomises the positions of the s + /// + public class OsuModRandom : ModRandom, IApplicableToBeatmap + { + public override string Description => "It never gets boring!"; + + private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast; + + /// + /// Number of previous hitobjects to be shifted together when another object is being moved. + /// + private const int preceding_hitobjects_to_shift = 10; + + private Random rng; + + public void ApplyToBeatmap(IBeatmap beatmap) + { + if (!(beatmap is OsuBeatmap osuBeatmap)) + return; + + var hitObjects = osuBeatmap.HitObjects; + + Seed.Value ??= RNG.Next(); + + rng = new Random((int)Seed.Value); + + RandomObjectInfo previous = null; + + float rateOfChangeMultiplier = 0; + + for (int i = 0; i < hitObjects.Count; i++) + { + var hitObject = hitObjects[i]; + + var current = new RandomObjectInfo(hitObject); + + // rateOfChangeMultiplier only changes every 5 iterations in a combo + // to prevent shaky-line-shaped streams + if (hitObject.IndexInCurrentCombo % 5 == 0) + rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1; + + if (hitObject is Spinner) + { + previous = null; + continue; + } + + applyRandomisation(rateOfChangeMultiplier, previous, current); + + // Move hit objects back into the playfield if they are outside of it + Vector2 shift = Vector2.Zero; + + switch (hitObject) + { + case HitCircle circle: + shift = clampHitCircleToPlayfield(circle, current); + break; + + case Slider slider: + shift = clampSliderToPlayfield(slider, current); + break; + } + + if (shift != Vector2.Zero) + { + var toBeShifted = new List(); + + for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--) + { + // only shift hit circles + if (!(hitObjects[j] is HitCircle)) break; + + toBeShifted.Add(hitObjects[j]); + } + + if (toBeShifted.Count > 0) + applyDecreasingShift(toBeShifted, shift); + } + + previous = current; + } + } + + /// + /// Returns the final position of the hit object + /// + /// Final position of the hit object + private void applyRandomisation(float rateOfChangeMultiplier, RandomObjectInfo previous, RandomObjectInfo current) + { + if (previous == null) + { + var playfieldSize = OsuPlayfield.BASE_SIZE; + + current.AngleRad = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); + current.PositionRandomised = new Vector2((float)rng.NextDouble() * playfieldSize.X, (float)rng.NextDouble() * playfieldSize.Y); + + return; + } + + float distanceToPrev = Vector2.Distance(previous.EndPositionOriginal, current.PositionOriginal); + + // The max. angle (relative to the angle of the vector pointing from the 2nd last to the last hit object) + // is proportional to the distance between the last and the current hit object + // to allow jumps and prevent too sharp turns during streams. + + // Allow maximum jump angle when jump distance is more than half of playfield diagonal length + var randomAngleRad = rateOfChangeMultiplier * 2 * Math.PI * Math.Min(1f, distanceToPrev / (playfield_diagonal * 0.5f)); + + current.AngleRad = (float)randomAngleRad + previous.AngleRad; + if (current.AngleRad < 0) + current.AngleRad += 2 * (float)Math.PI; + + var posRelativeToPrev = new Vector2( + distanceToPrev * (float)Math.Cos(current.AngleRad), + distanceToPrev * (float)Math.Sin(current.AngleRad) + ); + + posRelativeToPrev = OsuHitObjectGenerationUtils.RotateAwayFromEdge(previous.EndPositionRandomised, posRelativeToPrev); + + current.AngleRad = (float)Math.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X); + + current.PositionRandomised = previous.EndPositionRandomised + posRelativeToPrev; + } + + /// + /// Move the randomised position of a hit circle so that it fits inside the playfield. + /// + /// The deviation from the original randomised position in order to fit within the playfield. + private Vector2 clampHitCircleToPlayfield(HitCircle circle, RandomObjectInfo objectInfo) + { + var previousPosition = objectInfo.PositionRandomised; + objectInfo.EndPositionRandomised = objectInfo.PositionRandomised = clampToPlayfieldWithPadding( + objectInfo.PositionRandomised, + (float)circle.Radius + ); + + circle.Position = objectInfo.PositionRandomised; + + return objectInfo.PositionRandomised - previousPosition; + } + + /// + /// Moves the and all necessary nested s into the if they aren't already. + /// + /// The deviation from the original randomised position in order to fit within the playfield. + private Vector2 clampSliderToPlayfield(Slider slider, RandomObjectInfo objectInfo) + { + var possibleMovementBounds = calculatePossibleMovementBounds(slider); + + var previousPosition = objectInfo.PositionRandomised; + + // Clamp slider position to the placement area + // If the slider is larger than the playfield, force it to stay at the original position + var newX = possibleMovementBounds.Width < 0 + ? objectInfo.PositionOriginal.X + : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right); + + var newY = possibleMovementBounds.Height < 0 + ? objectInfo.PositionOriginal.Y + : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom); + + slider.Position = objectInfo.PositionRandomised = new Vector2(newX, newY); + objectInfo.EndPositionRandomised = slider.EndPosition; + + shiftNestedObjects(slider, objectInfo.PositionRandomised - objectInfo.PositionOriginal); + + return objectInfo.PositionRandomised - previousPosition; + } + + /// + /// Decreasingly shift a list of s by a specified amount. + /// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount. + /// + /// The list of hit objects to be shifted. + /// The amount to be shifted. + private void applyDecreasingShift(IList hitObjects, Vector2 shift) + { + for (int i = 0; i < hitObjects.Count; i++) + { + var hitObject = hitObjects[i]; + // The first object is shifted by a vector slightly smaller than shift + // The last object is shifted by a vector slightly larger than zero + Vector2 position = hitObject.Position + shift * ((hitObjects.Count - i) / (float)(hitObjects.Count + 1)); + + hitObject.Position = clampToPlayfieldWithPadding(position, (float)hitObject.Radius); + } + } + + /// + /// Calculates a which contains all of the possible movements of the slider (in relative X/Y coordinates) + /// such that the entire slider is inside the playfield. + /// + /// + /// If the slider is larger than the playfield, the returned may have negative width/height. + /// + private RectangleF calculatePossibleMovementBounds(Slider slider) + { + var pathPositions = new List(); + slider.Path.GetPathToProgress(pathPositions, 0, 1); + + float minX = float.PositiveInfinity; + float maxX = float.NegativeInfinity; + + float minY = float.PositiveInfinity; + float maxY = float.NegativeInfinity; + + // Compute the bounding box of the slider. + foreach (var pos in pathPositions) + { + minX = MathF.Min(minX, pos.X); + maxX = MathF.Max(maxX, pos.X); + + minY = MathF.Min(minY, pos.Y); + maxY = MathF.Max(maxY, pos.Y); + } + + // Take the circle radius into account. + var radius = (float)slider.Radius; + + minX -= radius; + minY -= radius; + + maxX += radius; + maxY += radius; + + // Given the bounding box of the slider (via min/max X/Y), + // the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right), + // and the amount that it can move to the right is WIDTH - maxX. + // Same calculation applies for the Y axis. + float left = -minX; + float right = OsuPlayfield.BASE_SIZE.X - maxX; + float top = -minY; + float bottom = OsuPlayfield.BASE_SIZE.Y - maxY; + + return new RectangleF(left, top, right - left, bottom - top); + } + + /// + /// Shifts all nested s and s by the specified shift. + /// + /// whose nested s and s should be shifted + /// The the 's nested s and s should be shifted by + private void shiftNestedObjects(Slider slider, Vector2 shift) + { + foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat)) + { + if (!(hitObject is OsuHitObject osuHitObject)) + continue; + + osuHitObject.Position += shift; + } + } + + /// + /// Clamp a position to playfield, keeping a specified distance from the edges. + /// + /// The position to be clamped. + /// The minimum distance allowed from playfield edges. + /// The clamped position. + private Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding) + { + return new Vector2( + Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding), + Math.Clamp(position.Y, padding, OsuPlayfield.BASE_SIZE.Y - padding) + ); + } + + private class RandomObjectInfo + { + public float AngleRad { get; set; } + + public Vector2 PositionOriginal { get; } + public Vector2 PositionRandomised { get; set; } + + public Vector2 EndPositionOriginal { get; } + public Vector2 EndPositionRandomised { get; set; } + + public RandomObjectInfo(OsuHitObject hitObject) + { + PositionRandomised = PositionOriginal = hitObject.Position; + EndPositionRandomised = EndPositionOriginal = hitObject.EndPosition; + AngleRad = 0; + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs index 96ba58da23..95e7d13ee7 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs @@ -12,7 +12,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModSpinIn : ModWithVisibilityAdjustment + public class OsuModSpinIn : ModWithVisibilityAdjustment, IHidesApproachCircles { public override string Name => "Spin In"; public override string Acronym => "SI"; @@ -21,8 +21,9 @@ namespace osu.Game.Rulesets.Osu.Mods 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) }; + // todo: this mod needs to be incompatible with "hidden" due to forcing the circle to remain opaque, + // further implementation will be required for supporting that. + public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModObjectScaleTween), typeof(OsuModHidden) }; private const int rotate_offset = 360; private const float rotate_starting_width = 2; @@ -43,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Mods switch (drawable) { case DrawableHitCircle circle: - using (circle.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) + using (circle.BeginAbsoluteSequence(h.StartTime - h.TimePreempt)) { circle.ApproachCircle.Hide(); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs index f080e11933..c7f4811701 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.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.Framework.Utils; @@ -13,7 +12,7 @@ using osu.Game.Rulesets.Osu.Objects.Drawables; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModSpunOut : Mod, IApplicableToDrawableHitObjects + public class OsuModSpunOut : Mod, IApplicableToDrawableHitObject { public override string Name => "Spun Out"; public override string Acronym => "SO"; @@ -21,18 +20,14 @@ namespace osu.Game.Rulesets.Osu.Mods 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) + public void ApplyToDrawableHitObject(DrawableHitObject hitObject) { - foreach (var hitObject in drawables) + if (hitObject is DrawableSpinner spinner) { - if (hitObject is DrawableSpinner spinner) - { - spinner.HandleUserInput = false; - spinner.OnUpdate += onSpinnerUpdate; - } + spinner.HandleUserInput = false; + spinner.OnUpdate += onSpinnerUpdate; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs index 2464308347..e21d1da009 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs @@ -1,13 +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.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Osu.Utils; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModTarget : Mod + public class OsuModTarget : ModWithVisibilityAdjustment, IApplicableToDrawableRuleset, + IApplicableToHealthProcessor, IApplicableToDifficulty, IApplicableFailOverride, + IHasSeed, IHidesApproachCircles { public override string Name => "Target"; public override string Acronym => "TP"; @@ -15,5 +45,510 @@ namespace osu.Game.Rulesets.Osu.Mods public override IconUsage? Icon => OsuIcon.ModTarget; public override string Description => @"Practice keeping up with the beat of the song."; public override double ScoreMultiplier => 1; + + public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles) }; + + [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable + { + Default = null, + Value = null + }; + + #region Constants + + /// + /// Jump distance for circles in the last combo + /// + private const float max_base_distance = 333f; + + /// + /// The maximum allowed jump distance after multipliers are applied + /// + private const float distance_cap = 380f; + + // The distances from the hit objects to the borders of the playfield they start to "turn around" and curve towards the middle. + // The closer the hit objects draw to the border, the sharper the turn + private const byte border_distance_x = 192; + private const byte border_distance_y = 144; + + /// + /// The extent of rotation towards playfield centre when a circle is near the edge + /// + private const float edge_rotation_multiplier = 0.75f; + + /// + /// Number of recent circles to check for overlap + /// + private const int overlap_check_count = 5; + + /// + /// Duration of the undimming animation + /// + private const double undim_duration = 96; + + /// + /// Acceptable difference for timing comparisons + /// + private const double timing_precision = 1; + + #endregion + + #region Private Fields + + private ControlPointInfo controlPointInfo; + + private List originalHitObjects; + + private Random rng; + + #endregion + + #region Sudden Death (IApplicableFailOverride) + + public bool PerformFail() => true; + + public bool RestartOnFail => false; + + public void ApplyToHealthProcessor(HealthProcessor healthProcessor) + { + // Sudden death + healthProcessor.FailConditions += (_, result) + => result.Type.AffectsCombo() + && !result.IsHit; + } + + #endregion + + #region Reduce AR (IApplicableToDifficulty) + + public void ReadFromDifficulty(BeatmapDifficulty difficulty) + { + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + // Decrease AR to increase preempt time + difficulty.ApproachRate *= 0.5f; + } + + #endregion + + #region Circle Transforms (ModWithVisibilityAdjustment) + + protected override void ApplyIncreasedVisibilityState(DrawableHitObject drawable, ArmedState state) + { + } + + protected override void ApplyNormalVisibilityState(DrawableHitObject drawable, ArmedState state) + { + if (!(drawable is DrawableHitCircle circle)) return; + + double startTime = circle.HitObject.StartTime; + double preempt = circle.HitObject.TimePreempt; + + using (circle.BeginAbsoluteSequence(startTime - preempt)) + { + // initial state + circle.ScaleTo(0.5f) + .FadeColour(OsuColour.Gray(0.5f)); + + // scale to final size + circle.ScaleTo(1f, preempt); + + // Remove approach circles + circle.ApproachCircle.Hide(); + } + + using (circle.BeginAbsoluteSequence(startTime - controlPointInfo.TimingPointAt(startTime).BeatLength - undim_duration)) + circle.FadeColour(Color4.White, undim_duration); + } + + #endregion + + #region Beatmap Generation (IApplicableToBeatmap) + + public override void ApplyToBeatmap(IBeatmap beatmap) + { + Seed.Value ??= RNG.Next(); + rng = new Random(Seed.Value.Value); + + var osuBeatmap = (OsuBeatmap)beatmap; + + if (osuBeatmap.HitObjects.Count == 0) return; + + controlPointInfo = osuBeatmap.ControlPointInfo; + originalHitObjects = osuBeatmap.HitObjects.OrderBy(x => x.StartTime).ToList(); + + var hitObjects = generateBeats(osuBeatmap) + .Select(beat => + { + var newCircle = new HitCircle(); + newCircle.ApplyDefaults(controlPointInfo, osuBeatmap.BeatmapInfo.BaseDifficulty); + newCircle.StartTime = beat; + return (OsuHitObject)newCircle; + }).ToList(); + + addHitSamples(hitObjects); + + fixComboInfo(hitObjects); + + randomizeCirclePos(hitObjects); + + osuBeatmap.HitObjects = hitObjects; + + base.ApplyToBeatmap(beatmap); + } + + private IEnumerable generateBeats(IBeatmap beatmap) + { + var startTime = originalHitObjects.First().StartTime; + var endTime = originalHitObjects.Last().GetEndTime(); + + var beats = beatmap.ControlPointInfo.TimingPoints + // Ignore timing points after endTime + .Where(timingPoint => !definitelyBigger(timingPoint.Time, endTime)) + // Generate the beats + .SelectMany(timingPoint => getBeatsForTimingPoint(timingPoint, endTime)) + // Remove beats before startTime + .Where(beat => almostBigger(beat, startTime)) + // Remove beats during breaks + .Where(beat => !isInsideBreakPeriod(beatmap.Breaks, beat)) + .ToList(); + + // Remove beats that are too close to the next one (e.g. due to timing point changes) + for (var i = beats.Count - 2; i >= 0; i--) + { + var beat = beats[i]; + + if (!definitelyBigger(beats[i + 1] - beat, beatmap.ControlPointInfo.TimingPointAt(beat).BeatLength / 2)) + beats.RemoveAt(i); + } + + return beats; + } + + private void addHitSamples(IEnumerable hitObjects) + { + foreach (var obj in hitObjects) + { + var samples = getSamplesAtTime(originalHitObjects, obj.StartTime); + + // If samples aren't available at the exact start time of the object, + // use samples (without additions) in the closest original hit object instead + obj.Samples = samples ?? getClosestHitObject(originalHitObjects, obj.StartTime).Samples.Where(s => !HitSampleInfo.AllAdditions.Contains(s.Name)).ToList(); + } + } + + private void fixComboInfo(List hitObjects) + { + // Copy combo indices from an original object at the same time or from the closest preceding object + // (Objects lying between two combos are assumed to belong to the preceding combo) + hitObjects.ForEach(newObj => + { + var closestOrigObj = originalHitObjects.FindLast(y => almostBigger(newObj.StartTime, y.StartTime)); + + // It shouldn't be possible for closestOrigObj to be null + // But if it is, obj should be in the first combo + newObj.ComboIndex = closestOrigObj?.ComboIndex ?? 0; + }); + + // The copied combo indices may not be continuous if the original map starts and ends a combo in between beats + // e.g. A stream with each object starting a new combo + // So combo indices need to be reprocessed to ensure continuity + // Other kinds of combo info are also added in the process + var combos = hitObjects.GroupBy(x => x.ComboIndex).ToList(); + + for (var i = 0; i < combos.Count; i++) + { + var group = combos[i].ToList(); + group.First().NewCombo = true; + group.Last().LastInCombo = true; + + for (var j = 0; j < group.Count; j++) + { + var x = group[j]; + x.ComboIndex = i; + x.IndexInCurrentCombo = j; + } + } + } + + private void randomizeCirclePos(IReadOnlyList hitObjects) + { + if (hitObjects.Count == 0) return; + + float nextSingle(float max = 1f) => (float)(rng.NextDouble() * max); + + const float two_pi = MathF.PI * 2; + + var direction = two_pi * nextSingle(); + var maxComboIndex = hitObjects.Last().ComboIndex; + + for (var i = 0; i < hitObjects.Count; i++) + { + var obj = hitObjects[i]; + var lastPos = i == 0 + ? Vector2.Divide(OsuPlayfield.BASE_SIZE, 2) + : hitObjects[i - 1].Position; + + var distance = maxComboIndex == 0 + ? (float)obj.Radius + : mapRange(obj.ComboIndex, 0, maxComboIndex, (float)obj.Radius, max_base_distance); + if (obj.NewCombo) distance *= 1.5f; + if (obj.Kiai) distance *= 1.2f; + distance = Math.Min(distance_cap, distance); + + // Attempt to place the circle at a place that does not overlap with previous ones + + var tryCount = 0; + + // for checking overlap + var precedingObjects = hitObjects.SkipLast(hitObjects.Count - i).TakeLast(overlap_check_count).ToList(); + + do + { + if (tryCount > 0) direction = two_pi * nextSingle(); + + var relativePos = new Vector2( + distance * MathF.Cos(direction), + distance * MathF.Sin(direction) + ); + // Rotate the new circle away from playfield border + relativePos = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastPos, relativePos, edge_rotation_multiplier); + direction = MathF.Atan2(relativePos.Y, relativePos.X); + + var newPosition = Vector2.Add(lastPos, relativePos); + + obj.Position = newPosition; + + clampToPlayfield(obj); + + tryCount++; + if (tryCount % 10 == 0) distance *= 0.9f; + } while (distance >= obj.Radius * 2 && checkForOverlap(precedingObjects, obj)); + + if (obj.LastInCombo) + direction = two_pi * nextSingle(); + else + direction += distance / distance_cap * (nextSingle() * two_pi - MathF.PI); + } + } + + #endregion + + #region Metronome (IApplicableToDrawableRuleset) + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + drawableRuleset.Overlays.Add(new TargetBeatContainer(drawableRuleset.Beatmap.HitObjects.First().StartTime)); + } + + public class TargetBeatContainer : BeatSyncedContainer + { + private readonly double firstHitTime; + + private PausableSkinnableSound sample; + + public TargetBeatContainer(double firstHitTime) + { + this.firstHitTime = firstHitTime; + AllowMistimedEventFiring = false; + Divisor = 1; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + sample = new PausableSkinnableSound(new SampleInfo("Gameplay/catch-banana")) + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + + if (!IsBeatSyncedWithTrack) return; + + int timeSignature = (int)timingPoint.TimeSignature; + + // play metronome from one measure before the first object. + if (BeatSyncClock.CurrentTime < firstHitTime - timingPoint.BeatLength * timeSignature) + return; + + sample.Frequency.Value = beatIndex % timeSignature == 0 ? 1 : 0.5f; + sample.Play(); + } + } + + #endregion + + #region Helper Subroutines + + /// + /// Check if a given time is inside a . + /// + /// + /// The given time is also considered to be inside a break if it is earlier than the + /// start time of the first original hit object after the break. + /// + /// The breaks of the beatmap. + /// The time to be checked.= + private bool isInsideBreakPeriod(IEnumerable breaks, double time) + { + return breaks.Any(breakPeriod => + { + var firstObjAfterBreak = originalHitObjects.First(obj => almostBigger(obj.StartTime, breakPeriod.EndTime)); + + return almostBigger(time, breakPeriod.StartTime) + && definitelyBigger(firstObjAfterBreak.StartTime, time); + }); + } + + private IEnumerable getBeatsForTimingPoint(TimingControlPoint timingPoint, double mapEndTime) + { + var beats = new List(); + int i = 0; + var currentTime = timingPoint.Time; + + while (!definitelyBigger(currentTime, mapEndTime) && controlPointInfo.TimingPointAt(currentTime) == timingPoint) + { + beats.Add(Math.Floor(currentTime)); + i++; + currentTime = timingPoint.Time + i * timingPoint.BeatLength; + } + + return beats; + } + + private OsuHitObject getClosestHitObject(List hitObjects, double time) + { + var precedingIndex = hitObjects.FindLastIndex(h => h.StartTime < time); + + if (precedingIndex == hitObjects.Count - 1) return hitObjects[precedingIndex]; + + // return the closest preceding/succeeding hit object, whoever is closer in time + return hitObjects[precedingIndex + 1].StartTime - time < time - hitObjects[precedingIndex].StartTime + ? hitObjects[precedingIndex + 1] + : hitObjects[precedingIndex]; + } + + /// + /// Get samples (if any) for a specific point in time. + /// + /// + /// Samples will be returned if a hit circle or a slider node exists at that point of time. + /// + /// The list of hit objects in a beatmap, ordered by StartTime + /// The point in time to get samples for + /// Hit samples + private IList getSamplesAtTime(IEnumerable hitObjects, double time) + { + // Get a hit object that + // either has StartTime equal to the target time + // or has a repeat node at the target time + var sampleObj = hitObjects.FirstOrDefault(hitObject => + { + if (almostEquals(time, hitObject.StartTime)) + return true; + + if (!(hitObject is IHasRepeats s)) + return false; + // If time is outside the duration of the IHasRepeats, + // then this hitObject isn't the one we want + if (!almostBigger(time, hitObject.StartTime) + || !almostBigger(s.EndTime, time)) + return false; + + return nodeIndexFromTime(s, time - hitObject.StartTime) != -1; + }); + if (sampleObj == null) return null; + + IList samples; + + if (sampleObj is IHasRepeats slider) + samples = slider.NodeSamples[nodeIndexFromTime(slider, time - sampleObj.StartTime)]; + else + samples = sampleObj.Samples; + + return samples; + } + + /// + /// Get the repeat node at a point in time. + /// + /// The slider. + /// The time since the start time of the slider. + /// Index of the node. -1 if there isn't a node at the specific time. + private int nodeIndexFromTime(IHasRepeats curve, double timeSinceStart) + { + double spanDuration = curve.Duration / curve.SpanCount(); + double nodeIndex = timeSinceStart / spanDuration; + + if (almostEquals(nodeIndex, Math.Round(nodeIndex))) + return (int)Math.Round(nodeIndex); + + return -1; + } + + private bool checkForOverlap(IEnumerable objectsToCheck, OsuHitObject target) + { + return objectsToCheck.Any(h => Vector2.Distance(h.Position, target.Position) < target.Radius * 2); + } + + /// + /// Move the hit object into playfield, taking its radius into account. + /// + /// The hit object to be clamped. + private void clampToPlayfield(OsuHitObject obj) + { + var position = obj.Position; + var radius = (float)obj.Radius; + + if (position.Y < radius) + position.Y = radius; + else if (position.Y > OsuPlayfield.BASE_SIZE.Y - radius) + position.Y = OsuPlayfield.BASE_SIZE.Y - radius; + + if (position.X < radius) + position.X = radius; + else if (position.X > OsuPlayfield.BASE_SIZE.X - radius) + position.X = OsuPlayfield.BASE_SIZE.X - radius; + + obj.Position = position; + } + + /// + /// Re-maps a number from one range to another. + /// + /// The number to be re-mapped. + /// Beginning of the original range. + /// End of the original range. + /// Beginning of the new range. + /// End of the new range. + /// The re-mapped number. + private static float mapRange(float value, float fromLow, float fromHigh, float toLow, float toHigh) + { + return (value - fromLow) * (toHigh - toLow) / (fromHigh - fromLow) + toLow; + } + + private static bool almostBigger(double value1, double value2) + { + return Precision.AlmostBigger(value1, value2, timing_precision); + } + + private static bool definitelyBigger(double value1, double value2) + { + return Precision.DefinitelyBigger(value1, value2, timing_precision); + } + + private static bool almostEquals(double value1, double value2) + { + return Precision.AlmostEquals(value1, value2, timing_precision); + } + + #endregion } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs index 3b16e9d2b7..7276cc753c 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs @@ -13,7 +13,5 @@ namespace osu.Game.Rulesets.Osu.Mods public override double ScoreMultiplier => 1; public override ModType Type => ModType.System; - - public override bool Ranked => true; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index 4b0939db16..07ce009cf8 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -11,7 +11,7 @@ using osu.Game.Rulesets.Osu.Skinning.Default; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModTraceable : ModWithVisibilityAdjustment + public class OsuModTraceable : ModWithVisibilityAdjustment, IRequiresApproachCircles { public override string Name => "Traceable"; public override string Acronym => "TC"; @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods 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) }; + public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles) }; protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) { @@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Mods private void applyCirclePieceState(DrawableOsuHitObject hitObject, IDrawable hitCircle = null) { var h = hitObject.HitObject; - using (hitObject.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) + using (hitObject.BeginAbsoluteSequence(h.StartTime - h.TimePreempt)) (hitCircle ?? hitObject).Hide(); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs index b5905d7015..8122ab563e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs @@ -50,7 +50,7 @@ namespace osu.Game.Rulesets.Osu.Mods double appearTime = hitObject.StartTime - hitObject.TimePreempt - 1; double moveDuration = hitObject.TimePreempt + 1; - using (drawable.BeginAbsoluteSequence(appearTime, true)) + using (drawable.BeginAbsoluteSequence(appearTime)) { drawable .MoveToOffset(appearOffset) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs index a01cec4bb3..ff6ba6e121 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Osu.Mods for (int i = 0; i < amountWiggles; i++) { - using (drawable.BeginAbsoluteSequence(osuObject.StartTime - osuObject.TimePreempt + i * wiggle_duration, true)) + using (drawable.BeginAbsoluteSequence(osuObject.StartTime - osuObject.TimePreempt + i * wiggle_duration)) wiggle(); } @@ -65,7 +65,7 @@ namespace osu.Game.Rulesets.Osu.Mods for (int i = 0; i < amountWiggles; i++) { - using (drawable.BeginAbsoluteSequence(osuObject.StartTime + i * wiggle_duration, true)) + using (drawable.BeginAbsoluteSequence(osuObject.StartTime + i * wiggle_duration)) wiggle(); } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs index cda4715280..001ea6c4ad 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointConnection.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Pooling; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Pooling; using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections @@ -12,34 +13,29 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections /// /// Visualises the s between two s. /// - public class FollowPointConnection : PoolableDrawable + public class FollowPointConnection : PoolableDrawableWithLifetime { // Todo: These shouldn't be constants public const int SPACING = 32; public const double PREEMPT = 800; - public FollowPointLifetimeEntry Entry; public DrawablePool Pool; - protected override void PrepareForUse() + protected override void OnApply(FollowPointLifetimeEntry entry) { - base.PrepareForUse(); - - Entry.Invalidated += onEntryInvalidated; + base.OnApply(entry); + entry.Invalidated += onEntryInvalidated; refreshPoints(); } - protected override void FreeAfterUse() + protected override void OnFree(FollowPointLifetimeEntry entry) { - base.FreeAfterUse(); - - Entry.Invalidated -= onEntryInvalidated; + base.OnFree(entry); + entry.Invalidated -= onEntryInvalidated; // Return points to the pool. ClearInternal(false); - - Entry = null; } private void onEntryInvalidated() => Scheduler.AddOnce(refreshPoints); @@ -48,8 +44,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { ClearInternal(false); - OsuHitObject start = Entry.Start; - OsuHitObject end = Entry.End; + var entry = Entry; + if (entry?.End == null) return; + + OsuHitObject start = entry.Start; + OsuHitObject end = entry.End; double startTime = start.GetEndTime(); @@ -87,14 +86,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections 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); + fp.Delay(fadeOutTime - fadeInTime).FadeOut(end.TimeFadeIn).Expire(); - finalTransformEndTime = fadeOutTime + end.TimeFadeIn; + finalTransformEndTime = fp.LifetimeEnd; } } - // todo: use Expire() on FollowPoints and take lifetime from them when https://github.com/ppy/osu-framework/issues/3300 is fixed. - Entry.LifetimeEnd = finalTransformEndTime; + entry.LifetimeEnd = finalTransformEndTime; } /// diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs index a167cb2f0f..82bca0a4e2 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointLifetimeEntry.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. +#nullable enable + using System; using osu.Framework.Bindables; using osu.Framework.Graphics.Performance; @@ -11,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { public class FollowPointLifetimeEntry : LifetimeEntry { - public event Action Invalidated; + public event Action? Invalidated; public readonly OsuHitObject Start; public FollowPointLifetimeEntry(OsuHitObject start) @@ -22,9 +24,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections bindEvents(); } - private OsuHitObject end; + private OsuHitObject? end; - public OsuHitObject End + public OsuHitObject? End { get => end; set @@ -56,11 +58,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections public void UnbindEvents() { - if (Start != null) - { - Start.DefaultsApplied -= onDefaultsApplied; - Start.PositionBindable.ValueChanged -= onPositionChanged; - } + Start.DefaultsApplied -= onDefaultsApplied; + Start.PositionBindable.ValueChanged -= onPositionChanged; if (End != null) { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs index 3e85e528e8..21e6619444 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs @@ -6,43 +6,32 @@ 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.Performance; using osu.Framework.Graphics.Pooling; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Pooling; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections { /// /// Visualises connections between s. /// - public class FollowPointRenderer : CompositeDrawable + public class FollowPointRenderer : PooledDrawableWithLifetimeContainer { - public override bool RemoveCompletedTransforms => false; - - public IReadOnlyList Entries => lifetimeEntries; + public new IReadOnlyList Entries => lifetimeEntries; private DrawablePool connectionPool; private DrawablePool pointPool; private readonly List lifetimeEntries = new List(); - private readonly Dictionary connectionsInUse = new Dictionary(); private readonly Dictionary startTimeMap = new Dictionary(); - private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager(); - - public FollowPointRenderer() - { - lifetimeManager.EntryBecameAlive += onEntryBecameAlive; - lifetimeManager.EntryBecameDead += onEntryBecameDead; - } [BackgroundDependencyLoader] private void load() { InternalChildren = new Drawable[] { - connectionPool = new DrawablePoolNoLifetime(1, 200), - pointPool = new DrawablePoolNoLifetime(50, 1000) + connectionPool = new DrawablePool(1, 200), + pointPool = new DrawablePool(50, 1000) }; } @@ -107,7 +96,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections previousEntry.End = newEntry.Start; } - lifetimeManager.AddEntry(newEntry); + Add(newEntry); } private void removeEntry(OsuHitObject hitObject) @@ -118,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections entry.UnbindEvents(); lifetimeEntries.RemoveAt(index); - lifetimeManager.RemoveEntry(entry); + Remove(entry); if (index > 0) { @@ -131,30 +120,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections } } - protected override bool CheckChildrenLife() + protected override FollowPointConnection GetDrawable(FollowPointLifetimeEntry entry) { - bool anyAliveChanged = base.CheckChildrenLife(); - anyAliveChanged |= lifetimeManager.Update(Time.Current); - return anyAliveChanged; - } - - private void onEntryBecameAlive(LifetimeEntry entry) - { - var connection = connectionPool.Get(c => - { - c.Entry = (FollowPointLifetimeEntry)entry; - c.Pool = pointPool; - }); - - connectionsInUse[entry] = connection; - - AddInternal(connection); - } - - private void onEntryBecameDead(LifetimeEntry entry) - { - RemoveInternal(connectionsInUse[entry]); - connectionsInUse.Remove(entry); + var connection = connectionPool.Get(); + connection.Pool = pointPool; + connection.Apply(entry); + return connection; } private void onStartTimeChanged(OsuHitObject hitObject) @@ -171,16 +142,5 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections entry.UnbindEvents(); lifetimeEntries.Clear(); } - - private class DrawablePoolNoLifetime : DrawablePool - where T : PoolableDrawable, new() - { - public override bool RemoveWhenNotAlive => false; - - public DrawablePoolNoLifetime(int initialSize, int? maximumSize = null) - : base(initialSize, maximumSize) - { - } - } } } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs index 236af4b3f1..46fc8f99b2 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableHitCircle.cs @@ -12,6 +12,7 @@ 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.Skinning; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; @@ -19,7 +20,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Objects.Drawables { - public class DrawableHitCircle : DrawableOsuHitObject, IHasMainCirclePiece + public class DrawableHitCircle : DrawableOsuHitObject, IHasMainCirclePiece, IHasApproachCircle { public OsuAction? HitAction => HitArea.HitAction; protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; @@ -28,6 +29,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public HitReceptor HitArea { get; private set; } public SkinnableDrawable CirclePiece { get; private set; } + Drawable IHasApproachCircle.ApproachCircle => ApproachCircle; + private Container scaleContainer; private InputManager inputManager; @@ -172,6 +175,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateStartTimeStateTransforms(); + // always fade out at the circle's start time (to match user expectations). ApproachCircle.FadeOut(50); } @@ -182,6 +186,10 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables // todo: temporary / arbitrary, used for lifetime optimisation. this.Delay(800).FadeOut(); + // in the case of an early state change, the fade should be expedited to the current point in time. + if (HitStateUpdateTime < HitObject.StartTime) + ApproachCircle.FadeOut(50); + switch (state) { case ArmedState.Idle: diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index b7458b5695..4a2a18ffd6 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -152,7 +152,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables while (Math.Abs(aimRotation - Arrow.Rotation) > 180) aimRotation += aimRotation < Arrow.Rotation ? 360 : -360; - if (!hasRotation) + // The clock may be paused in a scenario like the editor. + if (!hasRotation || !Clock.IsRunning) { Arrow.Rotation = aimRotation; hasRotation = true; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs index 19cee61f26..ec87d3bfdf 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs @@ -29,6 +29,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public new OsuSpinnerJudgementResult Result => (OsuSpinnerJudgementResult)base.Result; + public SkinnableDrawable Body { get; private set; } + public SpinnerRotationTracker RotationTracker { get; private set; } private SpinnerSpmCalculator spmCalculator; @@ -86,7 +88,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables RelativeSizeAxes = Axes.Y, Children = new Drawable[] { - new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinner()), + Body = new SkinnableDrawable(new OsuSkinComponent(OsuSkinComponents.SpinnerBody), _ => new DefaultSpinner()), RotationTracker = new SpinnerRotationTracker(this) } }, diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs index 02dc770285..c72080c9e5 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/SkinnableLighting.cs @@ -18,9 +18,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void SkinChanged(ISkinSource skin) { - base.SkinChanged(skin, allowFallback); + base.SkinChanged(skin); updateColour(); } diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 465d6d7155..5f37b0d040 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -164,7 +164,8 @@ namespace osu.Game.Rulesets.Osu { new OsuModTarget(), new OsuModDifficultyAdjust(), - new OsuModClassic() + new OsuModClassic(), + new OsuModRandom(), }; case ModType.Automation: @@ -186,6 +187,7 @@ namespace osu.Game.Rulesets.Osu new MultiMod(new ModWindUp(), new ModWindDown()), new OsuModTraceable(), new OsuModBarrelRoll(), + new OsuModApproachDifferent(), }; case ModType.System: @@ -217,7 +219,7 @@ namespace osu.Game.Rulesets.Osu public override RulesetSettingsSubsection CreateSettings() => new OsuSettingsSubsection(this); - public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new OsuLegacySkinTransformer(source); + public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new OsuLegacySkinTransformer(skin); public int LegacyID => 0; diff --git a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs index fcb544fa5b..46e501758b 100644 --- a/osu.Game.Rulesets.Osu/OsuSkinComponents.cs +++ b/osu.Game.Rulesets.Osu/OsuSkinComponents.cs @@ -10,7 +10,6 @@ namespace osu.Game.Rulesets.Osu Cursor, CursorTrail, SliderScorePoint, - ApproachCircle, ReverseArrow, HitCircleText, SliderHeadHitCircle, diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index 7b0cf651c8..b88bf9108b 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -233,35 +233,43 @@ namespace osu.Game.Rulesets.Osu.Replays // Wait until Auto could "see and react" to the next note. double waitTime = h.StartTime - Math.Max(0.0, h.TimePreempt - getReactionTime(h.StartTime - h.TimePreempt)); + bool hasWaited = false; if (waitTime > lastFrame.Time) { lastFrame = new OsuReplayFrame(waitTime, lastFrame.Position) { Actions = lastFrame.Actions }; + hasWaited = true; AddFrameToReplay(lastFrame); } - Vector2 lastPosition = lastFrame.Position; - double timeDifference = ApplyModsToTimeDelta(lastFrame.Time, h.StartTime); + OsuReplayFrame lastLastFrame = Frames.Count >= 2 ? (OsuReplayFrame)Frames[^2] : null; - // Only "snap" to hitcircles if they are far enough apart. As the time between hitcircles gets shorter the snapping threshold goes up. - if (timeDifference > 0 && // Sanity checks - ((lastPosition - targetPos).Length > h.Radius * (1.5 + 100.0 / timeDifference) || // Either the distance is big enough - timeDifference >= 266)) // ... or the beats are slow enough to tap anyway. + if (timeDifference > 0) { - // Perform eased movement + // If the last frame is a key-up frame and there has been no wait period, adjust the last frame's position such that it begins eased movement instantaneously. + if (lastLastFrame != null && lastFrame is OsuKeyUpReplayFrame && !hasWaited) + { + // [lastLastFrame] ... [lastFrame] ... [current frame] + // We want to find the cursor position at lastFrame, so interpolate between lastLastFrame and the new target position. + lastFrame.Position = Interpolation.ValueAt(lastFrame.Time, lastFrame.Position, targetPos, lastLastFrame.Time, h.StartTime, easing); + } + + Vector2 lastPosition = lastFrame.Position; + + // Perform the rest of the eased movement until the target position is reached. for (double time = lastFrame.Time + GetFrameDelay(lastFrame.Time); time < h.StartTime; time += GetFrameDelay(time)) { Vector2 currentPosition = Interpolation.ValueAt(time, lastPosition, targetPos, lastFrame.Time, h.StartTime, easing); AddFrameToReplay(new OsuReplayFrame((int)time, new Vector2(currentPosition.X, currentPosition.Y)) { Actions = lastFrame.Actions }); } + } - buttonIndex = 0; - } - else - { + // Start alternating once the time separation is too small (faster than ~225BPM). + if (timeDifference > 0 && timeDifference < 266) buttonIndex++; - } + else + buttonIndex = 0; } /// @@ -284,7 +292,7 @@ namespace osu.Game.Rulesets.Osu.Replays // TODO: Why do we delay 1 ms if the object is a spinner? There already is KEY_UP_DELAY from hEndTime. 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)); + var endFrame = new OsuKeyUpReplayFrame(hEndTime + endDelay, new Vector2(h.StackedEndPosition.X, h.StackedEndPosition.Y)); // Decrement because we want the previous frame, not the next one int index = FindInsertionIndex(startFrame) - 1; @@ -381,5 +389,13 @@ namespace osu.Game.Rulesets.Osu.Replays } #endregion + + private class OsuKeyUpReplayFrame : OsuReplayFrame + { + public OsuKeyUpReplayFrame(double time, Vector2 position) + : base(time, position) + { + } + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs index ba41ebd445..cb68d4b7a7 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/CirclePiece.cs @@ -42,6 +42,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default Origin = Anchor.Centre, Texture = textures.Get(@"Gameplay/osu/disc"), }, + new KiaiFlash + { + RelativeSizeAxes = Axes.Both, + }, triangles = new TrianglesPiece { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs index ae8c03dad1..df33bf52be 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinner.cs @@ -128,5 +128,13 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default spmContainer.FadeIn(drawableSpinner.HitObject.TimeFadeIn); } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableSpinner != null) + drawableSpinner.ApplyCustomUpdateState -= updateStateTransforms; + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs index 542f3eff0d..4ea0831627 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultSpinnerDisc.cs @@ -130,18 +130,18 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default Spinner spinner = drawableSpinner.HitObject; - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) { this.ScaleTo(initial_scale); this.RotateTo(0); - using (BeginDelayedSequence(spinner.TimePreempt / 2, true)) + using (BeginDelayedSequence(spinner.TimePreempt / 2)) { // 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)) + using (BeginDelayedSequence(spinner.TimePreempt + spinner.Duration + drawableHitObject.Result.TimeOffset)) { switch (state) { @@ -157,17 +157,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default } } - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) { centre.ScaleTo(0); mainContainer.ScaleTo(0); - using (BeginDelayedSequence(spinner.TimePreempt / 2, true)) + using (BeginDelayedSequence(spinner.TimePreempt / 2)) { centre.ScaleTo(0.3f, spinner.TimePreempt / 4, Easing.OutQuint); mainContainer.ScaleTo(0.2f, spinner.TimePreempt / 4, Easing.OutQuint); - using (BeginDelayedSequence(spinner.TimePreempt / 2, true)) + using (BeginDelayedSequence(spinner.TimePreempt / 2)) { centre.ScaleTo(0.5f, spinner.TimePreempt / 2, Easing.OutQuint); mainContainer.ScaleTo(1, spinner.TimePreempt / 2, Easing.OutQuint); @@ -176,7 +176,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default } // transforms we have from completing the spinner will be rolled back, so reapply immediately. - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) updateComplete(state == ArmedState.Hit, 0); } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs b/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.cs new file mode 100644 index 0000000000..d49b1713f6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/KiaiFlash.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.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public class KiaiFlash : BeatSyncedContainer + { + private const double fade_length = 80; + + private const float flash_opacity = 0.25f; + + public KiaiFlash() + { + EarlyActivationMilliseconds = 80; + Blending = BlendingParameters.Additive; + + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.White, + Alpha = 0f, + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!effectPoint.KiaiMode) + return; + + Child + .FadeTo(flash_opacity, EarlyActivationMilliseconds, Easing.OutQuint) + .Then() + .FadeOut(timingPoint.BeatLength - fade_length, Easing.OutSine); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs index b52dc749f0..d7ebe9333d 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/MainCirclePiece.cs @@ -42,7 +42,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default private readonly IBindable accentColour = new Bindable(); private readonly IBindable indexInCurrentCombo = new Bindable(); - private readonly IBindable armedState = new Bindable(); [Resolved] private DrawableHitObject drawableObject { get; set; } @@ -54,7 +53,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default accentColour.BindTo(drawableObject.AccentColour); indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); - armedState.BindTo(drawableObject.State); } protected override void LoadComplete() @@ -70,19 +68,18 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default indexInCurrentCombo.BindValueChanged(index => number.Text = (index.NewValue + 1).ToString(), true); - armedState.BindValueChanged(animate, true); + drawableObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(drawableObject, drawableObject.State.Value); } - private void animate(ValueChangedEvent state) + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) { - ClearTransforms(true); - using (BeginAbsoluteSequence(drawableObject.StateUpdateTime)) glow.FadeOut(400); using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) { - switch (state.NewValue) + switch (state) { case ArmedState.Hit: const double flash_in = 40; @@ -109,5 +106,13 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default } } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject != null) + drawableObject.ApplyCustomUpdateState -= updateStateTransforms; + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs index 8feeca56e8..8943a91076 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SliderBall.cs @@ -86,6 +86,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default public override void ApplyTransformsAt(double time, bool propagateChildren = false) { // For the same reasons as above w.r.t rewinding, we shouldn't propagate to children here either. + // ReSharper disable once RedundantArgumentDefaultValue - removing the "redundant" default value triggers BaseMethodCallWithDefaultParameter base.ApplyTransformsAt(time, false); } diff --git a/osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.cs b/osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.cs new file mode 100644 index 0000000000..7fbc5b144b --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/IHasApproachCircle.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; + +namespace osu.Game.Rulesets.Osu.Skinning +{ + /// + /// A common interface between implementations which provide an approach circle. + /// + public interface IHasApproachCircle + { + /// + /// The approach circle drawable. + /// + Drawable ApproachCircle { get; } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingSprite.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingSprite.cs new file mode 100644 index 0000000000..4a1d69ad41 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/KiaiFlashingSprite.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.Audio.Track; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Rulesets.Osu.Skinning.Legacy +{ + internal class KiaiFlashingSprite : BeatSyncedContainer + { + private readonly Sprite mainSprite; + private readonly Sprite flashingSprite; + + public Texture Texture + { + set + { + mainSprite.Texture = value; + flashingSprite.Texture = value; + } + } + + private const float flash_opacity = 0.3f; + + public KiaiFlashingSprite() + { + AutoSizeAxes = Axes.Both; + + Children = new Drawable[] + { + mainSprite = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + flashingSprite = new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Alpha = 0, + Blending = BlendingParameters.Additive, + } + }; + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + if (!effectPoint.KiaiMode) + return; + + flashingSprite + .FadeTo(flash_opacity) + .Then() + .FadeOut(timingPoint.BeatLength * 0.75f); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs index 7a8555d991..b2ffc171be 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs @@ -11,10 +11,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public class LegacyCursor : OsuCursorSprite { + private readonly ISkin skin; private bool spin; - public LegacyCursor() + public LegacyCursor(ISkin skin) { + this.skin = skin; Size = new Vector2(50); Anchor = Anchor.Centre; @@ -22,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy } [BackgroundDependencyLoader] - private void load(ISkinSource skin) + private void load() { bool centre = skin.GetConfig(OsuSkinConfiguration.CursorCentre)?.Value ?? true; spin = skin.GetConfig(OsuSkinConfiguration.CursorRotate)?.Value ?? true; diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs index 0025576325..f6fd3e36ab 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs @@ -14,14 +14,20 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public class LegacyCursorTrail : CursorTrail { + private readonly ISkin skin; private const double disjoint_trail_time_separation = 1000 / 60.0; private bool disjointTrail; private double lastTrailTime; private IBindable cursorSize; + public LegacyCursorTrail(ISkin skin) + { + this.skin = skin; + } + [BackgroundDependencyLoader] - private void load(ISkinSource skin, OsuConfigManager config) + private void load(OsuConfigManager config) { Texture = skin.GetTexture("cursortrail"); disjointTrail = skin.GetTexture("cursormiddle") == null; diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs index ffbeea5e0e..7a210324d7 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyMainCirclePiece.cs @@ -5,7 +5,6 @@ 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; @@ -21,6 +20,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public class LegacyMainCirclePiece : CompositeDrawable { + public override bool RemoveCompletedTransforms => false; + private readonly string priorityLookup; private readonly bool hasNumber; @@ -32,15 +33,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); } - private Container circleSprites; - private Sprite hitCircleSprite; - private Sprite hitCircleOverlay; + private Container circleSprites; + private Drawable hitCircleSprite; + private Drawable hitCircleOverlay; private SkinnableSpriteText hitCircleText; private readonly Bindable accentColour = new Bindable(); private readonly IBindable indexInCurrentCombo = new Bindable(); - private readonly IBindable armedState = new Bindable(); [Resolved] private DrawableHitObject drawableObject { get; set; } @@ -72,20 +72,20 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy InternalChildren = new Drawable[] { - circleSprites = new Container + circleSprites = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Children = new[] { - hitCircleSprite = new Sprite + hitCircleSprite = new KiaiFlashingSprite { Texture = baseTexture, Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - hitCircleOverlay = new Sprite + hitCircleOverlay = new KiaiFlashingSprite { Texture = overlayTexture, Anchor = Anchor.Centre, @@ -115,7 +115,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy accentColour.BindTo(drawableObject.AccentColour); indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); - armedState.BindTo(drawableObject.State); Texture getTextureWithFallback(string name) { @@ -141,18 +140,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy if (hasNumber) indexInCurrentCombo.BindValueChanged(index => hitCircleText.Text = (index.NewValue + 1).ToString(), true); - armedState.BindValueChanged(animate, true); + drawableObject.ApplyCustomUpdateState += updateStateTransforms; + updateStateTransforms(drawableObject, drawableObject.State.Value); } - private void animate(ValueChangedEvent state) + private void updateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) { const double legacy_fade_duration = 240; - ClearTransforms(true); - using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime)) { - switch (state.NewValue) + switch (state) { case ArmedState.Hit: circleSprites.FadeOut(legacy_fade_duration, Easing.Out); @@ -177,5 +175,13 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy } } } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject != null) + drawableObject.ApplyCustomUpdateState -= updateStateTransforms; + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs index 22fb3aab86..1e170036e4 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyNewStyleSpinner.cs @@ -55,28 +55,40 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-bottom") + Texture = source.GetTexture("spinner-bottom"), }, discTop = new Sprite { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-top") + Texture = source.GetTexture("spinner-top"), }, fixedMiddle = new Sprite { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-middle") + Texture = source.GetTexture("spinner-middle"), }, spinningMiddle = new Sprite { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Texture = source.GetTexture("spinner-middle2") - } + Texture = source.GetTexture("spinner-middle2"), + }, } }); + + if (!(source.FindProvider(s => s.GetTexture("spinner-top") != null) is DefaultLegacySkin)) + { + AddInternal(ApproachCircle = new Sprite + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-approachcircle"), + Scale = new Vector2(SPRITE_SCALE * 1.86f), + Y = SPINNER_Y_CENTRE, + }); + } } protected override void UpdateStateTransforms(DrawableHitObject drawableHitObject, ArmedState state) @@ -88,17 +100,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy case DrawableSpinner d: Spinner spinner = d.HitObject; - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) this.FadeOut(); - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn / 2, true)) + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn / 2)) this.FadeInFromZero(spinner.TimeFadeIn / 2); - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) { fixedMiddle.FadeColour(Color4.White); - using (BeginDelayedSequence(spinner.TimePreempt, true)) + using (BeginDelayedSequence(spinner.TimePreempt)) fixedMiddle.FadeColour(Color4.Red, spinner.Duration); } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs index 19cb55c16e..e3e8f3ce88 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyOldStyleSpinner.cs @@ -12,6 +12,7 @@ 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 { @@ -33,13 +34,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { spinnerBlink = source.GetConfig(OsuSkinConfiguration.SpinnerNoBlink)?.Value != true; - AddRangeInternal(new Drawable[] + AddRangeInternal(new[] { new Sprite { Anchor = Anchor.TopCentre, Origin = Anchor.Centre, Texture = source.GetTexture("spinner-background"), + Colour = source.GetConfig(OsuSkinColour.SpinnerBackground)?.Value ?? new Color4(100, 100, 100, 255), Scale = new Vector2(SPRITE_SCALE), Y = SPINNER_Y_CENTRE, }, @@ -66,6 +68,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Origin = Anchor.TopLeft, Scale = new Vector2(SPRITE_SCALE) } + }, + ApproachCircle = new Sprite + { + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + Texture = source.GetTexture("spinner-approachcircle"), + Scale = new Vector2(SPRITE_SCALE * 1.86f), + Y = SPINNER_Y_CENTRE, } }); } @@ -79,10 +89,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy Spinner spinner = d.HitObject; - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt, true)) + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimePreempt)) this.FadeOut(); - using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn / 2, true)) + using (BeginAbsoluteSequence(spinner.StartTime - spinner.TimeFadeIn / 2)) this.FadeInFromZero(spinner.TimeFadeIn / 2); } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs index 1a8c5ada1b..e4e1483665 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySliderBall.cs @@ -14,18 +14,21 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { private readonly Drawable animationContent; + private readonly ISkin skin; + private Sprite layerNd; private Sprite layerSpec; - public LegacySliderBall(Drawable animationContent) + public LegacySliderBall(Drawable animationContent, ISkin skin) { this.animationContent = animationContent; + this.skin = skin; AutoSizeAxes = Axes.Both; } [BackgroundDependencyLoader] - private void load(ISkinSource skin) + private void load() { var ballColour = skin.GetConfig(OsuSkinColour.SliderBall)?.Value ?? Color4.White; diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs index 959589620b..93aba608e6 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacySpinner.cs @@ -15,23 +15,25 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Skinning.Legacy { - public abstract class LegacySpinner : CompositeDrawable + public abstract class LegacySpinner : CompositeDrawable, IHasApproachCircle { + public const float SPRITE_SCALE = 0.625f; + /// /// All constants are in osu!stable's gamefield space, which is shifted 16px downwards. - /// This offset is negated in both osu!stable and osu!lazer to bring all constants into window-space. + /// This offset is negated to bring all constants into window-space. /// Note: SPINNER_Y_CENTRE + SPINNER_TOP_OFFSET - Position.Y = 240 (=480/2, or half the window-space in osu!stable) /// protected const float SPINNER_TOP_OFFSET = 45f - 16f; protected const float SPINNER_Y_CENTRE = SPINNER_TOP_OFFSET + 219f; - protected const float SPRITE_SCALE = 0.625f; - private const float spm_hide_offset = 50f; protected DrawableSpinner DrawableSpinner { get; private set; } + public Drawable ApproachCircle { get; protected set; } + private Sprite spin; private Sprite clear; @@ -138,7 +140,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { double startTime = Math.Min(Time.Current, DrawableSpinner.HitStateUpdateTime - 400); - using (BeginAbsoluteSequence(startTime, true)) + using (BeginAbsoluteSequence(startTime)) { clear.FadeInFromZero(400, Easing.Out); @@ -148,7 +150,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy } const double fade_out_duration = 50; - using (BeginAbsoluteSequence(DrawableSpinner.HitStateUpdateTime - fade_out_duration, true)) + using (BeginAbsoluteSequence(DrawableSpinner.HitStateUpdateTime - fade_out_duration)) clear.FadeOut(fade_out_duration); } else @@ -175,16 +177,19 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy spmCounter.MoveToOffset(new Vector2(0, -spm_hide_offset), d.HitObject.TimeFadeIn, Easing.Out); } + using (BeginAbsoluteSequence(d.HitObject.StartTime)) + ApproachCircle?.ScaleTo(SPRITE_SCALE * 0.1f, d.HitObject.Duration); + double spinFadeOutLength = Math.Min(400, d.HitObject.Duration); - using (BeginAbsoluteSequence(drawableHitObject.HitStateUpdateTime - spinFadeOutLength, true)) + using (BeginAbsoluteSequence(drawableHitObject.HitStateUpdateTime - spinFadeOutLength)) spin.FadeOutFromOne(spinFadeOutLength); break; case DrawableSpinnerTick d: if (state == ArmedState.Hit) { - using (BeginAbsoluteSequence(d.HitStateUpdateTime, true)) + using (BeginAbsoluteSequence(d.HitStateUpdateTime)) spin.FadeOut(300); } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index ffd4f78400..41b0a88f11 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public class OsuLegacySkinTransformer : LegacySkinTransformer { - private Lazy hasHitCircle; + private readonly Lazy hasHitCircle; /// /// On osu-stable, hitcircles have 5 pixels of transparent padding on each side to allow for shadows etc. @@ -20,16 +20,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy /// public const float LEGACY_CIRCLE_RADIUS = 64 - 5; - public OsuLegacySkinTransformer(ISkinSource source) - : base(source) + public OsuLegacySkinTransformer(ISkin skin) + : base(skin) { - Source.SourceChanged += sourceChanged; - sourceChanged(); - } - - private void sourceChanged() - { - hasHitCircle = new Lazy(() => Source.GetTexture("hitcircle") != null); + hasHitCircle = new Lazy(() => GetTexture("hitcircle") != null); } public override Drawable GetDrawableComponent(ISkinComponent component) @@ -55,7 +49,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy // Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME); if (sliderBallContent != null) - return new LegacySliderBall(sliderBallContent); + return new LegacySliderBall(sliderBallContent, this); return null; @@ -84,14 +78,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy return null; case OsuSkinComponents.Cursor: - if (Source.GetTexture("cursor") != null) - return new LegacyCursor(); + if (GetTexture("cursor") != null) + return new LegacyCursor(this); return null; case OsuSkinComponents.CursorTrail: - if (Source.GetTexture("cursortrail") != null) - return new LegacyCursorTrail(); + if (GetTexture("cursortrail") != null) + return new LegacyCursorTrail(this); return null; @@ -106,9 +100,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy }; case OsuSkinComponents.SpinnerBody: - bool hasBackground = Source.GetTexture("spinner-background") != null; + bool hasBackground = GetTexture("spinner-background") != null; - if (Source.GetTexture("spinner-top") != null && !hasBackground) + if (GetTexture("spinner-top") != null && !hasBackground) return new LegacyNewStyleSpinner(); else if (hasBackground) return new LegacyOldStyleSpinner(); @@ -117,7 +111,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy } } - return Source.GetDrawableComponent(component); + return base.GetDrawableComponent(component); } public override IBindable GetConfig(TLookup lookup) @@ -125,7 +119,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy switch (lookup) { case OsuSkinColour colour: - return Source.GetConfig(new SkinCustomColourLookup(colour)); + return base.GetConfig(new SkinCustomColourLookup(colour)); case OsuSkinConfiguration osuLookup: switch (osuLookup) @@ -139,14 +133,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy 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); + return base.GetConfig(OsuSkinConfiguration.HitCircleOverlayAboveNumber) ?? + base.GetConfig(OsuSkinConfiguration.HitCircleOverlayAboveNumer); } break; } - return Source.GetConfig(lookup); + return base.GetConfig(lookup); } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.cs index 4e6d3ef0e4..f7ba8b9fc4 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinColour.cs @@ -7,6 +7,7 @@ namespace osu.Game.Rulesets.Osu.Skinning { SliderTrackOverride, SliderBorder, - SliderBall + SliderBall, + SpinnerBackground, } } diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs index eea45c6c80..0e7d7cdcf3 100644 --- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs +++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs @@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor Size = new Vector2(size); } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void SkinChanged(ISkinSource skin) { cursorExpand = skin.GetConfig(OsuSkinConfiguration.CursorExpand)?.Value ?? true; } diff --git a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs index 705ba3e929..a4c0381d16 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.UI; @@ -11,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.UI { public class OsuSettingsSubsection : RulesetSettingsSubsection { - protected override string Header => "osu!"; + protected override LocalisableString Header => "osu!"; public OsuSettingsSubsection(Ruleset ruleset) : base(ruleset) diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs new file mode 100644 index 0000000000..06b964a647 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.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; +using osu.Game.Rulesets.Osu.UI; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Utils +{ + public static class OsuHitObjectGenerationUtils + { + // The relative distance to the edge of the playfield before objects' positions should start to "turn around" and curve towards the middle. + // The closer the hit objects draw to the border, the sharper the turn + private const float playfield_edge_ratio = 0.375f; + + private static readonly float border_distance_x = OsuPlayfield.BASE_SIZE.X * playfield_edge_ratio; + private static readonly float border_distance_y = OsuPlayfield.BASE_SIZE.Y * playfield_edge_ratio; + + private static readonly Vector2 playfield_middle = OsuPlayfield.BASE_SIZE / 2; + + /// + /// Rotate a hit object away from the playfield edge, while keeping a constant distance + /// from the previous object. + /// + /// + /// The extent of rotation depends on the position of the hit object. Hit objects + /// closer to the playfield edge will be rotated to a larger extent. + /// + /// Position of the previous hit object. + /// Position of the hit object to be rotated, relative to the previous hit object. + /// + /// The extent of rotation. + /// 0 means the hit object is never rotated. + /// 1 means the hit object will be fully rotated towards playfield center when it is originally at playfield edge. + /// + /// The new position of the hit object, relative to the previous one. + public static Vector2 RotateAwayFromEdge(Vector2 prevObjectPos, Vector2 posRelativeToPrev, float rotationRatio = 0.5f) + { + var relativeRotationDistance = 0f; + + if (prevObjectPos.X < playfield_middle.X) + { + relativeRotationDistance = Math.Max( + (border_distance_x - prevObjectPos.X) / border_distance_x, + relativeRotationDistance + ); + } + else + { + relativeRotationDistance = Math.Max( + (prevObjectPos.X - (OsuPlayfield.BASE_SIZE.X - border_distance_x)) / border_distance_x, + relativeRotationDistance + ); + } + + if (prevObjectPos.Y < playfield_middle.Y) + { + relativeRotationDistance = Math.Max( + (border_distance_y - prevObjectPos.Y) / border_distance_y, + relativeRotationDistance + ); + } + else + { + relativeRotationDistance = Math.Max( + (prevObjectPos.Y - (OsuPlayfield.BASE_SIZE.Y - border_distance_y)) / border_distance_y, + relativeRotationDistance + ); + } + + return RotateVectorTowardsVector( + posRelativeToPrev, + playfield_middle - prevObjectPos, + Math.Min(1, relativeRotationDistance * rotationRatio) + ); + } + + /// + /// Rotates vector "initial" towards vector "destination". + /// + /// The vector to be rotated. + /// The vector that "initial" should be rotated towards. + /// How much "initial" should be rotated. 0 means no rotation. 1 means "initial" is fully rotated to equal "destination". + /// The rotated vector. + public static Vector2 RotateVectorTowardsVector(Vector2 initial, Vector2 destination, float rotationRatio) + { + var initialAngleRad = MathF.Atan2(initial.Y, initial.X); + var destAngleRad = MathF.Atan2(destination.Y, destination.X); + + var diff = destAngleRad - initialAngleRad; + + while (diff < -MathF.PI) diff += 2 * MathF.PI; + + while (diff > MathF.PI) diff -= 2 * MathF.PI; + + var finalAngleRad = initialAngleRad + rotationRatio * diff; + + return new Vector2( + initial.Length * MathF.Cos(finalAngleRad), + initial.Length * MathF.Sin(finalAngleRad) + ); + } + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.cs new file mode 100644 index 0000000000..3090facf8c --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TaikoModTestScene.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.Mods +{ + public abstract class TaikoModTestScene : ModTestScene + { + protected sealed override Ruleset CreatePlayerRuleset() => new TaikoRuleset(); + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.cs new file mode 100644 index 0000000000..7abbb9d186 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/Mods/TestSceneTaikoModHidden.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.Linq; +using NUnit.Framework; +using osu.Game.Rulesets.Taiko.Mods; + +namespace osu.Game.Rulesets.Taiko.Tests.Mods +{ + public class TestSceneTaikoModHidden : TaikoModTestScene + { + [Test] + public void TestDefaultBeatmapTest() => CreateModTest(new ModTestData + { + Mod = new TaikoModHidden(), + Autoplay = true, + PassCondition = checkSomeAutoplayHits + }); + + private bool checkSomeAutoplayHits() + => Player.ScoreProcessor.JudgedHits >= 4 + && Player.Results.All(result => result.Type == result.Judgement.MaxResult); + } +} 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 8fb167ba10..532fdc5cb0 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 @@ -4,7 +4,7 @@ - + diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index 5bed48bcc6..36adbd5a5b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -7,10 +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 double StaminaStrain { get; set; } + public double RhythmStrain { get; set; } + public double ColourStrain { get; set; } + public double ApproachRate { get; set; } + public double GreatHitWindow { get; set; } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 6b3e31c5d5..18d06c069f 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -29,7 +29,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] { new Colour(mods), new Rhythm(mods), diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 2d9b95ae88..90dd733dfd 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -31,14 +30,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty public override double Calculate(Dictionary categoryDifficulty = null) { mods = Score.Mods; - 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; + countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); + countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); // Custom multipliers for NoFail and SpunOut. double multiplier = 1.1; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs index 64e59b64d0..31d9abf8b2 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModAutoplay.cs @@ -4,14 +4,13 @@ using System.Collections.Generic; 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 TaikoModAutoplay : ModAutoplay + public class TaikoModAutoplay : ModAutoplay { public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods) => new Score { diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs index 4006652bd5..9540e35780 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs @@ -2,7 +2,6 @@ // 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; @@ -11,14 +10,13 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModDifficultyAdjust : ModDifficultyAdjust { - [SettingSource("Scroll Speed", "Adjust a beatmap's set scroll speed", LAST_SETTING_ORDER + 1)] - public BindableNumber ScrollSpeed { get; } = new BindableFloat + [SettingSource("Scroll Speed", "Adjust a beatmap's set scroll speed", LAST_SETTING_ORDER + 1, SettingControlType = typeof(DifficultyAdjustSettingsControl))] + public DifficultyBindable ScrollSpeed { get; } = new DifficultyBindable { Precision = 0.05f, MinValue = 0.25f, MaxValue = 4, - Default = 1, - Value = 1, + ReadCurrentFromDifficulty = _ => 1, }; public override string SettingDescription @@ -39,7 +37,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { base.ApplySettings(difficulty); - ApplySetting(ScrollSpeed, scroll => difficulty.SliderMultiplier *= scroll); + if (ScrollSpeed.Value != null) difficulty.SliderMultiplier *= ScrollSpeed.Value.Value; } } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs index a5a8b75f80..8437dfe52e 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs @@ -9,7 +9,6 @@ namespace osu.Game.Rulesets.Taiko.Mods public class TaikoModHardRock : ModHardRock { public override double ScoreMultiplier => 1.06; - public override bool Ranked => true; /// /// Multiplier factor added to the scrolling speed. diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs index 7739ecaf5b..0fd3625a93 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs @@ -1,23 +1,93 @@ // Copyright (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.Beatmaps.ControlPoints; 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.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.Mods { - public class TaikoModHidden : ModHidden + public class TaikoModHidden : ModHidden, IApplicableToDifficulty { public override string Description => @"Beats fade out before you hit them!"; public override double ScoreMultiplier => 1.06; - public override bool HasImplementation => false; + + /// + /// In osu-stable, the hit position is 160, so the active playfield is essentially 160 pixels shorter + /// than the actual screen width. The normalized playfield height is 480, so on a 4:3 screen the + /// playfield ratio of the active area up to the hit position will actually be (640 - 160) / 480 = 1. + /// For custom resolutions/aspect ratios (x:y), the screen width given the normalized height becomes 480 * x / y instead, + /// and the playfield ratio becomes (480 * x / y - 160) / 480 = x / y - 1/3. + /// This constant is equal to the playfield ratio on 4:3 screens divided by the playfield ratio on 16:9 screens. + /// + private const double hd_sv_scale = (4.0 / 3.0 - 1.0 / 3.0) / (16.0 / 9.0 - 1.0 / 3.0); + + private double originalSliderMultiplier; + + private ControlPointInfo controlPointInfo; protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) { + ApplyNormalVisibilityState(hitObject, state); + } + + protected double MultiplierAt(double position) + { + double beatLength = controlPointInfo.TimingPointAt(position).BeatLength; + double speedMultiplier = controlPointInfo.DifficultyPointAt(position).SpeedMultiplier; + + return originalSliderMultiplier * speedMultiplier * TimingControlPoint.DEFAULT_BEAT_LENGTH / beatLength; } protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) { + switch (hitObject) + { + case DrawableDrumRollTick _: + case DrawableHit _: + double preempt = 10000 / MultiplierAt(hitObject.HitObject.StartTime); + double start = hitObject.HitObject.StartTime - preempt * 0.6; + double duration = preempt * 0.3; + + using (hitObject.BeginAbsoluteSequence(start)) + { + hitObject.FadeOut(duration); + + // DrawableHitObject sets LifetimeEnd to LatestTransformEndTime if it isn't manually changed. + // in order for the object to not be killed before its actual end time (as the latest transform ends earlier), set lifetime end explicitly. + hitObject.LifetimeEnd = state == ArmedState.Idle || !hitObject.AllJudged + ? hitObject.HitObject.GetEndTime() + hitObject.HitObject.HitWindows.WindowFor(HitResult.Miss) + : hitObject.HitStateUpdateTime; + } + + break; + } + } + + public void ReadFromDifficulty(BeatmapDifficulty difficulty) + { + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + // needs to be read after all processing has been run (TaikoBeatmapConverter applies an adjustment which would otherwise be omitted). + originalSliderMultiplier = difficulty.SliderMultiplier; + + // osu-stable has an added playfield cover that essentially forces a 4:3 playfield ratio, by cutting off all objects past that size. + // This is not yet implemented; instead a playfield adjustment container is present which maintains a 16:9 ratio. + // For now, increase the slider multiplier proportionally so that the notes stay on the screen for the same amount of time as on stable. + // Note that this means that the notes will scroll faster as they have a longer distance to travel on the screen in that same amount of time. + difficulty.SliderMultiplier /= hd_sv_scale; + } + + public override void ApplyToBeatmap(IBeatmap beatmap) + { + controlPointInfo = beatmap.ControlPointInfo; } } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs index a22f189d5e..307a37bf2e 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs @@ -20,10 +20,13 @@ namespace osu.Game.Rulesets.Taiko.Mods { var taikoBeatmap = (TaikoBeatmap)beatmap; + Seed.Value ??= RNG.Next(); + var rng = new Random((int)Seed.Value); + foreach (var obj in taikoBeatmap.HitObjects) { if (obj is Hit hit) - hit.Type = RNG.Next(2) == 0 ? HitType.Centre : HitType.Rim; + hit.Type = rng.Next(2) == 0 ? HitType.Centre : HitType.Rim; } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs index 60f9521996..888f47d341 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableSwell.cs @@ -227,7 +227,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { base.UpdateStartTimeStateTransforms(); - using (BeginDelayedSequence(-ring_appear_offset, true)) + using (BeginDelayedSequence(-ring_appear_offset)) targetRing.ScaleTo(target_ring_scale, 400, Easing.OutQuint); } diff --git a/osu.Game.Rulesets.Taiko/Objects/Hit.cs b/osu.Game.Rulesets.Taiko/Objects/Hit.cs index b4ed242893..2038da9344 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Hit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Hit.cs @@ -3,14 +3,19 @@ using System.Linq; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Game.Audio; +using osu.Game.Rulesets.Objects.Types; +using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.Objects { - public class Hit : TaikoStrongableHitObject + public class Hit : TaikoStrongableHitObject, IHasDisplayColour { public readonly Bindable TypeBindable = new Bindable(); + public Bindable DisplayColour { get; } = new Bindable(COLOUR_CENTRE); + /// /// The that actuates this . /// @@ -20,9 +25,17 @@ namespace osu.Game.Rulesets.Taiko.Objects set => TypeBindable.Value = value; } + public static readonly Color4 COLOUR_CENTRE = Color4Extensions.FromHex(@"bb1177"); + public static readonly Color4 COLOUR_RIM = Color4Extensions.FromHex(@"2299bb"); + public Hit() { - TypeBindable.BindValueChanged(_ => updateSamplesFromType()); + TypeBindable.BindValueChanged(_ => + { + updateSamplesFromType(); + DisplayColour.Value = Type == HitType.Centre ? COLOUR_CENTRE : COLOUR_RIM; + }); + SamplesBindable.BindCollectionChanged((_, __) => updateTypeFromSamples()); } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs index f65bb54726..455b2fc596 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/CentreHitCirclePiece.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; +using osu.Game.Rulesets.Taiko.Objects; using osuTK; namespace osu.Game.Rulesets.Taiko.Skinning.Default @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default [BackgroundDependencyLoader] private void load(OsuColour colours) { - AccentColour = colours.PinkDarker; + AccentColour = Hit.COLOUR_CENTRE; } /// diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs index ca2ab301be..bd21d511b1 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/RimHitCirclePiece.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; +using osu.Game.Rulesets.Taiko.Objects; using osuTK; using osuTK.Graphics; @@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default [BackgroundDependencyLoader] private void load(OsuColour colours) { - AccentColour = colours.BlueDarker; + AccentColour = Hit.COLOUR_RIM; } /// diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs index e0557c8617..a3ecbbc436 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/TaikoLegacySkinTransformer.cs @@ -4,7 +4,6 @@ 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; @@ -15,18 +14,12 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { public class TaikoLegacySkinTransformer : LegacySkinTransformer { - private Lazy hasExplosion; + private readonly Lazy hasExplosion; - public TaikoLegacySkinTransformer(ISkinSource source) - : base(source) + public TaikoLegacySkinTransformer(ISkin skin) + : base(skin) { - Source.SourceChanged += sourceChanged; - sourceChanged(); - } - - private void sourceChanged() - { - hasExplosion = new Lazy(() => Source.GetTexture(getHitName(TaikoSkinComponents.TaikoExplosionGreat)) != null); + hasExplosion = new Lazy(() => GetTexture(getHitName(TaikoSkinComponents.TaikoExplosionGreat)) != null); } public override Drawable GetDrawableComponent(ISkinComponent component) @@ -56,7 +49,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy case TaikoSkinComponents.CentreHit: case TaikoSkinComponents.RimHit: - if (GetTexture("taikohitcircle") != null) return new LegacyHit(taikoComponent.Component); @@ -91,7 +83,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy return null; case TaikoSkinComponents.TaikoExplosionMiss: - var missSprite = this.GetAnimation(getHitName(taikoComponent.Component), true, false); if (missSprite != null) return new LegacyHitExplosion(missSprite); @@ -100,7 +91,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy case TaikoSkinComponents.TaikoExplosionOk: case TaikoSkinComponents.TaikoExplosionGreat: - var hitName = getHitName(taikoComponent.Component); var hitSprite = this.GetAnimation(hitName, true, false); @@ -132,7 +122,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy } } - return Source.GetDrawableComponent(component); + return base.GetDrawableComponent(component); } private string getHitName(TaikoSkinComponents component) @@ -152,32 +142,33 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy throw new ArgumentOutOfRangeException(nameof(component), $"Invalid component type: {component}"); } - public override ISample GetSample(ISampleInfo sampleInfo) => Source.GetSample(new LegacyTaikoSampleInfo(sampleInfo)); - - public override IBindable GetConfig(TLookup lookup) => Source.GetConfig(lookup); - - private class LegacyTaikoSampleInfo : ISampleInfo + public override ISample GetSample(ISampleInfo sampleInfo) { - private readonly ISampleInfo source; + if (sampleInfo is HitSampleInfo hitSampleInfo) + return base.GetSample(new LegacyTaikoSampleInfo(hitSampleInfo)); + + return base.GetSample(sampleInfo); + } + + private class LegacyTaikoSampleInfo : HitSampleInfo + { + public LegacyTaikoSampleInfo(HitSampleInfo sampleInfo) + : base(sampleInfo.Name, sampleInfo.Bank, sampleInfo.Suffix, sampleInfo.Volume) - public LegacyTaikoSampleInfo(ISampleInfo source) { - this.source = source; } - public IEnumerable LookupNames + public override IEnumerable LookupNames { get { - foreach (var name in source.LookupNames) + foreach (var name in base.LookupNames) yield return name.Insert(name.LastIndexOf('/') + 1, "taiko-"); - foreach (var name in source.LookupNames) + foreach (var name in base.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 5854d4770c..ab5fcf6336 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new TaikoBeatmapConverter(beatmap, this); - public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new TaikoLegacySkinTransformer(source); + public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new TaikoLegacySkinTransformer(skin); public const string SHORT_NAME = "taiko"; diff --git a/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs b/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs index 9bfb6aa839..263454c78a 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrumRollHitContainer.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 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; @@ -11,6 +8,11 @@ namespace osu.Game.Rulesets.Taiko.UI { internal class DrumRollHitContainer : ScrollingHitObjectContainer { + // TODO: this usage is buggy. + // Because `LifetimeStart` is set based on scrolling, lifetime is not same as the time when the object is created. + // If the `Update` override is removed, it breaks in an obscure way. + protected override bool RemoveRewoundEntry => true; + protected override void Update() { base.Update(); @@ -23,14 +25,5 @@ namespace osu.Game.Rulesets.Taiko.UI 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/TaikoMascotAnimation.cs b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs index 9c76aea54c..3706acbe23 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs @@ -85,8 +85,12 @@ namespace osu.Game.Rulesets.Taiko.UI } [BackgroundDependencyLoader] - private void load(ISkinSource skin) + private void load(ISkinSource source) { + ISkin skin = source.FindProvider(s => getAnimationFrame(s, state, 0) != null); + + if (skin == null) return; + for (int frameIndex = 0; true; frameIndex++) { var texture = getAnimationFrame(skin, state, frameIndex); @@ -112,8 +116,12 @@ namespace osu.Game.Rulesets.Taiko.UI } [BackgroundDependencyLoader] - private void load(ISkinSource skin) + private void load(ISkinSource source) { + ISkin skin = source.FindProvider(s => getAnimationFrame(s, TaikoMascotAnimationState.Clear, 0) != null); + + if (skin == null) return; + foreach (var frameIndex in clear_animation_sequence) { var texture = getAnimationFrame(skin, TaikoMascotAnimationState.Clear, frameIndex); diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs index 0d117f8755..5dc25d6643 100644 --- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs @@ -19,7 +19,9 @@ using osu.Game.Database; using osu.Game.IO; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Scoring; using osu.Game.Tests.Resources; +using osu.Game.Tests.Scores.IO; using osu.Game.Users; using SharpCompress.Archives; using SharpCompress.Archives.Zip; @@ -185,13 +187,62 @@ namespace osu.Game.Tests.Beatmaps.IO } } - private string hashFile(string filename) + [Test] + public async Task TestImportThenImportWithChangedHashedFile() { - using (var s = File.OpenRead(filename)) - return s.ComputeMD5Hash(); + 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); + + await createScoreForBeatmap(osu, imported.Beatmaps.First()); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // arbitrary write to hashed file + // this triggers the special BeatmapManager.PreImport deletion/replacement flow. + using (var sw = new FileInfo(Directory.GetFiles(extractedFolder, "*.osu").First()).AppendText()) + await sw.WriteLineAsync("// changed"); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await osu.Dependencies.Get().Import(new ImportTask(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] + [Ignore("intentionally broken by import optimisations")] public async Task TestImportThenImportWithChangedFile() { using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(ImportBeatmapTest))) @@ -294,6 +345,7 @@ namespace osu.Game.Tests.Beatmaps.IO } [Test] + [Ignore("intentionally broken by import optimisations")] public async Task TestImportCorruptThenImport() { // unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. @@ -439,12 +491,11 @@ namespace osu.Game.Tests.Beatmaps.IO } } - [TestCase(true)] - [TestCase(false)] - public async Task TestImportThenDeleteThenImportWithOnlineIDMismatch(bool set) + [Test] + public async Task TestImportThenDeleteThenImportWithOnlineIDsMissing() { // 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}")) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(ImportBeatmapTest)}")) { try { @@ -452,10 +503,8 @@ namespace osu.Game.Tests.Beatmaps.IO var imported = await LoadOszIntoOsu(osu); - if (set) - imported.OnlineBeatmapSetID = 1234; - else - imported.Beatmaps.First().OnlineBeatmapID = 1234; + foreach (var b in imported.Beatmaps) + b.OnlineBeatmapID = null; osu.Dependencies.Get().Update(imported); @@ -895,7 +944,17 @@ namespace osu.Game.Tests.Beatmaps.IO Assert.IsTrue(manager.QueryBeatmapSets(_ => true).First().DeletePending); } - private void checkBeatmapSetCount(OsuGameBase osu, int expected, bool includeDeletePending = false) + private static Task createScoreForBeatmap(OsuGameBase osu, BeatmapInfo beatmap) + { + return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo + { + OnlineScoreID = 2, + Beatmap = beatmap, + BeatmapInfoID = beatmap.ID + }, new ImportScoreTest.TestArchiveReader()); + } + + private static void checkBeatmapSetCount(OsuGameBase osu, int expected, bool includeDeletePending = false) { var manager = osu.Dependencies.Get(); @@ -904,12 +963,18 @@ namespace osu.Game.Tests.Beatmaps.IO : manager.GetAllUsableBeatmapSets().Count); } - private void checkBeatmapCount(OsuGameBase osu, int expected) + private static string hashFile(string filename) + { + using (var s = File.OpenRead(filename)) + return s.ComputeMD5Hash(); + } + + private static void checkBeatmapCount(OsuGameBase osu, int expected) { Assert.AreEqual(expected, osu.Dependencies.Get().QueryBeatmaps(_ => true).ToList().Count); } - private void checkSingleReferencedFileCount(OsuGameBase osu, int expected) + private static void checkSingleReferencedFileCount(OsuGameBase osu, int expected) { Assert.AreEqual(expected, osu.Dependencies.Get().QueryFiles(f => f.ReferenceCount == 1).Count()); } diff --git a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs index 6c8133660f..9fba0f1668 100644 --- a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs +++ b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestSingleSpan() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, null, default).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, null).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestRepeat() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2, null, default).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2, null).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestNonEvenTicks() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2, null, default).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2, null).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -85,7 +85,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestLegacyLastTickOffset() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, 100, default).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, 100).ToArray(); Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LegacyLastTick)); Assert.That(events[2].Time, Is.EqualTo(900)); @@ -97,7 +97,7 @@ namespace osu.Game.Tests.Beatmaps 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(); + var events = SliderEventGenerator.Generate(start_time, span_duration, velocity, velocity, span_duration, 2, 0).ToArray(); Assert.Multiple(() => { diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index ecb37706b0..2c2c4dc24e 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -28,6 +28,8 @@ namespace osu.Game.Tests.Chat [TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123")] [TestCase(LinkAction.OpenBeatmapSet, "123", "https://dev.ppy.sh/beatmapsets/123/whatever")] [TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/abc", "https://dev.ppy.sh/beatmapsets/abc")] + [TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/discussions", "https://dev.ppy.sh/beatmapsets/discussions")] + [TestCase(LinkAction.External, "https://dev.ppy.sh/beatmapsets/discussions/123", "https://dev.ppy.sh/beatmapsets/discussions/123")] public void TestBeatmapLinks(LinkAction expectedAction, string expectedArg, string link) { MessageFormatter.WebsiteRootUrl = "dev.ppy.sh"; diff --git a/osu.Game.Tests/Chat/TestSceneChannelManager.cs b/osu.Game.Tests/Chat/TestSceneChannelManager.cs new file mode 100644 index 0000000000..0ec21a4c7b --- /dev/null +++ b/osu.Game.Tests/Chat/TestSceneChannelManager.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.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Chat; +using osu.Game.Tests.Visual; +using osu.Game.Users; + +namespace osu.Game.Tests.Chat +{ + [HeadlessTest] + public class TestSceneChannelManager : OsuTestScene + { + private ChannelManager channelManager; + private int currentMessageId; + + [SetUp] + public void Setup() => Schedule(() => + { + var container = new ChannelManagerContainer(); + Child = container; + channelManager = container.ChannelManager; + }); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("register request handling", () => + { + currentMessageId = 0; + + ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case JoinChannelRequest joinChannel: + joinChannel.TriggerSuccess(); + return true; + + case PostMessageRequest postMessage: + postMessage.TriggerSuccess(new Message(++currentMessageId) + { + IsAction = postMessage.Message.IsAction, + ChannelId = postMessage.Message.ChannelId, + Content = postMessage.Message.Content, + Links = postMessage.Message.Links, + Timestamp = postMessage.Message.Timestamp, + Sender = postMessage.Message.Sender + }); + + return true; + } + + return false; + }; + }); + } + + [Test] + public void TestCommandsPostedToCorrectChannelWhenNotCurrent() + { + Channel channel1 = null; + Channel channel2 = null; + + AddStep("join 2 rooms", () => + { + channelManager.JoinChannel(channel1 = createChannel(1, ChannelType.Public)); + channelManager.JoinChannel(channel2 = createChannel(2, ChannelType.Public)); + }); + + AddStep("select channel 1", () => channelManager.CurrentChannel.Value = channel1); + + AddStep("post /me command to channel 2", () => channelManager.PostCommand("me dances", channel2)); + AddAssert("/me command received by channel 2", () => channel2.Messages.Last().Content == "dances"); + + AddStep("post /np command to channel 2", () => channelManager.PostCommand("np", channel2)); + AddAssert("/np command received by channel 2", () => channel2.Messages.Last().Content.Contains("is listening to")); + } + + private Channel createChannel(int id, ChannelType type) => new Channel(new User()) + { + Id = id, + Name = $"Channel {id}", + Topic = $"Topic of channel {id} with type {type}", + Type = type, + }; + + private class ChannelManagerContainer : CompositeDrawable + { + [Cached] + public ChannelManager ChannelManager { get; } = new ChannelManager(); + + public ChannelManagerContainer() + { + InternalChild = ChannelManager; + } + } + } +} diff --git a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs index a47631a83b..8f5ebf53bd 100644 --- a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs +++ b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs @@ -113,7 +113,6 @@ namespace osu.Game.Tests.Collections.IO await importCollectionsFromStream(osu, ms); } - Assert.That(host.UpdateThread.Running, Is.True); Assert.That(exceptionThrown, Is.False); Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(0)); } diff --git a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs new file mode 100644 index 0000000000..642ecf00b8 --- /dev/null +++ b/osu.Game.Tests/Database/TestRealmKeyBindingStore.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; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Input.Bindings; +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.Input; +using osu.Game.Input.Bindings; +using Realms; + +namespace osu.Game.Tests.Database +{ + [TestFixture] + public class TestRealmKeyBindingStore + { + private NativeStorage storage; + + private RealmKeyBindingStore keyBindingStore; + + private RealmContextFactory realmContextFactory; + + [SetUp] + public void SetUp() + { + var directory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); + + storage = new NativeStorage(directory.FullName); + + realmContextFactory = new RealmContextFactory(storage); + keyBindingStore = new RealmKeyBindingStore(realmContextFactory); + } + + [Test] + public void TestDefaultsPopulationAndQuery() + { + Assert.That(queryCount(), Is.EqualTo(0)); + + KeyBindingContainer testContainer = new TestKeyBindingContainer(); + + keyBindingStore.Register(testContainer); + + Assert.That(queryCount(), Is.EqualTo(3)); + + Assert.That(queryCount(GlobalAction.Back), Is.EqualTo(1)); + Assert.That(queryCount(GlobalAction.Select), Is.EqualTo(2)); + } + + private int queryCount(GlobalAction? match = null) + { + using (var usage = realmContextFactory.GetForRead()) + { + var results = usage.Realm.All(); + if (match.HasValue) + results = results.Where(k => k.ActionInt == (int)match.Value); + return results.Count(); + } + } + + [Test] + public void TestUpdateViaQueriedReference() + { + KeyBindingContainer testContainer = new TestKeyBindingContainer(); + + keyBindingStore.Register(testContainer); + + using (var primaryUsage = realmContextFactory.GetForRead()) + { + var backBinding = primaryUsage.Realm.All().Single(k => k.ActionInt == (int)GlobalAction.Back); + + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.Escape })); + + var tsr = ThreadSafeReference.Create(backBinding); + + using (var usage = realmContextFactory.GetForWrite()) + { + var binding = usage.Realm.ResolveReference(tsr); + binding.KeyCombination = new KeyCombination(InputKey.BackSpace); + + usage.Commit(); + } + + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); + + // check still correct after re-query. + backBinding = primaryUsage.Realm.All().Single(k => k.ActionInt == (int)GlobalAction.Back); + Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace })); + } + } + + [TearDown] + public void TearDown() + { + realmContextFactory.Dispose(); + storage.DeleteDirectory(string.Empty); + } + + public class TestKeyBindingContainer : KeyBindingContainer + { + public override IEnumerable DefaultKeyBindings => + new[] + { + new KeyBinding(InputKey.Escape, GlobalAction.Back), + new KeyBinding(InputKey.Enter, GlobalAction.Select), + new KeyBinding(InputKey.Space, GlobalAction.Select), + }; + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs b/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.cs new file mode 100644 index 0000000000..cf5b3a42a4 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckFewHitsoundsTest.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 System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckFewHitsoundsTest + { + private CheckFewHitsounds check; + + private List notHitsounded; + private List hitsounded; + + [SetUp] + public void Setup() + { + check = new CheckFewHitsounds(); + notHitsounded = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; + hitsounded = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL), + new HitSampleInfo(HitSampleInfo.HIT_FINISH) + }; + } + + [Test] + public void TestHitsounded() + { + var hitObjects = new List(); + + for (int i = 0; i < 16; ++i) + { + var samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; + + if ((i + 1) % 2 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP)); + if ((i + 1) % 3 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE)); + if ((i + 1) % 4 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH)); + + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples }); + } + + assertOk(hitObjects); + } + + [Test] + public void TestHitsoundedWithBreak() + { + var hitObjects = new List(); + + for (int i = 0; i < 32; ++i) + { + var samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; + + if ((i + 1) % 2 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP)); + if ((i + 1) % 3 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_WHISTLE)); + if ((i + 1) % 4 == 0) + samples.Add(new HitSampleInfo(HitSampleInfo.HIT_FINISH)); + // Leaves a gap in which no hitsounds exist or can be added, and so shouldn't be an issue. + if (i > 8 && i < 24) + continue; + + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples }); + } + + assertOk(hitObjects); + } + + [Test] + public void TestLightlyHitsounded() + { + var hitObjects = new List(); + + for (int i = 0; i < 30; ++i) + { + var samples = i % 8 == 0 ? hitsounded : notHitsounded; + + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples }); + } + + assertLongPeriodNegligible(hitObjects, count: 3); + } + + [Test] + public void TestRarelyHitsounded() + { + var hitObjects = new List(); + + for (int i = 0; i < 30; ++i) + { + var samples = (i == 0 || i == 15) ? hitsounded : notHitsounded; + + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples }); + } + + // Should prompt one warning between 1st and 16th, and another between 16th and 31st. + assertLongPeriodWarning(hitObjects, count: 2); + } + + [Test] + public void TestExtremelyRarelyHitsounded() + { + var hitObjects = new List(); + + for (int i = 0; i < 80; ++i) + { + var samples = i == 40 ? hitsounded : notHitsounded; + + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = samples }); + } + + // Should prompt one problem between 1st and 41st, and another between 41st and 81st. + assertLongPeriodProblem(hitObjects, count: 2); + } + + [Test] + public void TestNotHitsounded() + { + var hitObjects = new List(); + + for (int i = 0; i < 20; ++i) + hitObjects.Add(new HitCircle { StartTime = 1000 * i, Samples = notHitsounded }); + + assertNoHitsounds(hitObjects); + } + + [Test] + public void TestNestedObjectsHitsounded() + { + var ticks = new List(); + for (int i = 1; i < 16; ++i) + ticks.Add(new SliderTick { StartTime = 1000 * i, Samples = hitsounded }); + + var nested = new MockNestableHitObject(ticks.ToList(), 0, 16000) + { + Samples = hitsounded + }; + nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + assertOk(new List { nested }); + } + + [Test] + public void TestNestedObjectsRarelyHitsounded() + { + var ticks = new List(); + for (int i = 1; i < 16; ++i) + ticks.Add(new SliderTick { StartTime = 1000 * i, Samples = i == 0 ? hitsounded : notHitsounded }); + + var nested = new MockNestableHitObject(ticks.ToList(), 0, 16000) + { + Samples = hitsounded + }; + nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + assertLongPeriodWarning(new List { nested }); + } + + [Test] + public void TestConcurrentObjects() + { + var hitObjects = new List(); + + var ticks = new List(); + for (int i = 1; i < 10; ++i) + ticks.Add(new SliderTick { StartTime = 5000 * i, Samples = hitsounded }); + + var nested = new MockNestableHitObject(ticks.ToList(), 0, 50000) + { + Samples = notHitsounded + }; + nested.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + hitObjects.Add(nested); + + for (int i = 1; i <= 6; ++i) + hitObjects.Add(new HitCircle { StartTime = 10000 * i, Samples = notHitsounded }); + + assertOk(hitObjects); + } + + private void assertOk(List hitObjects) + { + Assert.That(check.Run(getContext(hitObjects)), Is.Empty); + } + + private void assertLongPeriodProblem(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodProblem)); + } + + private void assertLongPeriodWarning(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodWarning)); + } + + private void assertLongPeriodNegligible(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckFewHitsounds.IssueTemplateLongPeriodNegligible)); + } + + private void assertNoHitsounds(List hitObjects) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Any(issue => issue.Template is CheckFewHitsounds.IssueTemplateNoHitsounds)); + } + + private BeatmapVerifierContext getContext(List hitObjects) + { + var beatmap = new Beatmap { HitObjects = hitObjects }; + + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs new file mode 100644 index 0000000000..41a8f72305 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckMutedObjectsTest.cs @@ -0,0 +1,289 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckMutedObjectsTest + { + private CheckMutedObjects check; + private ControlPointInfo cpi; + + private const int volume_regular = 50; + private const int volume_low = 15; + private const int volume_muted = 5; + + [SetUp] + public void Setup() + { + check = new CheckMutedObjects(); + + cpi = new ControlPointInfo(); + cpi.Add(0, new SampleControlPoint { SampleVolume = volume_regular }); + cpi.Add(1000, new SampleControlPoint { SampleVolume = volume_low }); + cpi.Add(2000, new SampleControlPoint { SampleVolume = volume_muted }); + } + + [Test] + public void TestNormalControlPointVolume() + { + var hitcircle = new HitCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertOk(new List { hitcircle }); + } + + [Test] + public void TestLowControlPointVolume() + { + var hitcircle = new HitCircle + { + StartTime = 1000, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertLowVolume(new List { hitcircle }); + } + + [Test] + public void TestMutedControlPointVolume() + { + var hitcircle = new HitCircle + { + StartTime = 2000, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMuted(new List { hitcircle }); + } + + [Test] + public void TestNormalSampleVolume() + { + // The sample volume should take precedence over the control point volume. + var hitcircle = new HitCircle + { + StartTime = 2000, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertOk(new List { hitcircle }); + } + + [Test] + public void TestLowSampleVolume() + { + var hitcircle = new HitCircle + { + StartTime = 2000, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_low) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertLowVolume(new List { hitcircle }); + } + + [Test] + public void TestMutedSampleVolume() + { + var hitcircle = new HitCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) } + }; + hitcircle.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMuted(new List { hitcircle }); + } + + [Test] + public void TestNormalSampleVolumeSlider() + { + var sliderHead = new SliderHeadCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var sliderTick = new SliderTick + { + StartTime = 250, + Samples = new List { new HitSampleInfo("slidertick", volume: volume_muted) } // Should be fine. + }; + sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 500) + { + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + slider.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertOk(new List { slider }); + } + + [Test] + public void TestMutedSampleVolumeSliderHead() + { + var sliderHead = new SliderHeadCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) } + }; + sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var sliderTick = new SliderTick + { + StartTime = 250, + Samples = new List { new HitSampleInfo("slidertick") } + }; + sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 500) + { + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } // Applies to the tail. + }; + slider.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMuted(new List { slider }); + } + + [Test] + public void TestMutedSampleVolumeSliderTail() + { + var sliderHead = new SliderHeadCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var sliderTick = new SliderTick + { + StartTime = 250, + Samples = new List { new HitSampleInfo("slidertick") } + }; + sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 2500) + { + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_muted) } // Applies to the tail. + }; + slider.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMutedPassive(new List { slider }); + } + + [Test] + public void TestMutedControlPointVolumeSliderHead() + { + var sliderHead = new SliderHeadCircle + { + StartTime = 2000, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var sliderTick = new SliderTick + { + StartTime = 2250, + Samples = new List { new HitSampleInfo("slidertick") } + }; + sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 2000, endTime: 2500) + { + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: volume_regular) } + }; + slider.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMuted(new List { slider }); + } + + [Test] + public void TestMutedControlPointVolumeSliderTail() + { + var sliderHead = new SliderHeadCircle + { + StartTime = 0, + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + sliderHead.ApplyDefaults(cpi, new BeatmapDifficulty()); + + var sliderTick = new SliderTick + { + StartTime = 250, + Samples = new List { new HitSampleInfo("slidertick") } + }; + sliderTick.ApplyDefaults(cpi, new BeatmapDifficulty()); + + // Ends after the 5% control point. + var slider = new MockNestableHitObject(new List { sliderHead, sliderTick, }, startTime: 0, endTime: 2500) + { + Samples = new List { new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } + }; + slider.ApplyDefaults(cpi, new BeatmapDifficulty()); + + assertMutedPassive(new List { slider }); + } + + private void assertOk(List hitObjects) + { + Assert.That(check.Run(getContext(hitObjects)), Is.Empty); + } + + private void assertLowVolume(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckMutedObjects.IssueTemplateLowVolumeActive)); + } + + private void assertMuted(List hitObjects, int count = 1) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(count)); + Assert.That(issues.All(issue => issue.Template is CheckMutedObjects.IssueTemplateMutedActive)); + } + + private void assertMutedPassive(List hitObjects) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Any(issue => issue.Template is CheckMutedObjects.IssueTemplateMutedPassive)); + } + + private BeatmapVerifierContext getContext(List hitObjects) + { + var beatmap = new Beatmap + { + ControlPointInfo = cpi, + HitObjects = hitObjects + }; + + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckZeroLengthObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckZeroLengthObjectsTest.cs new file mode 100644 index 0000000000..93b20cd166 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckZeroLengthObjectsTest.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 System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Beatmaps; +using osuTK; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckZeroLengthObjectsTest + { + private CheckZeroLengthObjects check; + + [SetUp] + public void Setup() + { + check = new CheckZeroLengthObjects(); + } + + [Test] + public void TestCircle() + { + assertOk(new List + { + new HitCircle { StartTime = 1000, Position = new Vector2(0, 0) } + }); + } + + [Test] + public void TestRegularSlider() + { + assertOk(new List + { + getSliderMock(1000).Object + }); + } + + [Test] + public void TestZeroLengthSlider() + { + assertZeroLength(new List + { + getSliderMock(0).Object + }); + } + + [Test] + public void TestNegativeLengthSlider() + { + assertZeroLength(new List + { + getSliderMock(-1000).Object + }); + } + + private Mock getSliderMock(double duration) + { + var mockSlider = new Mock(); + mockSlider.As().Setup(d => d.Duration).Returns(duration); + + return mockSlider; + } + + private void assertOk(List hitObjects) + { + Assert.That(check.Run(getContext(hitObjects)), Is.Empty); + } + + private void assertZeroLength(List hitObjects) + { + var issues = check.Run(getContext(hitObjects)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.First().Template is CheckZeroLengthObjects.IssueTemplateZeroLength); + } + + private BeatmapVerifierContext getContext(List hitObjects) + { + var beatmap = new Beatmap { HitObjects = hitObjects }; + + return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap)); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/MockNestableHitObject.cs b/osu.Game.Tests/Editing/Checks/MockNestableHitObject.cs new file mode 100644 index 0000000000..29938839d3 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/MockNestableHitObject.cs @@ -0,0 +1,36 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Threading; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Tests.Editing.Checks +{ + public sealed class MockNestableHitObject : HitObject, IHasDuration + { + private readonly IEnumerable toBeNested; + + public MockNestableHitObject(IEnumerable toBeNested, double startTime, double endTime) + { + this.toBeNested = toBeNested; + StartTime = startTime; + EndTime = endTime; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + foreach (var hitObject in toBeNested) + AddNested(hitObject); + } + + public double EndTime { get; } + + public double Duration + { + get => EndTime - StartTime; + set => throw new System.NotImplementedException(); + } + } +} diff --git a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs index da0d57f9d1..0ce71696bd 100644 --- a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs +++ b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs @@ -44,11 +44,9 @@ namespace osu.Game.Tests.Gameplay { TestDrawableHitObject dho = null; TestLifetimeEntry entry = null; - AddStep("Create DHO", () => + AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject { - dho = new TestDrawableHitObject(null); - dho.Apply(entry = new TestLifetimeEntry(new HitObject())); - Child = dho; + Entry = entry = new TestLifetimeEntry(new HitObject()) }); AddStep("KeepAlive = true", () => @@ -81,12 +79,10 @@ namespace osu.Game.Tests.Gameplay AddAssert("Lifetime is updated", () => entry.LifetimeStart == -TestLifetimeEntry.INITIAL_LIFETIME_OFFSET); TestDrawableHitObject dho = null; - AddStep("Create DHO", () => + AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject { - dho = new TestDrawableHitObject(null); - dho.Apply(entry); - Child = dho; - dho.SetLifetimeStartOnApply = true; + Entry = entry, + SetLifetimeStartOnApply = true }); AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty())); AddAssert("Lifetime is correct", () => dho.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY && entry.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY); @@ -97,11 +93,9 @@ namespace osu.Game.Tests.Gameplay { TestDrawableHitObject dho = null; TestLifetimeEntry entry = null; - AddStep("Create DHO", () => + AddStep("Create DHO", () => Child = dho = new TestDrawableHitObject { - dho = new TestDrawableHitObject(null); - dho.Apply(entry = new TestLifetimeEntry(new HitObject())); - Child = dho; + Entry = entry = new TestLifetimeEntry(new HitObject()) }); AddStep("Set entry lifetime", () => @@ -135,7 +129,7 @@ namespace osu.Game.Tests.Gameplay public bool SetLifetimeStartOnApply; - public TestDrawableHitObject(HitObject hitObject) + public TestDrawableHitObject(HitObject hitObject = null) : base(hitObject) { } diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index 883791c35c..7ff1259307 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -123,18 +123,14 @@ namespace osu.Game.Tests.Gameplay public ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); + public ISkin FindProvider(Func lookupFunction) => null; + public IBindable GetConfig(TLookup lookup) { switch (lookup) { - case GlobalSkinColours global: - switch (global) - { - case GlobalSkinColours.ComboColours: - return SkinUtils.As(new Bindable>(ComboColours)); - } - - break; + case SkinComboColourLookup comboColour: + return SkinUtils.As(new Bindable(ComboColours[comboColour.ColourIndex % ComboColours.Count])); } throw new NotImplementedException(); diff --git a/osu.Game.Tests/ImportTest.cs b/osu.Game.Tests/ImportTest.cs index ea351e0d45..e888f51e98 100644 --- a/osu.Game.Tests/ImportTest.cs +++ b/osu.Game.Tests/ImportTest.cs @@ -17,7 +17,8 @@ namespace osu.Game.Tests protected virtual TestOsuGameBase LoadOsuIntoHost(GameHost host, bool withBeatmap = false) { var osu = new TestOsuGameBase(withBeatmap); - Task.Run(() => host.Run(osu)); + Task.Run(() => host.Run(osu)) + .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); diff --git a/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs b/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.cs new file mode 100644 index 0000000000..dab4825919 --- /dev/null +++ b/osu.Game.Tests/Localisation/BeatmapMetadataRomanisationTest.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.Beatmaps; + +namespace osu.Game.Tests.Localisation +{ + [TestFixture] + public class BeatmapMetadataRomanisationTest + { + [Test] + public void TestRomanisation() + { + var metadata = new BeatmapMetadata + { + Artist = "Romanised Artist", + ArtistUnicode = "Unicode Artist", + Title = "Romanised title", + TitleUnicode = "Unicode Title" + }; + var romanisableString = metadata.ToRomanisableString(); + + Assert.AreEqual(metadata.ToString(), romanisableString.Romanised); + Assert.AreEqual($"{metadata.ArtistUnicode} - {metadata.TitleUnicode}", romanisableString.Original); + } + + [Test] + public void TestRomanisationNoUnicode() + { + var metadata = new BeatmapMetadata + { + Artist = "Romanised Artist", + Title = "Romanised title" + }; + var romanisableString = metadata.ToRomanisableString(); + + Assert.AreEqual(romanisableString.Romanised, romanisableString.Original); + } + } +} diff --git a/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs new file mode 100644 index 0000000000..84cf796835 --- /dev/null +++ b/osu.Game.Tests/Mods/ModDifficultyAdjustTest.cs @@ -0,0 +1,165 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +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.Mods +{ + [TestFixture] + public class ModDifficultyAdjustTest + { + private TestModDifficultyAdjust testMod; + + [SetUp] + public void Setup() + { + testMod = new TestModDifficultyAdjust(); + } + + [Test] + public void TestUnchangedSettingsFollowAppliedDifficulty() + { + var result = applyDifficulty(new BeatmapDifficulty + { + DrainRate = 10, + OverallDifficulty = 10 + }); + + Assert.That(result.DrainRate, Is.EqualTo(10)); + Assert.That(result.OverallDifficulty, Is.EqualTo(10)); + + result = applyDifficulty(new BeatmapDifficulty + { + DrainRate = 1, + OverallDifficulty = 1 + }); + + Assert.That(result.DrainRate, Is.EqualTo(1)); + Assert.That(result.OverallDifficulty, Is.EqualTo(1)); + } + + [Test] + public void TestChangedSettingsOverrideAppliedDifficulty() + { + testMod.OverallDifficulty.Value = 4; + + var result = applyDifficulty(new BeatmapDifficulty + { + DrainRate = 10, + OverallDifficulty = 10 + }); + + Assert.That(result.DrainRate, Is.EqualTo(10)); + Assert.That(result.OverallDifficulty, Is.EqualTo(4)); + + result = applyDifficulty(new BeatmapDifficulty + { + DrainRate = 1, + OverallDifficulty = 1 + }); + + Assert.That(result.DrainRate, Is.EqualTo(1)); + Assert.That(result.OverallDifficulty, Is.EqualTo(4)); + } + + [Test] + public void TestChangedSettingsRetainedWhenSameValueIsApplied() + { + testMod.OverallDifficulty.Value = 4; + + // Apply and de-apply the same value as the mod. + applyDifficulty(new BeatmapDifficulty { OverallDifficulty = 4 }); + var result = applyDifficulty(new BeatmapDifficulty { OverallDifficulty = 10 }); + + Assert.That(result.OverallDifficulty, Is.EqualTo(4)); + } + + [Test] + public void TestChangedSettingSerialisedWhenSameValueIsApplied() + { + applyDifficulty(new BeatmapDifficulty { OverallDifficulty = 4 }); + testMod.OverallDifficulty.Value = 4; + + var result = (TestModDifficultyAdjust)new APIMod(testMod).ToMod(new TestRuleset()); + + Assert.That(result.OverallDifficulty.Value, Is.EqualTo(4)); + } + + [Test] + public void TestChangedSettingsRevertedToDefault() + { + applyDifficulty(new BeatmapDifficulty + { + DrainRate = 10, + OverallDifficulty = 10 + }); + + testMod.OverallDifficulty.Value = 4; + testMod.ResetSettingsToDefaults(); + + Assert.That(testMod.DrainRate.Value, Is.Null); + Assert.That(testMod.OverallDifficulty.Value, Is.Null); + + var applied = applyDifficulty(new BeatmapDifficulty + { + DrainRate = 10, + OverallDifficulty = 10 + }); + + Assert.That(applied.OverallDifficulty, Is.EqualTo(10)); + } + + /// + /// Applies a to the mod and returns a new + /// representing the result if the mod were applied to a fresh instance. + /// + private BeatmapDifficulty applyDifficulty(BeatmapDifficulty difficulty) + { + // ensure that ReadFromDifficulty doesn't pollute the values. + var newDifficulty = difficulty.Clone(); + + testMod.ReadFromDifficulty(difficulty); + + testMod.ApplyToDifficulty(newDifficulty); + return newDifficulty; + } + + private class TestModDifficultyAdjust : ModDifficultyAdjust + { + } + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) + { + if (type == ModType.DifficultyIncrease) + yield return new TestModDifficultyAdjust(); + } + + 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 => string.Empty; + public override string ShortName => string.Empty; + } + } +} diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 7384471c41..4c126f0a3b 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -14,6 +14,14 @@ namespace osu.Game.Tests.Mods [TestFixture] public class ModUtilsTest { + [Test] + public void TestModIsNotCompatibleWithItself() + { + var mod = new Mock(); + Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object, mod.Object }, out var invalid), Is.False); + Assert.That(invalid, Is.EquivalentTo(new[] { mod.Object })); + } + [Test] public void TestModIsCompatibleByItself() { @@ -21,6 +29,14 @@ namespace osu.Game.Tests.Mods Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object })); } + [Test] + public void TestModIsCompatibleByItselfWithIncompatibleInterface() + { + var mod = new Mock(); + mod.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(IModCompatibilitySpecification) }); + Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object })); + } + [Test] public void TestIncompatibleThroughTopLevel() { @@ -34,6 +50,20 @@ namespace osu.Game.Tests.Mods Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False); } + [Test] + public void TestIncompatibleThroughInterface() + { + var mod1 = new Mock(); + var mod2 = new Mock(); + + mod1.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(IModCompatibilitySpecification) }); + mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(IModCompatibilitySpecification) }); + + // Test both orderings. + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False); + Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False); + } + [Test] public void TestMultiModIncompatibleWithTopLevel() { @@ -125,7 +155,7 @@ namespace osu.Game.Tests.Mods // multi mod. new object[] { - new Mod[] { new MultiMod(new OsuModHalfTime()), new OsuModHalfTime() }, + new Mod[] { new MultiMod(new OsuModHalfTime()), new OsuModDaycore() }, new[] { typeof(MultiMod) } }, // valid pair. @@ -149,11 +179,15 @@ namespace osu.Game.Tests.Mods Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } - public abstract class CustomMod1 : Mod + public abstract class CustomMod1 : Mod, IModCompatibilitySpecification { } - public abstract class CustomMod2 : Mod + public abstract class CustomMod2 : Mod, IModCompatibilitySpecification + { + } + + public interface IModCompatibilitySpecification { } } diff --git a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs index b27c257795..240ae4a90c 100644 --- a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs +++ b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs @@ -248,13 +248,13 @@ namespace osu.Game.Tests.NonVisual } [Test] - public void TestCreateCopyIsDeepClone() + public void TestDeepClone() { var cpi = new ControlPointInfo(); cpi.Add(1000, new TimingControlPoint { BeatLength = 500 }); - var cpiCopy = cpi.CreateCopy(); + var cpiCopy = cpi.DeepClone(); cpiCopy.Add(2000, new TimingControlPoint { BeatLength = 500 }); diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index a763544c37..4c44e2ec72 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -142,7 +142,10 @@ namespace osu.Game.Tests.NonVisual foreach (var file in osuStorage.IgnoreFiles) { - Assert.That(File.Exists(Path.Combine(originalDirectory, file))); + // avoid touching realm files which may be a pipe and break everything. + // this is also done locally inside OsuStorage via the IgnoreFiles list. + if (file.EndsWith(".ini", StringComparison.Ordinal)) + Assert.That(File.Exists(Path.Combine(originalDirectory, file))); Assert.That(storage.Exists(file), Is.False); } @@ -181,6 +184,9 @@ namespace osu.Game.Tests.NonVisual Assert.DoesNotThrow(() => osu.Migrate(customPath2)); Assert.That(File.Exists(Path.Combine(customPath2, database_filename))); + // some files may have been left behind for whatever reason, but that's not what we're testing here. + customPath = prepareCustomPath(); + Assert.DoesNotThrow(() => osu.Migrate(customPath)); Assert.That(File.Exists(Path.Combine(customPath, database_filename))); } diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 16c1004f37..e458e66ab7 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -217,7 +217,7 @@ namespace osu.Game.Tests.NonVisual throw new NotImplementedException(); } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods) + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { throw new NotImplementedException(); } diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs index 49389e67aa..a55bdd2df8 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs @@ -33,10 +33,11 @@ namespace osu.Game.Tests.NonVisual.Filtering * outside of the range. */ - [Test] - public void TestApplyStarQueries() + [TestCase("star")] + [TestCase("stars")] + public void TestApplyStarQueries(string variant) { - const string query = "stars<4 easy"; + string query = $"{variant}<4 easy"; var filterCriteria = new FilterCriteria(); FilterQueryParser.ApplyQueries(filterCriteria, query); Assert.AreEqual("easy", filterCriteria.SearchText.Trim()); @@ -89,6 +90,20 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.Less(filterCriteria.DrainRate.Min, 6.1f); } + [Test] + public void TestApplyOverallDifficultyQueries() + { + const string query = "od>4 easy od<8"; + var filterCriteria = new FilterCriteria(); + FilterQueryParser.ApplyQueries(filterCriteria, query); + Assert.AreEqual("easy", filterCriteria.SearchText.Trim()); + Assert.AreEqual(1, filterCriteria.SearchTerms.Length); + Assert.Greater(filterCriteria.OverallDifficulty.Min, 4.0); + Assert.Less(filterCriteria.OverallDifficulty.Min, 4.1); + Assert.Greater(filterCriteria.OverallDifficulty.Max, 7.9); + Assert.Less(filterCriteria.OverallDifficulty.Max, 8.0); + } + [Test] public void TestApplyBPMQueries() { diff --git a/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs new file mode 100644 index 0000000000..97105b6b6a --- /dev/null +++ b/osu.Game.Tests/NonVisual/FirstAvailableHitWindowsTest.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; + +namespace osu.Game.Tests.NonVisual +{ + public class FirstAvailableHitWindowsTest + { + private TestDrawableRuleset testDrawableRuleset; + + [SetUp] + public void Setup() + { + testDrawableRuleset = new TestDrawableRuleset(); + } + + [Test] + public void TestResultIfOnlyParentHitWindowIsEmpty() + { + var testObject = new TestHitObject(HitWindows.Empty); + HitObject nested = new TestHitObject(new HitWindows()); + testObject.AddNested(nested); + testDrawableRuleset.HitObjects = new List { testObject }; + + Assert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, nested.HitWindows); + } + + [Test] + public void TestResultIfParentHitWindowsIsNotEmpty() + { + var testObject = new TestHitObject(new HitWindows()); + HitObject nested = new TestHitObject(new HitWindows()); + testObject.AddNested(nested); + testDrawableRuleset.HitObjects = new List { testObject }; + + Assert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, testObject.HitWindows); + } + + [Test] + public void TestResultIfParentAndChildHitWindowsAreEmpty() + { + var firstObject = new TestHitObject(HitWindows.Empty); + HitObject nested = new TestHitObject(HitWindows.Empty); + firstObject.AddNested(nested); + + var secondObject = new TestHitObject(new HitWindows()); + testDrawableRuleset.HitObjects = new List { firstObject, secondObject }; + + Assert.AreSame(testDrawableRuleset.FirstAvailableHitWindows, secondObject.HitWindows); + } + + [Test] + public void TestResultIfAllHitWindowsAreEmpty() + { + var firstObject = new TestHitObject(HitWindows.Empty); + HitObject nested = new TestHitObject(HitWindows.Empty); + firstObject.AddNested(nested); + + testDrawableRuleset.HitObjects = new List { firstObject }; + + Assert.IsNull(testDrawableRuleset.FirstAvailableHitWindows); + } + + [SuppressMessage("ReSharper", "UnassignedGetOnlyAutoProperty")] + private class TestDrawableRuleset : DrawableRuleset + { + public List HitObjects; + public override IEnumerable Objects => HitObjects; + + public override event Action NewResult; + public override event Action RevertResult; + + public override Playfield Playfield { get; } + public override Container Overlays { get; } + public override Container FrameStableComponents { get; } + public override IFrameStableClock FrameStableClock { get; } + internal override bool FrameStablePlayback { get; set; } + public override IReadOnlyList Mods { get; } + + public override double GameplayStartTime { get; } + public override GameplayCursorContainer Cursor { get; } + + public TestDrawableRuleset() + : base(new OsuRuleset()) + { + // won't compile without this. + NewResult?.Invoke(null); + RevertResult?.Invoke(null); + } + + public override void SetReplayScore(Score replayScore) => throw new NotImplementedException(); + + public override void SetRecordTarget(Score score) => throw new NotImplementedException(); + + public override void RequestResume(Action continueResume) => throw new NotImplementedException(); + + public override void CancelResume() => throw new NotImplementedException(); + } + + public class TestHitObject : HitObject + { + public TestHitObject(HitWindows hitWindows) + { + HitWindows = hitWindows; + HitWindows.SetDifficulty(0.5f); + } + + public new void AddNested(HitObject nested) => base.AddNested(nested); + } + } +} diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index adc1d6aede..0983b806e2 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; using osu.Game.Tests.Visual.Multiplayer; using osu.Game.Users; @@ -50,7 +51,10 @@ namespace osu.Game.Tests.NonVisual.Multiplayer AddStep("create room initially in gameplay", () => { - Room.RoomID.Value = null; + var newRoom = new Room(); + newRoom.CopyFrom(SelectedRoom.Value); + + newRoom.RoomID.Value = null; Client.RoomSetupAction = room => { room.State = MultiplayerRoomState.Playing; @@ -61,7 +65,7 @@ namespace osu.Game.Tests.NonVisual.Multiplayer }); }; - RoomManager.CreateRoom(Room); + RoomManager.CreateRoom(newRoom); }); AddUntilStep("wait for room join", () => Client.Room != null); diff --git a/osu.Game.Tests/NonVisual/ScoreInfoTest.cs b/osu.Game.Tests/NonVisual/ScoreInfoTest.cs new file mode 100644 index 0000000000..6e5718cd4c --- /dev/null +++ b/osu.Game.Tests/NonVisual/ScoreInfoTest.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.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class ScoreInfoTest + { + [Test] + public void TestDeepClone() + { + var score = new ScoreInfo(); + + score.Statistics.Add(HitResult.Good, 10); + score.Rank = ScoreRank.B; + + var scoreCopy = score.DeepClone(); + + score.Statistics[HitResult.Good]++; + score.Rank = ScoreRank.X; + + Assert.That(scoreCopy.Statistics[HitResult.Good], Is.EqualTo(10)); + Assert.That(score.Statistics[HitResult.Good], Is.EqualTo(11)); + + Assert.That(scoreCopy.Rank, Is.EqualTo(ScoreRank.B)); + Assert.That(score.Rank, Is.EqualTo(ScoreRank.X)); + } + } +} diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs index b08a228de3..e45b8f7dc5 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinAnimationTest.cs @@ -61,6 +61,7 @@ namespace osu.Game.Tests.NonVisual.Skinning public Drawable GetDrawableComponent(ISkinComponent component) => throw new NotSupportedException(); public ISample GetSample(ISampleInfo sampleInfo) => throw new NotSupportedException(); public IBindable GetConfig(TLookup lookup) => throw new NotSupportedException(); + public ISkin FindProvider(Func lookupFunction) => null; } private class TestAnimationTimeReference : IAnimationTimeReference diff --git a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs index d4e591cf09..6851df3832 100644 --- a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs +++ b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs @@ -31,32 +31,24 @@ namespace osu.Game.Tests.OnlinePlay } [Test] - public void TestMasterClockStartsWhenAllPlayerClocksHaveFrames() + public void TestPlayerClocksStartWhenAllHaveFrames() { setWaiting(() => player1, false); - assertMasterState(false); assertPlayerClockState(() => player1, false); assertPlayerClockState(() => player2, false); setWaiting(() => player2, false); - assertMasterState(true); assertPlayerClockState(() => player1, true); assertPlayerClockState(() => player2, true); } [Test] - public void TestMasterClockDoesNotStartWhenNoneReadyForMaximumDelayTime() - { - AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); - assertMasterState(false); - } - - [Test] - public void TestMasterClockStartsWhenAnyReadyForMaximumDelayTime() + public void TestReadyPlayersStartWhenReadyForMaximumDelayTime() { setWaiting(() => player1, false); AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); - assertMasterState(true); + assertPlayerClockState(() => player1, true); + assertPlayerClockState(() => player2, false); } [Test] @@ -153,9 +145,6 @@ namespace osu.Game.Tests.OnlinePlay private void setPlayerClockTime(Func playerClock, double offsetFromMaster) => AddStep($"set player clock {playerClock().Id} = master - {offsetFromMaster}", () => playerClock().Seek(master.CurrentTime - offsetFromMaster)); - private void assertMasterState(bool running) - => AddAssert($"master clock {(running ? "is" : "is not")} running", () => master.IsRunning == running); - private void assertCatchingUp(Func playerClock, bool catchingUp) => AddAssert($"player clock {playerClock().Id} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp); @@ -201,6 +190,11 @@ namespace osu.Game.Tests.OnlinePlay private class TestManualClock : ManualClock, IAdjustableClock { + public TestManualClock() + { + IsRunning = true; + } + public void Start() => IsRunning = true; public void Stop() => IsRunning = false; diff --git a/osu.Game.Tests/Resources/Shaders/sh_TestFragment.fs b/osu.Game.Tests/Resources/Shaders/sh_TestFragment.fs new file mode 100644 index 0000000000..c70ad751be --- /dev/null +++ b/osu.Game.Tests/Resources/Shaders/sh_TestFragment.fs @@ -0,0 +1,11 @@ +#include "sh_Utils.h" + +varying mediump vec2 v_TexCoord; +varying mediump vec4 v_TexRect; + +void main(void) +{ + float hueValue = v_TexCoord.x / (v_TexRect[2] - v_TexRect[0]); + gl_FragColor = hsv2rgb(vec4(hueValue, 1, 1, 1)); +} + diff --git a/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs b/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs new file mode 100644 index 0000000000..4485356fa4 --- /dev/null +++ b/osu.Game.Tests/Resources/Shaders/sh_TestVertex.vs @@ -0,0 +1,31 @@ +#include "sh_Utils.h" + +attribute highp vec2 m_Position; +attribute lowp vec4 m_Colour; +attribute mediump vec2 m_TexCoord; +attribute mediump vec4 m_TexRect; +attribute mediump vec2 m_BlendRange; + +varying highp vec2 v_MaskingPosition; +varying lowp vec4 v_Colour; +varying mediump vec2 v_TexCoord; +varying mediump vec4 v_TexRect; +varying mediump vec2 v_BlendRange; + +uniform highp mat4 g_ProjMatrix; +uniform highp mat3 g_ToMaskingSpace; + +void main(void) +{ + // Transform from screen space to masking space. + highp vec3 maskingPos = g_ToMaskingSpace * vec3(m_Position, 1.0); + v_MaskingPosition = maskingPos.xy / maskingPos.z; + + v_Colour = m_Colour; + v_TexCoord = m_TexCoord; + v_TexRect = m_TexRect; + v_BlendRange = m_BlendRange; + + gl_Position = gProjMatrix * vec4(m_Position, 1.0, 1.0); +} + diff --git a/osu.Game.Tests/Resources/old-skin/skin.ini b/osu.Game.Tests/Resources/old-skin/skin.ini index 5369de24e9..94c6b5b58d 100644 --- a/osu.Game.Tests/Resources/old-skin/skin.ini +++ b/osu.Game.Tests/Resources/old-skin/skin.ini @@ -1,2 +1,2 @@ [General] -Version: 1.0 \ No newline at end of file +// no version specified means v1 diff --git a/osu.Game.Tests/Resources/special-skin/scorebar-bg.png b/osu.Game.Tests/Resources/special-skin/scorebar-bg.png new file mode 100644 index 0000000000..1a25274ed8 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/scorebar-bg.png differ diff --git a/osu.Game.Tests/Resources/special-skin/scorebar-colour-0.png b/osu.Game.Tests/Resources/special-skin/scorebar-colour-0.png new file mode 100644 index 0000000000..3c15449b03 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/scorebar-colour-0.png differ diff --git a/osu.Game.Tests/Resources/special-skin/scorebar-colour-1.png b/osu.Game.Tests/Resources/special-skin/scorebar-colour-1.png new file mode 100644 index 0000000000..a444723ef4 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/scorebar-colour-1.png differ diff --git a/osu.Game.Tests/Resources/special-skin/scorebar-colour-2.png b/osu.Game.Tests/Resources/special-skin/scorebar-colour-2.png new file mode 100644 index 0000000000..e1c6b41d9b Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/scorebar-colour-2.png differ diff --git a/osu.Game.Tests/Resources/special-skin/scorebar-colour-3.png b/osu.Game.Tests/Resources/special-skin/scorebar-colour-3.png new file mode 100644 index 0000000000..a3a5ca4716 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/scorebar-colour-3.png differ diff --git a/osu.Game.Tests/Resources/special-skin/scorebar-marker.png b/osu.Game.Tests/Resources/special-skin/scorebar-marker.png new file mode 100644 index 0000000000..b5af0b2148 Binary files /dev/null and b/osu.Game.Tests/Resources/special-skin/scorebar-marker.png differ diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs index f421a30283..c357fccd27 100644 --- a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs @@ -13,8 +13,10 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.UI; @@ -31,12 +33,14 @@ namespace osu.Game.Tests.Rulesets DrawableWithDependencies drawable = null; TestTextureStore textureStore = null; TestSampleStore sampleStore = null; + TestShaderManager shaderManager = null; AddStep("add dependencies", () => { Child = drawable = new DrawableWithDependencies(); textureStore = drawable.ParentTextureStore; sampleStore = drawable.ParentSampleStore; + shaderManager = drawable.ParentShaderManager; }); AddStep("clear children", Clear); @@ -52,12 +56,14 @@ namespace osu.Game.Tests.Rulesets AddAssert("parent texture store not disposed", () => !textureStore.IsDisposed); AddAssert("parent sample store not disposed", () => !sampleStore.IsDisposed); + AddAssert("parent shader manager not disposed", () => !shaderManager.IsDisposed); } private class DrawableWithDependencies : CompositeDrawable { public TestTextureStore ParentTextureStore { get; private set; } public TestSampleStore ParentSampleStore { get; private set; } + public TestShaderManager ParentShaderManager { get; private set; } public DrawableWithDependencies() { @@ -70,6 +76,7 @@ namespace osu.Game.Tests.Rulesets dependencies.CacheAs(ParentTextureStore = new TestTextureStore()); dependencies.CacheAs(ParentSampleStore = new TestSampleStore()); + dependencies.CacheAs(ParentShaderManager = new TestShaderManager()); return new DrawableRulesetDependencies(new OsuRuleset(), dependencies); } @@ -135,5 +142,23 @@ namespace osu.Game.Tests.Rulesets public int PlaybackConcurrency { get; set; } } + + private class TestShaderManager : ShaderManager + { + public TestShaderManager() + : base(new ResourceStore()) + { + } + + public override byte[] LoadRaw(string name) => null; + + public bool IsDisposed { get; private set; } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + IsDisposed = true; + } + } } } diff --git a/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs b/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.cs new file mode 100644 index 0000000000..28ad7ed6a7 --- /dev/null +++ b/osu.Game.Tests/Rulesets/TestSceneRulesetSkinProvidingContainer.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 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.Rulesets; +using osu.Game.Skinning; +using osu.Game.Tests.Testing; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Rulesets +{ + [HeadlessTest] + public class TestSceneRulesetSkinProvidingContainer : OsuTestScene + { + private SkinRequester requester; + + protected override Ruleset CreateRuleset() => new TestSceneRulesetDependencies.TestRuleset(); + + [Test] + public void TestRulesetResources() + { + setupProviderStep(); + + AddAssert("ruleset texture retrieved via skin", () => requester.GetTexture("test-image") != null); + AddAssert("ruleset sample retrieved via skin", () => requester.GetSample(new SampleInfo("test-sample")) != null); + } + + [Test] + public void TestEarlyAddedSkinRequester() + { + Texture textureOnLoad = null; + + AddStep("setup provider", () => + { + var rulesetSkinProvider = new RulesetSkinProvidingContainer(Ruleset.Value.CreateInstance(), Beatmap.Value.Beatmap, Beatmap.Value.Skin); + + rulesetSkinProvider.Add(requester = new SkinRequester()); + + requester.OnLoadAsync += () => textureOnLoad = requester.GetTexture("test-image"); + + Child = rulesetSkinProvider; + }); + + AddAssert("requester got correct initial texture", () => textureOnLoad != null); + } + + private void setupProviderStep() + { + AddStep("setup provider", () => + { + Child = new RulesetSkinProvidingContainer(Ruleset.Value.CreateInstance(), Beatmap.Value.Beatmap, Beatmap.Value.Skin) + .WithChild(requester = new SkinRequester()); + }); + } + + private class SkinRequester : Drawable, ISkin + { + private ISkinSource skin; + + public event Action OnLoadAsync; + + [BackgroundDependencyLoader] + private void load(ISkinSource skin) + { + this.skin = skin; + + OnLoadAsync?.Invoke(); + } + + public Drawable GetDrawableComponent(ISkinComponent component) => skin.GetDrawableComponent(component); + + public Texture GetTexture(string componentName, WrapMode wrapModeS = default, WrapMode wrapModeT = default) => skin.GetTexture(componentName); + + public ISample GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); + + public IBindable GetConfig(TLookup lookup) => skin.GetConfig(lookup); + } + } +} diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs index 7522aca5dc..cd7d744f53 100644 --- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs +++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs @@ -43,7 +43,7 @@ namespace osu.Game.Tests.Scores.IO OnlineScoreID = 12345, }; - var imported = await loadScoreIntoOsu(osu, toImport); + var imported = await LoadScoreIntoOsu(osu, toImport); Assert.AreEqual(toImport.Rank, imported.Rank); Assert.AreEqual(toImport.TotalScore, imported.TotalScore); @@ -75,7 +75,7 @@ namespace osu.Game.Tests.Scores.IO Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() }, }; - var imported = await loadScoreIntoOsu(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)); @@ -105,7 +105,7 @@ namespace osu.Game.Tests.Scores.IO } }; - var imported = await loadScoreIntoOsu(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]); @@ -136,7 +136,7 @@ namespace osu.Game.Tests.Scores.IO } }; - var imported = await loadScoreIntoOsu(osu, toImport); + var imported = await LoadScoreIntoOsu(osu, toImport); var beatmapManager = osu.Dependencies.Get(); var scoreManager = osu.Dependencies.Get(); @@ -144,7 +144,7 @@ namespace osu.Game.Tests.Scores.IO 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); + var secondImport = await LoadScoreIntoOsu(osu, imported); Assert.That(secondImport, Is.Null); } finally @@ -163,7 +163,7 @@ namespace osu.Game.Tests.Scores.IO { var osu = LoadOsuIntoHost(host, true); - await loadScoreIntoOsu(osu, new ScoreInfo { OnlineScoreID = 2 }, new TestArchiveReader()); + await LoadScoreIntoOsu(osu, new ScoreInfo { OnlineScoreID = 2 }, new TestArchiveReader()); var scoreManager = osu.Dependencies.Get(); @@ -177,7 +177,7 @@ namespace osu.Game.Tests.Scores.IO } } - private async Task loadScoreIntoOsu(OsuGameBase osu, ScoreInfo score, ArchiveReader archive = null) + public static async Task LoadScoreIntoOsu(OsuGameBase osu, ScoreInfo score, ArchiveReader archive = null) { var beatmapManager = osu.Dependencies.Get(); @@ -190,7 +190,7 @@ namespace osu.Game.Tests.Scores.IO return scoreManager.GetAllUsableScores().FirstOrDefault(); } - private class TestArchiveReader : ArchiveReader + internal class TestArchiveReader : ArchiveReader { public TestArchiveReader() : base("test_archive") diff --git a/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs b/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.cs new file mode 100644 index 0000000000..71544e94f3 --- /dev/null +++ b/osu.Game.Tests/Skins/TestSceneBeatmapSkinLookupDisables.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; +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.Configuration; +using osu.Game.Rulesets.Osu; +using osu.Game.Skinning; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Skins +{ + [TestFixture] + [HeadlessTest] + public class TestSceneBeatmapSkinLookupDisables : OsuTestScene + { + private UserSkinSource userSource; + private BeatmapSkinSource beatmapSource; + private SkinRequester requester; + + [Resolved] + private OsuConfigManager config { get; set; } + + [SetUp] + public void SetUp() => Schedule(() => + { + Add(new SkinProvidingContainer(userSource = new UserSkinSource()) + .WithChild(new BeatmapSkinProvidingContainer(beatmapSource = new BeatmapSkinSource()) + .WithChild(requester = new SkinRequester()))); + }); + + [TestCase(false)] + [TestCase(true)] + public void TestDrawableLookup(bool allowBeatmapLookups) + { + AddStep($"Set beatmap skin enabled to {allowBeatmapLookups}", () => config.SetValue(OsuSetting.BeatmapSkins, allowBeatmapLookups)); + + string expected = allowBeatmapLookups ? "beatmap" : "user"; + + AddAssert($"Check lookup is from {expected}", () => requester.GetDrawableComponent(new TestSkinComponent())?.Name == expected); + } + + [TestCase(false)] + [TestCase(true)] + public void TestProviderLookup(bool allowBeatmapLookups) + { + AddStep($"Set beatmap skin enabled to {allowBeatmapLookups}", () => config.SetValue(OsuSetting.BeatmapSkins, allowBeatmapLookups)); + + ISkin expected() => allowBeatmapLookups ? (ISkin)beatmapSource : userSource; + + AddAssert("Check lookup is from correct source", () => requester.FindProvider(s => s.GetDrawableComponent(new TestSkinComponent()) != null) == expected()); + } + + public class UserSkinSource : LegacySkin + { + public UserSkinSource() + : base(new SkinInfo(), null, null, string.Empty) + { + } + + public override Drawable GetDrawableComponent(ISkinComponent component) + { + return new Container { Name = "user" }; + } + } + + public class BeatmapSkinSource : LegacyBeatmapSkin + { + public BeatmapSkinSource() + : base(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo, null, null) + { + } + + public override Drawable GetDrawableComponent(ISkinComponent component) + { + return new Container { Name = "beatmap" }; + } + } + + 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 ISample GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); + + public IBindable GetConfig(TLookup lookup) => skin.GetConfig(lookup); + + public ISkin FindProvider(Func lookupFunction) => skin.FindProvider(lookupFunction); + } + + private class TestSkinComponent : ISkinComponent + { + public string LookupName => string.Empty; + } + } +} diff --git a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs index 732a3f3f42..c15d804a19 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinConfigurationLookup.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.IO; using System.Linq; @@ -222,6 +223,8 @@ namespace osu.Game.Tests.Skins public ISample GetSample(ISampleInfo sampleInfo) => skin.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => skin.GetConfig(lookup); + + public ISkin FindProvider(Func lookupFunction) => skin.FindProvider(lookupFunction); } } } diff --git a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs new file mode 100644 index 0000000000..ab47067411 --- /dev/null +++ b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.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 System.Linq; +using NUnit.Framework; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; +using osu.Framework.Testing; +using osu.Game.Audio; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Skins +{ + [HeadlessTest] + public class TestSceneSkinProvidingContainer : OsuTestScene + { + /// + /// Ensures that the first inserted skin after resetting (via source change) + /// is always prioritised over others when providing the same resource. + /// + [Test] + public void TestPriorityPreservation() + { + TestSkinProvidingContainer provider = null; + TestSkin mostPrioritisedSource = null; + + AddStep("setup sources", () => + { + var sources = new List(); + for (int i = 0; i < 10; i++) + sources.Add(new TestSkin()); + + mostPrioritisedSource = sources.First(); + + Child = provider = new TestSkinProvidingContainer(sources); + }); + + AddAssert("texture provided by expected skin", () => + { + return provider.FindProvider(s => s.GetTexture(TestSkin.TEXTURE_NAME) != null) == mostPrioritisedSource; + }); + + AddStep("trigger source change", () => provider.TriggerSourceChanged()); + + AddAssert("texture still provided by expected skin", () => + { + return provider.FindProvider(s => s.GetTexture(TestSkin.TEXTURE_NAME) != null) == mostPrioritisedSource; + }); + } + + private class TestSkinProvidingContainer : SkinProvidingContainer + { + private readonly IEnumerable sources; + + public TestSkinProvidingContainer(IEnumerable sources) + { + this.sources = sources; + } + + public new void TriggerSourceChanged() => base.TriggerSourceChanged(); + + protected override void OnSourceChanged() + { + ResetSources(); + sources.ForEach(AddSource); + } + } + + private class TestSkin : ISkin + { + public const string TEXTURE_NAME = "virtual-texture"; + + public Drawable GetDrawableComponent(ISkinComponent component) => throw new System.NotImplementedException(); + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + if (componentName == TEXTURE_NAME) + return Texture.WhitePixel; + + return null; + } + + public ISample GetSample(ISampleInfo sampleInfo) => throw new System.NotImplementedException(); + + public IBindable GetConfig(TLookup lookup) => throw new System.NotImplementedException(); + } + } +} diff --git a/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs index 97087e31ab..8c6932e792 100644 --- a/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs +++ b/osu.Game.Tests/Testing/TestSceneRulesetDependencies.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Configuration.Tracking; +using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Testing; @@ -45,6 +46,14 @@ namespace osu.Game.Tests.Testing Dependencies.Get().Get(@"test-sample") != null); } + [Test] + public void TestRetrieveShader() + { + AddAssert("ruleset shaders retrieved", () => + Dependencies.Get().LoadRaw(@"sh_TestVertex.vs") != null && + Dependencies.Get().LoadRaw(@"sh_TestFragment.fs") != null); + } + [Test] public void TestResolveConfigManager() { @@ -52,7 +61,7 @@ namespace osu.Game.Tests.Testing Dependencies.Get() != null); } - private class TestRuleset : Ruleset + public class TestRuleset : Ruleset { public override string Description => string.Empty; public override string ShortName => string.Empty; diff --git a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs new file mode 100644 index 0000000000..f7d42a2ee6 --- /dev/null +++ b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Online.API; +using osu.Game.Screens; +using osu.Game.Screens.Backgrounds; +using osu.Game.Skinning; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Background +{ + [TestFixture] + public class TestSceneBackgroundScreenDefault : OsuTestScene + { + private BackgroundScreenStack stack; + private BackgroundScreenDefault screen; + + private Graphics.Backgrounds.Background getCurrentBackground() => screen.ChildrenOfType().FirstOrDefault(); + + [Resolved] + private SkinManager skins { get; set; } + + [Resolved] + private OsuConfigManager config { get; set; } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create background stack", () => Child = stack = new BackgroundScreenStack()); + AddStep("push default screen", () => stack.Push(screen = new BackgroundScreenDefault(false))); + AddUntilStep("wait for screen to load", () => screen.IsCurrentScreen()); + } + + [Test] + public void TestBackgroundTypeSwitch() + { + setSupporter(true); + + setSourceMode(BackgroundSource.Beatmap); + AddUntilStep("is beatmap background", () => getCurrentBackground() is BeatmapBackground); + + setSourceMode(BackgroundSource.BeatmapWithStoryboard); + AddUntilStep("is storyboard background", () => getCurrentBackground() is BeatmapBackgroundWithStoryboard); + + setSourceMode(BackgroundSource.Skin); + AddUntilStep("is default background", () => getCurrentBackground().GetType() == typeof(Graphics.Backgrounds.Background)); + + setCustomSkin(); + AddUntilStep("is skin background", () => getCurrentBackground() is SkinBackground); + } + + [Test] + public void TestTogglingSupporterTogglesBeatmapBackground() + { + setSourceMode(BackgroundSource.Beatmap); + + setSupporter(true); + AddUntilStep("is beatmap background", () => getCurrentBackground() is BeatmapBackground); + + setSupporter(false); + AddUntilStep("is default background", () => !(getCurrentBackground() is BeatmapBackground)); + + setSupporter(true); + AddUntilStep("is beatmap background", () => getCurrentBackground() is BeatmapBackground); + } + + [TestCase(BackgroundSource.Beatmap, typeof(BeatmapBackground))] + [TestCase(BackgroundSource.BeatmapWithStoryboard, typeof(BeatmapBackgroundWithStoryboard))] + [TestCase(BackgroundSource.Skin, typeof(SkinBackground))] + public void TestBackgroundDoesntReloadOnNoChange(BackgroundSource source, Type backgroundType) + { + Graphics.Backgrounds.Background last = null; + + setSourceMode(source); + setSupporter(true); + if (source == BackgroundSource.Skin) + setCustomSkin(); + + AddUntilStep("wait for beatmap background to be loaded", () => (last = getCurrentBackground())?.GetType() == backgroundType); + AddAssert("next doesn't load new background", () => screen.Next() == false); + + // doesn't really need to be checked but might as well. + AddWaitStep("wait a bit", 5); + AddUntilStep("ensure same background instance", () => last == getCurrentBackground()); + } + + [Test] + public void TestBackgroundCyclingOnDefaultSkin([Values] bool supporter) + { + Graphics.Backgrounds.Background last = null; + + setSourceMode(BackgroundSource.Skin); + setSupporter(supporter); + setDefaultSkin(); + + AddUntilStep("wait for beatmap background to be loaded", () => (last = getCurrentBackground())?.GetType() == typeof(Graphics.Backgrounds.Background)); + AddAssert("next cycles background", () => screen.Next()); + + // doesn't really need to be checked but might as well. + AddWaitStep("wait a bit", 5); + AddUntilStep("ensure different background instance", () => last != getCurrentBackground()); + } + + private void setSourceMode(BackgroundSource source) => + AddStep($"set background mode to {source}", () => config.SetValue(OsuSetting.MenuBackgroundSource, source)); + + private void setSupporter(bool isSupporter) => + AddStep($"set supporter {isSupporter}", () => ((DummyAPIAccess)API).LocalUser.Value = new User + { + IsSupporter = isSupporter, + Id = API.LocalUser.Value.Id + 1, + }); + + private void setCustomSkin() + { + // feign a skin switch. this doesn't do anything except force CurrentSkin to become a LegacySkin. + AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo { ID = 5 }); + } + + private void setDefaultSkin() => AddStep("set default skin", () => skins.CurrentSkinInfo.SetDefault()); + + [TearDownSteps] + public void TearDown() => setDefaultSkin(); + } +} diff --git a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs index dc5a4f4a3e..0bd1263076 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneSeasonalBackgroundLoader.cs @@ -161,15 +161,18 @@ namespace osu.Game.Tests.Visual.Background private void loadNextBackground() { + SeasonalBackground previousBackground = null; SeasonalBackground background = null; AddStep("create next background", () => { + previousBackground = (SeasonalBackground)backgroundContainer.SingleOrDefault(); background = backgroundLoader.LoadNextBackground(); LoadComponentAsync(background, bg => backgroundContainer.Child = bg); }); AddUntilStep("background loaded", () => background.IsLoaded); + AddAssert("background is different", () => !background.Equals(previousBackground)); } private void assertAnyBackground() diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index 07162c3cd1..b6ae91844a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -55,7 +55,12 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestExitWithoutSave() { - AddStep("exit without save", () => Editor.Exit()); + AddStep("exit without save", () => + { + Editor.Exit(); + DialogOverlay.CurrentDialog.PerformOkAction(); + }); + AddUntilStep("wait for exit", () => !Editor.IsCurrentScreen()); AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID)?.DeletePending == true); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs index 0b52ae2b95..028509ccd4 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs @@ -13,8 +13,8 @@ namespace osu.Game.Tests.Visual.Editing { public TestSceneEditorComposeRadioButtons() { - RadioButtonCollection collection; - Add(collection = new RadioButtonCollection + EditorRadioButtonCollection collection; + Add(collection = new EditorRadioButtonCollection { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index 7ca24346aa..550896270a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -2,17 +2,24 @@ // 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.Timing; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; 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.Objects; using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components.RadioButtons; +using osu.Game.Screens.Edit.Compose.Components; using osuTK; namespace osu.Game.Tests.Visual.Editing @@ -20,37 +27,89 @@ namespace osu.Game.Tests.Visual.Editing [TestFixture] public class TestSceneHitObjectComposer : EditorClockTestScene { - [BackgroundDependencyLoader] - private void load() + private OsuHitObjectComposer hitObjectComposer; + private EditorBeatmapContainer editorBeatmapContainer; + + private EditorBeatmap editorBeatmap => editorBeatmapContainer.EditorBeatmap; + + [SetUpSteps] + public void SetUpSteps() { - Beatmap.Value = CreateWorkingBeatmap(new Beatmap + AddStep("create beatmap", () => { - HitObjects = new List + Beatmap.Value = CreateWorkingBeatmap(new Beatmap { - new HitCircle { Position = new Vector2(256, 192), Scale = 0.5f }, - new HitCircle { Position = new Vector2(344, 148), Scale = 0.5f }, - new Slider + HitObjects = new List { - Position = new Vector2(128, 256), - Path = new SliderPath(PathType.Linear, new[] + new HitCircle { Position = new Vector2(256, 192), Scale = 0.5f }, + new HitCircle { Position = new Vector2(344, 148), Scale = 0.5f }, + new Slider { - Vector2.Zero, - new Vector2(216, 0), - }), - Scale = 0.5f, - } - }, + Position = new Vector2(128, 256), + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(216, 0), + }), + Scale = 0.5f, + } + }, + }); }); - var editorBeatmap = new EditorBeatmap(Beatmap.Value.GetPlayableBeatmap(new OsuRuleset().RulesetInfo)); + AddStep("Create composer", () => + { + Child = editorBeatmapContainer = new EditorBeatmapContainer(Beatmap.Value) + { + Child = hitObjectComposer = new OsuHitObjectComposer(new OsuRuleset()) + }; + }); + } - var clock = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; - Dependencies.CacheAs(clock); - Dependencies.CacheAs(clock); - Dependencies.CacheAs(editorBeatmap); - Dependencies.CacheAs(editorBeatmap); + [Test] + public void TestPlacementOnlyWorksWithTiming() + { + AddStep("clear all control points", () => editorBeatmap.ControlPointInfo.Clear()); - Child = new OsuHitObjectComposer(new OsuRuleset()); + AddAssert("Tool is selection", () => hitObjectComposer.ChildrenOfType().First().CurrentTool is SelectTool); + AddAssert("Hitcircle button not clickable", () => !hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").Enabled.Value); + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + AddAssert("Hitcircle button is clickable", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").Enabled.Value); + AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").Click()); + AddAssert("Tool changed", () => hitObjectComposer.ChildrenOfType().First().CurrentTool is HitCircleCompositionTool); + } + + public class EditorBeatmapContainer : Container + { + private readonly WorkingBeatmap working; + + public EditorBeatmap EditorBeatmap { get; private set; } + + public EditorBeatmapContainer(WorkingBeatmap working) + { + this.working = working; + + RelativeSizeAxes = Axes.Both; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + EditorBeatmap = new EditorBeatmap(working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo)); + + dependencies.CacheAs(EditorBeatmap); + dependencies.CacheAs(EditorBeatmap); + + return dependencies; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Add(EditorBeatmap); + } } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.cs new file mode 100644 index 0000000000..19081f3281 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneMetadataSection.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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Setup; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneMetadataSection : OsuTestScene + { + [Cached] + private EditorBeatmap editorBeatmap = new EditorBeatmap(new Beatmap()); + + private TestMetadataSection metadataSection; + + [Test] + public void TestMinimalMetadata() + { + AddStep("set metadata", () => + { + editorBeatmap.Metadata.Artist = "Example Artist"; + editorBeatmap.Metadata.ArtistUnicode = null; + + editorBeatmap.Metadata.Title = "Example Title"; + editorBeatmap.Metadata.TitleUnicode = null; + }); + + createSection(); + + assertArtist("Example Artist"); + assertRomanisedArtist("Example Artist", false); + + assertTitle("Example Title"); + assertRomanisedTitle("Example Title", false); + } + + [Test] + public void TestInitialisationFromNonRomanisedVariant() + { + AddStep("set metadata", () => + { + editorBeatmap.Metadata.ArtistUnicode = "*なみりん"; + editorBeatmap.Metadata.Artist = null; + + editorBeatmap.Metadata.TitleUnicode = "コイシテイク・プラネット"; + editorBeatmap.Metadata.Title = null; + }); + + createSection(); + + assertArtist("*なみりん"); + assertRomanisedArtist(string.Empty, true); + + assertTitle("コイシテイク・プラネット"); + assertRomanisedTitle(string.Empty, true); + } + + [Test] + public void TestInitialisationPreservesOriginalValues() + { + AddStep("set metadata", () => + { + editorBeatmap.Metadata.ArtistUnicode = "*なみりん"; + editorBeatmap.Metadata.Artist = "*namirin"; + + editorBeatmap.Metadata.TitleUnicode = "コイシテイク・プラネット"; + editorBeatmap.Metadata.Title = "Koishiteiku Planet"; + }); + + createSection(); + + assertArtist("*なみりん"); + assertRomanisedArtist("*namirin", true); + + assertTitle("コイシテイク・プラネット"); + assertRomanisedTitle("Koishiteiku Planet", true); + } + + [Test] + public void TestValueTransfer() + { + AddStep("set metadata", () => + { + editorBeatmap.Metadata.ArtistUnicode = "*なみりん"; + editorBeatmap.Metadata.Artist = null; + + editorBeatmap.Metadata.TitleUnicode = "コイシテイク・プラネット"; + editorBeatmap.Metadata.Title = null; + }); + + createSection(); + + AddStep("set romanised artist name", () => metadataSection.ArtistTextBox.Current.Value = "*namirin"); + assertArtist("*namirin"); + assertRomanisedArtist("*namirin", false); + + AddStep("set native artist name", () => metadataSection.ArtistTextBox.Current.Value = "*なみりん"); + assertArtist("*なみりん"); + assertRomanisedArtist("*namirin", true); + + AddStep("set romanised title", () => metadataSection.TitleTextBox.Current.Value = "Hitokoto no kyori"); + assertTitle("Hitokoto no kyori"); + assertRomanisedTitle("Hitokoto no kyori", false); + + AddStep("set native title", () => metadataSection.TitleTextBox.Current.Value = "ヒトコトの距離"); + assertTitle("ヒトコトの距離"); + assertRomanisedTitle("Hitokoto no kyori", true); + } + + private void createSection() + => AddStep("create metadata section", () => Child = metadataSection = new TestMetadataSection()); + + private void assertArtist(string expected) + => AddAssert($"artist is {expected}", () => metadataSection.ArtistTextBox.Current.Value == expected); + + private void assertRomanisedArtist(string expected, bool editable) + { + AddAssert($"romanised artist is {expected}", () => metadataSection.RomanisedArtistTextBox.Current.Value == expected); + AddAssert($"romanised artist is {(editable ? "" : "not ")}editable", () => metadataSection.RomanisedArtistTextBox.ReadOnly == !editable); + } + + private void assertTitle(string expected) + => AddAssert($"title is {expected}", () => metadataSection.TitleTextBox.Current.Value == expected); + + private void assertRomanisedTitle(string expected, bool editable) + { + AddAssert($"romanised title is {expected}", () => metadataSection.RomanisedTitleTextBox.Current.Value == expected); + AddAssert($"romanised title is {(editable ? "" : "not ")}editable", () => metadataSection.RomanisedTitleTextBox.ReadOnly == !editable); + } + + private class TestMetadataSection : MetadataSection + { + public new LabelledTextBox ArtistTextBox => base.ArtistTextBox; + public new LabelledTextBox RomanisedArtistTextBox => base.RomanisedArtistTextBox; + + public new LabelledTextBox TitleTextBox => base.TitleTextBox; + public new LabelledTextBox RomanisedTitleTextBox => base.RomanisedTitleTextBox; + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index e47c782bca..fdc3916c47 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -11,19 +11,18 @@ 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 : TestSceneAllRulesetPlayers { - protected new TestPlayer Player => (TestPlayer)base.Player; + protected new TestReplayPlayer Player => (TestReplayPlayer)base.Player; protected override Player CreatePlayer(Ruleset ruleset) { SelectedMods.Value = new[] { ruleset.GetAutoplayMod() }; - return new TestPlayer(false); + return new TestReplayPlayer(false); } protected override void AddCheckSteps() @@ -36,18 +35,6 @@ namespace osu.Game.Tests.Visual.Gameplay 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); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs index cc53e50884..13e84e335d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs @@ -116,12 +116,12 @@ namespace osu.Game.Tests.Visual.Gameplay private class TestOsuRuleset : OsuRuleset { - public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new TestOsuLegacySkinTransformer(source); + public override ISkin CreateLegacySkinProvider(ISkin skin, IBeatmap beatmap) => new TestOsuLegacySkinTransformer(skin); private class TestOsuLegacySkinTransformer : OsuLegacySkinTransformer { - public TestOsuLegacySkinTransformer(ISkinSource source) - : base(source) + public TestOsuLegacySkinTransformer(ISkin skin) + : base(skin) { } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs index 4ee48fd853..11bd701e19 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneCompletionCancellation.cs @@ -114,11 +114,6 @@ namespace osu.Game.Tests.Visual.Gameplay { public bool ResultsCreated { get; private set; } - public FakeRankingPushPlayer() - : base(true, true) - { - } - protected override ResultsScreen CreateResults(ScoreInfo score) { var results = base.CreateResults(score); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs index d69ac665cc..ed40a83831 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs @@ -87,7 +87,7 @@ namespace osu.Game.Tests.Visual.Gameplay showOverlay(); AddStep("Up arrow", () => InputManager.Key(Key.Up)); - AddAssert("Last button selected", () => pauseOverlay.Buttons.Last().Selected.Value); + AddAssert("Last button selected", () => pauseOverlay.Buttons.Last().State == SelectionState.Selected); } /// @@ -99,7 +99,7 @@ namespace osu.Game.Tests.Visual.Gameplay showOverlay(); AddStep("Down arrow", () => InputManager.Key(Key.Down)); - AddAssert("First button selected", () => getButton(0).Selected.Value); + AddAssert("First button selected", () => getButton(0).State == SelectionState.Selected); } /// @@ -111,11 +111,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("Show overlay", () => failOverlay.Show()); AddStep("Up arrow", () => InputManager.Key(Key.Up)); - AddAssert("Last button selected", () => failOverlay.Buttons.Last().Selected.Value); + AddAssert("Last button selected", () => failOverlay.Buttons.Last().State == SelectionState.Selected); AddStep("Up arrow", () => InputManager.Key(Key.Up)); - AddAssert("First button selected", () => failOverlay.Buttons.First().Selected.Value); + AddAssert("First button selected", () => failOverlay.Buttons.First().State == SelectionState.Selected); AddStep("Up arrow", () => InputManager.Key(Key.Up)); - AddAssert("Last button selected", () => failOverlay.Buttons.Last().Selected.Value); + AddAssert("Last button selected", () => failOverlay.Buttons.Last().State == SelectionState.Selected); } /// @@ -127,11 +127,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("Show overlay", () => failOverlay.Show()); AddStep("Down arrow", () => InputManager.Key(Key.Down)); - AddAssert("First button selected", () => failOverlay.Buttons.First().Selected.Value); + AddAssert("First button selected", () => failOverlay.Buttons.First().State == SelectionState.Selected); AddStep("Down arrow", () => InputManager.Key(Key.Down)); - AddAssert("Last button selected", () => failOverlay.Buttons.Last().Selected.Value); + AddAssert("Last button selected", () => failOverlay.Buttons.Last().State == SelectionState.Selected); AddStep("Down arrow", () => InputManager.Key(Key.Down)); - AddAssert("First button selected", () => failOverlay.Buttons.First().Selected.Value); + AddAssert("First button selected", () => failOverlay.Buttons.First().State == SelectionState.Selected); } /// @@ -145,7 +145,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("Hover first button", () => InputManager.MoveMouseTo(failOverlay.Buttons.First())); AddStep("Hide overlay", () => failOverlay.Hide()); - AddAssert("Overlay state is reset", () => !failOverlay.Buttons.Any(b => b.Selected.Value)); + AddAssert("Overlay state is reset", () => failOverlay.Buttons.All(b => b.State == SelectionState.NotSelected)); } /// @@ -162,11 +162,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("Hide overlay", () => pauseOverlay.Hide()); showOverlay(); - AddAssert("First button not selected", () => !getButton(0).Selected.Value); + AddAssert("First button not selected", () => getButton(0).State == SelectionState.NotSelected); AddStep("Move slightly", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(1))); - AddAssert("First button selected", () => getButton(0).Selected.Value); + AddAssert("First button selected", () => getButton(0).State == SelectionState.Selected); } /// @@ -179,8 +179,8 @@ namespace osu.Game.Tests.Visual.Gameplay 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); + AddAssert("First button not selected", () => getButton(0).State == SelectionState.NotSelected); + AddAssert("Second button selected", () => getButton(1).State == SelectionState.Selected); } /// @@ -196,8 +196,8 @@ namespace osu.Game.Tests.Visual.Gameplay 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); + AddAssert("Second button not selected", () => getButton(1).State == SelectionState.NotSelected); + AddAssert("First button selected", () => getButton(0).State == SelectionState.Selected); } /// @@ -211,7 +211,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("Hover second button", () => InputManager.MoveMouseTo(getButton(1))); AddStep("Unhover second button", () => InputManager.MoveMouseTo(Vector2.Zero)); AddStep("Down arrow", () => InputManager.Key(Key.Down)); - AddAssert("First button selected", () => getButton(0).Selected.Value); // Initial state condition + AddAssert("First button selected", () => getButton(0).State == SelectionState.Selected); // Initial state condition } /// @@ -282,7 +282,7 @@ namespace osu.Game.Tests.Visual.Gameplay showOverlay(); AddAssert("No button selected", - () => pauseOverlay.Buttons.All(button => !button.Selected.Value)); + () => pauseOverlay.Buttons.All(button => button.State == SelectionState.NotSelected)); } private void showOverlay() => AddStep("Show overlay", () => pauseOverlay.Show()); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index 2c5443fe08..7accaef818 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -4,14 +4,15 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +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.Framework.Threading; using osu.Framework.Utils; using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets.Catch.Scoring; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; @@ -29,15 +30,22 @@ namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneHitErrorMeter : OsuTestScene { - [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(); + [Cached(typeof(ScoreProcessor))] + private TestScoreProcessor scoreProcessor = new TestScoreProcessor(); [Cached(typeof(DrawableRuleset))] private TestDrawableRuleset drawableRuleset = new TestDrawableRuleset(); - public TestSceneHitErrorMeter() + [SetUpSteps] + public void SetUp() { - recreateDisplay(new OsuHitWindows(), 5); + AddStep("reset score processor", () => scoreProcessor.Reset()); + } + + [Test] + public void TestBasic() + { + AddStep("create display", () => recreateDisplay(new OsuHitWindows(), 5)); AddRepeatStep("New random judgement", () => newJudgement(), 40); @@ -45,12 +53,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddRepeatStep("New max positive", () => newJudgement(drawableRuleset.HitWindows.WindowFor(HitResult.Meh)), 20); AddStep("New fixed judgement (50ms)", () => newJudgement(50)); + ScheduledDelegate del = null; AddStep("Judgement barrage", () => { int runCount = 0; - ScheduledDelegate del = null; - del = Scheduler.AddDelayed(() => { newJudgement(runCount++ / 10f); @@ -60,6 +67,7 @@ namespace osu.Game.Tests.Visual.Gameplay del?.Cancel(); }, 10, true); }); + AddUntilStep("wait for barrage", () => del.Cancelled); } [Test] @@ -84,10 +92,49 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] - public void TestCatch() + public void TestEmpty() { - AddStep("OD 1", () => recreateDisplay(new CatchHitWindows(), 1)); - AddStep("OD 10", () => recreateDisplay(new CatchHitWindows(), 10)); + AddStep("empty windows", () => recreateDisplay(HitWindows.Empty, 5)); + + AddStep("hit", () => newJudgement()); + AddAssert("no bars added", () => !this.ChildrenOfType().Any()); + AddAssert("circle added", () => + this.ChildrenOfType().All( + meter => meter.ChildrenOfType().Count() == 1)); + + AddStep("miss", () => newJudgement(50, HitResult.Miss)); + AddAssert("no bars added", () => !this.ChildrenOfType().Any()); + AddAssert("circle added", () => + this.ChildrenOfType().All( + meter => meter.ChildrenOfType().Count() == 2)); + } + + [Test] + public void TestBonus() + { + AddStep("OD 1", () => recreateDisplay(new OsuHitWindows(), 1)); + + AddStep("small bonus", () => newJudgement(result: HitResult.SmallBonus)); + AddAssert("no bars added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + + AddStep("large bonus", () => newJudgement(result: HitResult.LargeBonus)); + AddAssert("no bars added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + } + + [Test] + public void TestIgnore() + { + AddStep("OD 1", () => recreateDisplay(new OsuHitWindows(), 1)); + + AddStep("ignore hit", () => newJudgement(result: HitResult.IgnoreHit)); + AddAssert("no bars added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); + + AddStep("ignore miss", () => newJudgement(result: HitResult.IgnoreMiss)); + AddAssert("no bars added", () => !this.ChildrenOfType().Any()); + AddAssert("no circle added", () => !this.ChildrenOfType().Any()); } private void recreateDisplay(HitWindows hitWindows, float overallDifficulty) @@ -154,12 +201,12 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private void newJudgement(double offset = 0) + private void newJudgement(double offset = 0, HitResult result = HitResult.Perfect) { scoreProcessor.ApplyResult(new JudgementResult(new HitCircle { HitWindows = drawableRuleset.HitWindows }, new Judgement()) { TimeOffset = offset == 0 ? RNG.Next(-150, 150) : offset, - Type = HitResult.Perfect, + Type = result, }); } @@ -177,6 +224,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override Container Overlays { get; } public override Container FrameStableComponents { get; } public override IFrameStableClock FrameStableClock { get; } + internal override bool FrameStablePlayback { get; set; } public override IReadOnlyList Mods { get; } public override double GameplayStartTime { get; } @@ -198,5 +246,10 @@ namespace osu.Game.Tests.Visual.Gameplay public override void CancelResume() => throw new NotImplementedException(); } + + private class TestScoreProcessor : ScoreProcessor + { + public void Reset() => base.Reset(false); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs new file mode 100644 index 0000000000..5ff2e9c439 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs @@ -0,0 +1,246 @@ +// Copyright (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.Screens; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; +using osu.Game.Online.Solo; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Ranking; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestScenePlayerScoreSubmission : PlayerTestScene + { + protected override bool AllowFail => allowFail; + + private bool allowFail; + + private Func createCustomBeatmap; + private Func createCustomRuleset; + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + protected override bool HasCustomSteps => true; + + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(false); + + protected override Ruleset CreatePlayerRuleset() => createCustomRuleset?.Invoke() ?? new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => createCustomBeatmap?.Invoke(ruleset) ?? createTestBeatmap(ruleset); + + private IBeatmap createTestBeatmap(RulesetInfo ruleset) + { + var beatmap = (TestBeatmap)base.CreateBeatmap(ruleset); + + beatmap.HitObjects = beatmap.HitObjects.Take(10).ToList(); + + return beatmap; + } + + [Test] + public void TestNoSubmissionOnResultsWithNoToken() + { + prepareTokenResponse(false); + + createPlayerTest(); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + addFakeHit(); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + + AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); + + AddAssert("ensure no submission", () => Player.SubmittedScore == null); + } + + [Test] + public void TestSubmissionOnResults() + { + prepareTokenResponse(true); + + createPlayerTest(); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + addFakeHit(); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + + AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); + AddAssert("ensure passing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == true); + } + + [Test] + public void TestNoSubmissionOnExitWithNoToken() + { + prepareTokenResponse(false); + + createPlayerTest(); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + addFakeHit(); + + AddStep("exit", () => Player.Exit()); + AddAssert("ensure no submission", () => Player.SubmittedScore == null); + } + + [Test] + public void TestNoSubmissionOnEmptyFail() + { + prepareTokenResponse(true); + + createPlayerTest(true); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for fail", () => Player.HasFailed); + AddStep("exit", () => Player.Exit()); + + AddAssert("ensure no submission", () => Player.SubmittedScore == null); + } + + [Test] + public void TestSubmissionOnFail() + { + prepareTokenResponse(true); + + createPlayerTest(true); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + addFakeHit(); + + AddUntilStep("wait for fail", () => Player.HasFailed); + AddStep("exit", () => Player.Exit()); + + AddAssert("ensure failing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == false); + } + + [Test] + public void TestNoSubmissionOnEmptyExit() + { + prepareTokenResponse(true); + + createPlayerTest(); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddStep("exit", () => Player.Exit()); + AddAssert("ensure no submission", () => Player.SubmittedScore == null); + } + + [Test] + public void TestSubmissionOnExit() + { + prepareTokenResponse(true); + + createPlayerTest(); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + addFakeHit(); + + AddStep("exit", () => Player.Exit()); + AddAssert("ensure failing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == false); + } + + [Test] + public void TestNoSubmissionOnLocalBeatmap() + { + prepareTokenResponse(true); + + createPlayerTest(false, r => + { + var beatmap = createTestBeatmap(r); + beatmap.BeatmapInfo.OnlineBeatmapID = null; + return beatmap; + }); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + addFakeHit(); + + AddStep("exit", () => Player.Exit()); + AddAssert("ensure no submission", () => Player.SubmittedScore == null); + } + + [Test] + public void TestNoSubmissionOnCustomRuleset() + { + prepareTokenResponse(true); + + createPlayerTest(false, createRuleset: () => new OsuRuleset { RulesetInfo = { ID = 10 } }); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + addFakeHit(); + + AddStep("exit", () => Player.Exit()); + AddAssert("ensure no submission", () => Player.SubmittedScore == null); + } + + private void createPlayerTest(bool allowFail = false, Func createBeatmap = null, Func createRuleset = null) + { + CreateTest(() => AddStep("set up requirements", () => + { + this.allowFail = allowFail; + createCustomBeatmap = createBeatmap; + createCustomRuleset = createRuleset; + })); + } + + private void prepareTokenResponse(bool validToken) + { + AddStep("Prepare test API", () => + { + dummyAPI.HandleRequest = request => + { + switch (request) + { + case CreateSoloScoreRequest tokenRequest: + if (validToken) + tokenRequest.TriggerSuccess(new APIScoreToken { ID = 1234 }); + else + tokenRequest.TriggerFailure(new APIException("something went wrong!", null)); + return true; + } + + return false; + }; + }); + } + + private void addFakeHit() + { + AddUntilStep("wait for first result", () => Player.Results.Count > 0); + + AddStep("force successfuly hit", () => + { + Player.ScoreProcessor.RevertResult(Player.Results.First()); + Player.ScoreProcessor.ApplyResult(new OsuJudgementResult(Beatmap.Value.Beatmap.HitObjects.First(), new OsuJudgement()) + { + Type = HitResult.Great, + }); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs new file mode 100644 index 0000000000..fcd65eaff3 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayPlayer.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneReplayPlayer : RateAdjustedBeatmapTestScene + { + protected TestReplayPlayer Player; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("Initialise player", () => Player = CreatePlayer(new OsuRuleset())); + AddStep("Load player", () => LoadScreen(Player)); + AddUntilStep("player loaded", () => Player.IsLoaded); + } + + [Test] + public void TestPause() + { + double? lastTime = null; + + AddUntilStep("wait for first hit", () => Player.ScoreProcessor.TotalScore.Value > 0); + + AddStep("Pause playback", () => InputManager.Key(Key.Space)); + + AddUntilStep("Time stopped progressing", () => + { + double current = Player.GameplayClockContainer.CurrentTime; + bool changed = lastTime != current; + lastTime = current; + + return !changed; + }); + + AddWaitStep("wait some", 10); + + AddAssert("Time still stopped", () => lastTime == Player.GameplayClockContainer.CurrentTime); + } + + [Test] + public void TestSeekBackwards() + { + double? lastTime = null; + + AddUntilStep("wait for first hit", () => Player.ScoreProcessor.TotalScore.Value > 0); + + AddStep("Seek backwards", () => + { + lastTime = Player.GameplayClockContainer.CurrentTime; + InputManager.Key(Key.Left); + }); + + AddAssert("Jumped backwards", () => Player.GameplayClockContainer.CurrentTime - lastTime < 0); + } + + [Test] + public void TestSeekForwards() + { + double? lastTime = null; + + AddUntilStep("wait for first hit", () => Player.ScoreProcessor.TotalScore.Value > 0); + + AddStep("Seek forwards", () => + { + lastTime = Player.GameplayClockContainer.CurrentTime; + InputManager.Key(Key.Right); + }); + + AddAssert("Jumped forwards", () => Player.GameplayClockContainer.CurrentTime - lastTime > 500); + } + + protected TestReplayPlayer CreatePlayer(Ruleset ruleset) + { + Beatmap.Value = CreateWorkingBeatmap(ruleset.RulesetInfo); + SelectedMods.Value = new[] { ruleset.GetAutoplayMod() }; + + return new TestReplayPlayer(false); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs index 7a6e2f54c2..3e8ba69e01 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableDrawable.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.Globalization; using System.Linq; using NUnit.Framework; @@ -42,9 +43,9 @@ namespace osu.Game.Tests.Visual.Gameplay 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) + new ExposedSkinnableDrawable("default", _ => new DefaultBox()), + new ExposedSkinnableDrawable("available", _ => new DefaultBox()), + new ExposedSkinnableDrawable("available", _ => new DefaultBox(), ConfineMode.NoScaling) } }, }; @@ -73,9 +74,9 @@ namespace osu.Game.Tests.Visual.Gameplay 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) + new ExposedSkinnableDrawable("default", _ => new DefaultBox()), + new ExposedSkinnableDrawable("available", _ => new DefaultBox()), + new ExposedSkinnableDrawable("available", _ => new DefaultBox(), ConfineMode.NoScaling) } }, }; @@ -100,7 +101,7 @@ namespace osu.Game.Tests.Visual.Gameplay Child = new SkinProvidingContainer(secondarySource) { RelativeSizeAxes = Axes.Both, - Child = consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation"), source => true) + Child = consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation")) } }; }); @@ -129,7 +130,7 @@ namespace osu.Game.Tests.Visual.Gameplay }; }); - AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation"), source => true))); + AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation")))); AddAssert("consumer using override source", () => consumer.Drawable is SecondarySourceBox); AddAssert("skinchanged only called once", () => consumer.SkinChangedCount == 1); } @@ -152,7 +153,7 @@ namespace osu.Game.Tests.Visual.Gameplay }; }); - AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation"), source => true))); + AddStep("add permissive", () => target.Add(consumer = new SkinConsumer("test", name => new NamedBox("Default Implementation")))); AddAssert("consumer using override source", () => consumer.Drawable is SecondarySourceBox); AddStep("disable", () => target.Disable()); AddAssert("consumer using base source", () => consumer.Drawable is BaseSourceBox); @@ -180,9 +181,8 @@ namespace osu.Game.Tests.Visual.Gameplay { 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) + public ExposedSkinnableDrawable(string name, Func defaultImplementation, ConfineMode confineMode = ConfineMode.ScaleToFit) + : base(new TestSkinComponent(name), defaultImplementation, confineMode) { } } @@ -250,14 +250,14 @@ namespace osu.Game.Tests.Visual.Gameplay 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) + public SkinConsumer(string name, Func defaultImplementation) + : base(new TestSkinComponent(name), defaultImplementation) { } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void SkinChanged(ISkinSource skin) { - base.SkinChanged(skin, allowFallback); + base.SkinChanged(skin); SkinChangedCount++; } } @@ -301,6 +301,8 @@ namespace osu.Game.Tests.Visual.Gameplay public ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + + public ISkin FindProvider(Func lookupFunction) => throw new NotImplementedException(); } private class SecondarySource : ISkin @@ -312,6 +314,8 @@ namespace osu.Game.Tests.Visual.Gameplay public ISample GetSample(ISampleInfo sampleInfo) => throw new NotImplementedException(); public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + + public ISkin FindProvider(Func lookupFunction) => throw new NotImplementedException(); } [Cached(typeof(ISkinSource))] @@ -325,6 +329,10 @@ namespace osu.Game.Tests.Visual.Gameplay public IBindable GetConfig(TLookup lookup) => throw new NotImplementedException(); + public ISkin FindProvider(Func lookupFunction) => throw new NotImplementedException(); + + public IEnumerable AllSources => throw new NotImplementedException(); + public event Action SourceChanged { add { } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs index d792405eeb..ccf13e1e8f 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -29,14 +30,13 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("setup hierarchy", () => { - Children = new Drawable[] + Child = skinSource = new TestSkinSourceContainer { - skinSource = new TestSkinSourceContainer - { - RelativeSizeAxes = Axes.Both, - Child = skinnableSound = new PausableSkinnableSound(new SampleInfo("Gameplay/normal-sliderslide")) - }, + RelativeSizeAxes = Axes.Both, }; + + // has to be added after the hierarchy above else the `ISkinSource` dependency won't be cached. + skinSource.Add(skinnableSound = new PausableSkinnableSound(new SampleInfo("Gameplay/normal-sliderslide"))); }); } @@ -147,6 +147,8 @@ namespace osu.Game.Tests.Visual.Gameplay public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => source?.GetTexture(componentName, wrapModeS, wrapModeT); public ISample GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); public IBindable GetConfig(TLookup lookup) => source?.GetConfig(lookup); + public ISkin FindProvider(Func lookupFunction) => lookupFunction(this) ? this : source?.FindProvider(lookupFunction); + public IEnumerable AllSources => new[] { this }.Concat(source?.AllSources ?? Enumerable.Empty()); public void TriggerSourceChanged() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index e9894ff469..7584d67afe 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -2,8 +2,6 @@ // 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; @@ -41,8 +39,6 @@ namespace osu.Game.Tests.Visual.Gameplay [Resolved] private OsuGameBase game { get; set; } - private int nextFrame; - private BeatmapSetInfo importedBeatmap; private int importedBeatmapId; @@ -51,8 +47,6 @@ namespace osu.Game.Tests.Visual.Gameplay { base.SetUpSteps(); - AddStep("reset sent frames", () => nextFrame = 0); - AddStep("import beatmap", () => { importedBeatmap = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result; @@ -76,9 +70,9 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("screen hasn't changed", () => Stack.CurrentScreen is SoloSpectator); start(); - sendFrames(); - waitForPlayer(); + + sendFrames(); AddAssert("ensure frames arrived", () => replayHandler.HasFrames); AddUntilStep("wait for frame starvation", () => replayHandler.WaitingForFrame); @@ -105,7 +99,8 @@ namespace osu.Game.Tests.Visual.Gameplay waitForPlayer(); checkPaused(true); - sendFrames(1000); // send enough frames to ensure play won't be paused + // send enough frames to ensure play won't be paused + sendFrames(100); checkPaused(false); } @@ -114,14 +109,13 @@ namespace osu.Game.Tests.Visual.Gameplay public void TestSpectatingDuringGameplay() { start(); + sendFrames(300); loadSpectatingScreen(); - - AddStep("advance frame count", () => nextFrame = 300); - sendFrames(); - waitForPlayer(); + sendFrames(300); + AddUntilStep("playing from correct point in time", () => player.ChildrenOfType().First().FrameStableClock.CurrentTime > 30000); } @@ -210,7 +204,7 @@ namespace osu.Game.Tests.Visual.Gameplay private double currentFrameStableTime => player.ChildrenOfType().First().FrameStableClock.CurrentTime; - private void waitForPlayer() => AddUntilStep("wait for player", () => Stack.CurrentScreen is Player); + private void waitForPlayer() => AddUntilStep("wait for player", () => (Stack.CurrentScreen as Player)?.IsLoaded == true); private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId)); @@ -221,11 +215,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void sendFrames(int count = 10) { - AddStep("send frames", () => - { - testSpectatorClient.SendFrames(streamingUser.Id, nextFrame, count); - nextFrame += count; - }); + AddStep("send frames", () => testSpectatorClient.SendFrames(streamingUser.Id, count)); } private void loadSpectatingScreen() @@ -233,14 +223,5 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser))); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded); } - - internal class TestUserLookupCache : UserLookupCache - { - protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) => Task.FromResult(new User - { - Id = lookup, - Username = $"User {lookup}" - }); - } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs index 469f594fdc..bb577886cc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -179,7 +179,7 @@ namespace osu.Game.Tests.Visual.Gameplay foreach (var legacyFrame in frames.Frames) { var frame = new TestReplayFrame(); - frame.FromLegacy(legacyFrame, null, null); + frame.FromLegacy(legacyFrame, null); replay.Frames.Add(frame); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index 5ef3eff856..3ed274690e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -66,12 +66,12 @@ namespace osu.Game.Tests.Visual.Gameplay } [Test] - public void TestStoryboardExitToSkipOutro() + public void TestStoryboardExitDuringOutroStillExits() { CreateTest(null); AddUntilStep("completion set by processor", () => Player.ScoreProcessor.HasCompleted.Value); AddStep("exit via pause", () => Player.ExitViaPause()); - AddAssert("score shown", () => Player.IsScoreShown); + AddAssert("player exited", () => !Player.IsCurrentScreen() && Player.GetChildScreen() == null); } [TestCase(false)] diff --git a/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.cs deleted file mode 100644 index c665a57452..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/RoomManagerTestScene.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; -using osu.Framework.Allocation; -using osu.Game.Beatmaps; -using osu.Game.Online.Rooms; -using osu.Game.Rulesets; -using osu.Game.Screens.OnlinePlay; -using osu.Game.Users; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public abstract class RoomManagerTestScene : RoomTestScene - { - [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 deleted file mode 100644 index 1785c99784..0000000000 --- a/osu.Game.Tests/Visual/Multiplayer/TestRoomManager.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 osu.Framework.Bindables; -using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public class TestRoomManager : IRoomManager - { - public event Action RoomsUpdated - { - add { } - remove { } - } - - public readonly BindableList Rooms = new BindableList(); - - public IBindable 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/TestSceneLoungeRoomInfo.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs index 9f24347ae9..471d0b6c98 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomInfo.cs @@ -4,17 +4,21 @@ using System; using NUnit.Framework; using osu.Framework.Graphics; +using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneLoungeRoomInfo : RoomTestScene + public class TestSceneLoungeRoomInfo : OnlinePlayTestScene { [SetUp] public new void Setup() => Schedule(() => { + SelectedRoom.Value = new Room(); + Child = new RoomInfo { Anchor = Anchor.Centre, @@ -23,15 +27,10 @@ namespace osu.Game.Tests.Visual.Multiplayer }; }); - public override void SetUpSteps() - { - // Todo: Temp - } - [Test] public void TestNonSelectedRoom() { - AddStep("set null room", () => Room.RoomID.Value = null); + AddStep("set null room", () => SelectedRoom.Value.RoomID.Value = null); } [Test] @@ -39,11 +38,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { 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(); + SelectedRoom.Value.RoomID.Value = 0; + SelectedRoom.Value.Name.Value = "Room 0"; + SelectedRoom.Value.Host.Value = new User { Username = "peppy", Id = 2 }; + SelectedRoom.Value.EndDate.Value = DateTimeOffset.Now.AddMonths(1); + SelectedRoom.Value.Status.Value = new RoomStatusOpen(); }); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index 5682fd5c3c..4d5bf8f225 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -3,55 +3,51 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Graphics; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Osu; using osu.Game.Screens.OnlinePlay.Lounge.Components; -using osuTK.Graphics; +using osu.Game.Tests.Visual.OnlinePlay; using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneLoungeRoomsContainer : RoomManagerTestScene + public class TestSceneLoungeRoomsContainer : OnlinePlayTestScene { + protected new BasicTestRoomManager RoomManager => (BasicTestRoomManager)base.RoomManager; + private RoomsContainer container; - [BackgroundDependencyLoader] - private void load() + [SetUp] + public new void Setup() => Schedule(() => { Child = container = new RoomsContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 0.5f, - JoinRequested = joinRequested }; - } + }); [Test] public void TestBasicListChanges() { - AddRooms(3); + AddStep("add rooms", () => RoomManager.AddRooms(3)); 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()); + AddStep("select first room", () => container.Rooms.First().Click()); 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); } [Test] public void TestKeyboardNavigation() { - AddRooms(3); + AddStep("add rooms", () => RoomManager.AddRooms(3)); AddAssert("no selection", () => checkRoomSelected(null)); @@ -64,15 +60,12 @@ namespace osu.Game.Tests.Visual.Multiplayer 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); } [Test] public void TestClickDeselection() { - AddRooms(1); + AddStep("add room", () => RoomManager.AddRooms(1)); AddAssert("no selection", () => checkRoomSelected(null)); @@ -91,7 +84,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestStringFiltering() { - AddRooms(4); + AddStep("add rooms", () => RoomManager.AddRooms(4)); AddUntilStep("4 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 4); @@ -107,29 +100,26 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestRulesetFiltering() { - AddRooms(2, new OsuRuleset().RulesetInfo); - AddRooms(3, new CatchRuleset().RulesetInfo); + AddStep("add rooms", () => RoomManager.AddRooms(2, new OsuRuleset().RulesetInfo)); + AddStep("add rooms", () => RoomManager.AddRooms(3, new CatchRuleset().RulesetInfo)); + // Todo: What even is this case...? + AddStep("set empty filter criteria", () => container.Filter(null)); 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 JoinedRoomStatus : RoomStatus + [Test] + public void TestPasswordProtectedRooms() { - public override string Message => "Joined"; - - public override Color4 GetAppropriateColour(OsuColour colours) => colours.Yellow; + AddStep("add rooms", () => RoomManager.AddRooms(3, withPassword: true)); } + + private bool checkRoomSelected(Room room) => SelectedRoom.Value == room; } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs index 9ad9f2c883..d66603a448 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs @@ -11,11 +11,12 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual.OnlinePlay; using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMatchBeatmapDetailArea : RoomTestScene + public class TestSceneMatchBeatmapDetailArea : OnlinePlayTestScene { [Resolved] private BeatmapManager beatmapManager { get; set; } @@ -26,6 +27,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUp] public new void Setup() => Schedule(() => { + SelectedRoom.Value = new Room(); + Child = new MatchBeatmapDetailArea { Anchor = Anchor.Centre, @@ -37,9 +40,9 @@ namespace osu.Game.Tests.Visual.Multiplayer private void createNewItem() { - Room.Playlist.Add(new PlaylistItem + SelectedRoom.Value.Playlist.Add(new PlaylistItem { - ID = Room.Playlist.Count, + ID = SelectedRoom.Value.Playlist.Count, Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, Ruleset = { Value = new OsuRuleset().RulesetInfo }, RequiredMods = diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs index 7cdc6b1a7d..71ba5db481 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchHeader.cs @@ -7,46 +7,49 @@ using osu.Game.Online.Rooms; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMatchHeader : RoomTestScene + public class TestSceneMatchHeader : OnlinePlayTestScene { - public TestSceneMatchHeader() - { - Child = new Header(); - } - [SetUp] public new void Setup() => Schedule(() => { - Room.Playlist.Add(new PlaylistItem + SelectedRoom.Value = new Room { - Beatmap = + Name = { Value = "A very awesome room" }, + Host = { Value = new User { Id = 2, Username = "peppy" } }, + Playlist = { - Value = new BeatmapInfo + new PlaylistItem { - Metadata = new BeatmapMetadata + Beatmap = { - Title = "Title", - Artist = "Artist", - AuthorString = "Author", + Value = new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "Title", + Artist = "Artist", + AuthorString = "Author", + }, + Version = "Version", + Ruleset = new OsuRuleset().RulesetInfo + } }, - Version = "Version", - Ruleset = new OsuRuleset().RulesetInfo + RequiredMods = + { + new OsuModDoubleTime(), + new OsuModNoFail(), + new OsuModRelax(), + } } - }, - RequiredMods = - { - new OsuModDoubleTime(), - new OsuModNoFail(), - new OsuModRelax(), } - }); + }; - 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/TestSceneMatchLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs index 64eaf0556b..a7a5f3af39 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchLeaderboard.cs @@ -2,72 +2,74 @@ // 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.Allocation; using osu.Framework.Graphics; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Match.Components; +using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Users; using osuTK; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMatchLeaderboard : RoomTestScene + public class TestSceneMatchLeaderboard : OnlinePlayTestScene { - protected override bool UseOnlineAPI => true; - - public TestSceneMatchLeaderboard() - { - Add(new MatchLeaderboard - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Size = new Vector2(550f, 450f), - Scope = MatchLeaderboardScope.Overall, - }); - } - [BackgroundDependencyLoader] - private void load(IAPIProvider api) + private void load() { - var req = new GetRoomScoresRequest(); - req.Success += v => { }; - req.Failure += _ => { }; + ((DummyAPIAccess)API).HandleRequest = r => + { + switch (r) + { + case GetRoomLeaderboardRequest leaderboardRequest: + leaderboardRequest.TriggerSuccess(new APILeaderboard + { + Leaderboard = new List + { + new APIUserScoreAggregate + { + UserID = 2, + User = new User { Id = 2, Username = "peppy" }, + TotalScore = 995533, + RoomID = 3, + CompletedBeatmaps = 1, + TotalAttempts = 6, + Accuracy = 0.9851 + }, + new APIUserScoreAggregate + { + UserID = 1040328, + User = new User { Id = 1040328, Username = "smoogipoo" }, + TotalScore = 981100, + RoomID = 3, + CompletedBeatmaps = 1, + TotalAttempts = 9, + Accuracy = 0.937 + } + } + }); + return true; + } - api.Queue(req); + return false; + }; } [SetUp] public new void Setup() => Schedule(() => { - Room.RoomID.Value = 3; + SelectedRoom.Value = new Room { RoomID = { Value = 3 } }; + + Child = new MatchLeaderboard + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Size = new Vector2(550f, 450f), + Scope = MatchLeaderboardScope.Overall, + }; }); - - private class GetRoomScoresRequest : APIRequest> - { - protected override string Target => "rooms/3/leaderboard"; - } - - private class RoomScore - { - [JsonProperty("user")] - public User User { get; set; } - - [JsonProperty("accuracy")] - public double Accuracy { get; set; } - - [JsonProperty("total_score")] - public int TotalScore { get; set; } - - [JsonProperty("pp")] - public double PP { get; set; } - - [JsonProperty("attempts")] - public int TotalAttempts { get; set; } - - [JsonProperty("completed")] - public int CompletedAttempts { get; set; } - } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs index 5ad35be0ec..e14df62af1 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorLeaderboard.cs @@ -3,74 +3,40 @@ using System.Collections.Generic; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Timing; -using osu.Game.Database; -using osu.Game.Online.Spectator; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play.HUD; -using osu.Game.Tests.Visual.Spectator; -using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMultiSpectatorLeaderboard : MultiplayerTestScene { - [Cached(typeof(SpectatorClient))] - private TestSpectatorClient spectatorClient = new TestSpectatorClient(); - - [Cached(typeof(UserLookupCache))] - private UserLookupCache lookupCache = new TestUserLookupCache(); - - protected override Container Content => content; - private readonly Container content; - - private readonly Dictionary clocks = new Dictionary - { - { PLAYER_1_ID, new ManualClock() }, - { PLAYER_2_ID, new ManualClock() } - }; - - public TestSceneMultiSpectatorLeaderboard() - { - base.Content.AddRange(new Drawable[] - { - spectatorClient, - lookupCache, - content = new Container { RelativeSizeAxes = Axes.Both } - }); - } + private Dictionary clocks; + private MultiSpectatorLeaderboard leaderboard; [SetUpSteps] public new void SetUpSteps() { - MultiSpectatorLeaderboard leaderboard = null; - AddStep("reset", () => { Clear(); - foreach (var (userId, clock) in clocks) + clocks = new Dictionary { - spectatorClient.EndPlay(userId); - clock.CurrentTime = 0; - } + { PLAYER_1_ID, new ManualClock() }, + { PLAYER_2_ID, new ManualClock() } + }; + + foreach (var (userId, _) in clocks) + SpectatorClient.StartPlay(userId, 0); }); AddStep("create leaderboard", () => { - foreach (var (userId, _) in clocks) - spectatorClient.StartPlay(userId, 0); - Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); - var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); var scoreProcessor = new OsuScoreProcessor(); scoreProcessor.ApplyBeatmap(playable); @@ -96,10 +62,10 @@ namespace osu.Game.Tests.Visual.Multiplayer // For player 2, send frames in sets of 10. for (int i = 0; i < 100; i++) { - spectatorClient.SendFrames(PLAYER_1_ID, i, 1); + SpectatorClient.SendFrames(PLAYER_1_ID, 1); if (i % 10 == 0) - spectatorClient.SendFrames(PLAYER_2_ID, i, 10); + SpectatorClient.SendFrames(PLAYER_2_ID, 10); } }); @@ -145,17 +111,5 @@ namespace osu.Game.Tests.Visual.Multiplayer private void assertCombo(int userId, int expectedCombo) => AddUntilStep($"player {userId} has {expectedCombo} combo", () => this.ChildrenOfType().Single(s => s.User?.Id == userId).Combo.Value == expectedCombo); - - private class TestUserLookupCache : UserLookupCache - { - protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) - { - return Task.FromResult(new User - { - Id = lookup, - Username = $"User {lookup}" - }); - } - } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index b91391c409..072e32370d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -3,31 +3,20 @@ using System.Collections.Generic; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Online.Spectator; +using osu.Game.Rulesets.UI; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play; using osu.Game.Tests.Beatmaps.IO; -using osu.Game.Tests.Visual.Spectator; -using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMultiSpectatorScreen : MultiplayerTestScene { - [Cached(typeof(SpectatorClient))] - private TestSpectatorClient spectatorClient = new TestSpectatorClient(); - - [Cached(typeof(UserLookupCache))] - private UserLookupCache lookupCache = new TestUserLookupCache(); - [Resolved] private OsuGameBase game { get; set; } @@ -37,7 +26,6 @@ namespace osu.Game.Tests.Visual.Multiplayer private MultiSpectatorScreen spectatorScreen; private readonly List playingUserIds = new List(); - private readonly Dictionary nextFrame = new Dictionary(); private BeatmapSetInfo importedSet; private BeatmapInfo importedBeatmap; @@ -51,25 +39,8 @@ namespace osu.Game.Tests.Visual.Multiplayer importedBeatmapId = importedBeatmap.OnlineBeatmapID ?? -1; } - public override void SetUpSteps() - { - base.SetUpSteps(); - - AddStep("reset sent frames", () => nextFrame.Clear()); - - AddStep("add streaming client", () => - { - Remove(spectatorClient); - Add(spectatorClient); - }); - - AddStep("finish previous gameplay", () => - { - foreach (var id in playingUserIds) - spectatorClient.EndPlay(id); - playingUserIds.Clear(); - }); - } + [SetUp] + public new void Setup() => Schedule(() => playingUserIds.Clear()); [Test] public void TestDelayedStart() @@ -80,18 +51,16 @@ namespace osu.Game.Tests.Visual.Multiplayer Client.CurrentMatchPlayingUserIds.Add(PLAYER_2_ID); playingUserIds.Add(PLAYER_1_ID); playingUserIds.Add(PLAYER_2_ID); - nextFrame[PLAYER_1_ID] = 0; - nextFrame[PLAYER_2_ID] = 0; }); loadSpectateScreen(false); AddWaitStep("wait a bit", 10); - AddStep("load player first_player_id", () => spectatorClient.StartPlay(PLAYER_1_ID, importedBeatmapId)); + AddStep("load player first_player_id", () => SpectatorClient.StartPlay(PLAYER_1_ID, importedBeatmapId)); AddUntilStep("one player added", () => spectatorScreen.ChildrenOfType().Count() == 1); AddWaitStep("wait a bit", 10); - AddStep("load player second_player_id", () => spectatorClient.StartPlay(PLAYER_2_ID, importedBeatmapId)); + AddStep("load player second_player_id", () => SpectatorClient.StartPlay(PLAYER_2_ID, importedBeatmapId)); AddUntilStep("two players added", () => spectatorScreen.ChildrenOfType().Count() == 2); } @@ -107,6 +76,23 @@ namespace osu.Game.Tests.Visual.Multiplayer AddWaitStep("wait a bit", 20); } + [Test] + public void TestTimeDoesNotProgressWhileAllPlayersPaused() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + loadSpectateScreen(); + + sendFrames(PLAYER_1_ID, 40); + sendFrames(PLAYER_2_ID, 20); + + checkPaused(PLAYER_2_ID, true); + checkPausedInstant(PLAYER_1_ID, false); + AddAssert("master clock still running", () => this.ChildrenOfType().Single().IsRunning); + + checkPaused(PLAYER_1_ID, true); + AddUntilStep("master clock paused", () => !this.ChildrenOfType().Single().IsRunning); + } + [Test] public void TestPlayersMustStartSimultaneously() { @@ -151,7 +137,7 @@ namespace osu.Game.Tests.Visual.Multiplayer // Send initial frames for both players. A few more for player 1. sendFrames(PLAYER_1_ID, 20); - sendFrames(PLAYER_2_ID, 10); + sendFrames(PLAYER_2_ID); checkPausedInstant(PLAYER_1_ID, false); checkPausedInstant(PLAYER_2_ID, false); @@ -182,7 +168,7 @@ namespace osu.Game.Tests.Visual.Multiplayer // Send initial frames for both players. A few more for player 1. sendFrames(PLAYER_1_ID, 1000); - sendFrames(PLAYER_2_ID, 10); + sendFrames(PLAYER_2_ID, 30); checkPausedInstant(PLAYER_1_ID, false); checkPausedInstant(PLAYER_2_ID, false); @@ -208,10 +194,10 @@ namespace osu.Game.Tests.Visual.Multiplayer assertMuted(PLAYER_1_ID, true); assertMuted(PLAYER_2_ID, true); - sendFrames(PLAYER_1_ID, 10); + sendFrames(PLAYER_1_ID); sendFrames(PLAYER_2_ID, 20); - assertMuted(PLAYER_1_ID, false); - assertMuted(PLAYER_2_ID, true); + checkPaused(PLAYER_1_ID, false); + assertOneNotMuted(); checkPaused(PLAYER_1_ID, true); assertMuted(PLAYER_1_ID, true); @@ -229,6 +215,36 @@ namespace osu.Game.Tests.Visual.Multiplayer assertMuted(PLAYER_2_ID, true); } + [Test] + public void TestSpectatingDuringGameplay() + { + var players = new[] { PLAYER_1_ID, PLAYER_2_ID }; + + start(players); + sendFrames(players, 300); + + loadSpectateScreen(); + sendFrames(players, 300); + + AddUntilStep("playing from correct point in time", () => this.ChildrenOfType().All(r => r.FrameStableClock.CurrentTime > 30000)); + } + + [Test] + public void TestSpectatingDuringGameplayWithLateFrames() + { + start(new[] { PLAYER_1_ID, PLAYER_2_ID }); + sendFrames(new[] { PLAYER_1_ID, PLAYER_2_ID }, 300); + + loadSpectateScreen(); + sendFrames(PLAYER_1_ID, 300); + + AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + checkPaused(PLAYER_1_ID, false); + + sendFrames(PLAYER_2_ID, 300); + AddUntilStep("player 2 playing from correct point in time", () => getPlayer(PLAYER_2_ID).ChildrenOfType().Single().FrameStableClock.CurrentTime > 30000); + } + private void loadSpectateScreen(bool waitForPlayerLoad = true) { AddStep("load screen", () => @@ -242,8 +258,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded && (!waitForPlayerLoad || spectatorScreen.AllPlayersLoaded)); } - private void start(int userId, int? beatmapId = null) => start(new[] { userId }, beatmapId); - private void start(int[] userIds, int? beatmapId = null) { AddStep("start play", () => @@ -251,23 +265,12 @@ namespace osu.Game.Tests.Visual.Multiplayer foreach (int id in userIds) { Client.CurrentMatchPlayingUserIds.Add(id); - spectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId); + SpectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId); playingUserIds.Add(id); - nextFrame[id] = 0; } }); } - private void finish(int userId) - { - AddStep("end play", () => - { - spectatorClient.EndPlay(userId); - playingUserIds.Remove(userId); - nextFrame.Remove(userId); - }); - } - private void sendFrames(int userId, int count = 10) => sendFrames(new[] { userId }, count); private void sendFrames(int[] userIds, int count = 10) @@ -275,10 +278,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("send frames", () => { foreach (int id in userIds) - { - spectatorClient.SendFrames(id, nextFrame[id], count); - nextFrame[id] += count; - } + SpectatorClient.SendFrames(id, count); }); } @@ -286,7 +286,14 @@ namespace osu.Game.Tests.Visual.Multiplayer => AddUntilStep($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); private void checkPausedInstant(int userId, bool state) - => AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); + { + checkPaused(userId, state); + + // Todo: The following should work, but is broken because SpectatorScreen retrieves the WorkingBeatmap via the BeatmapManager, bypassing the test scene clock and running real-time. + // AddAssert($"{userId} is {(state ? "paused" : "playing")}", () => getPlayer(userId).ChildrenOfType().First().GameplayClock.IsRunning != state); + } + + private void assertOneNotMuted() => AddAssert("one player not muted", () => spectatorScreen.ChildrenOfType().Count(p => !p.Mute) == 1); private void assertMuted(int userId, bool muted) => AddAssert($"{userId} {(muted ? "is" : "is not")} muted", () => getInstance(userId).Mute == muted); @@ -297,17 +304,5 @@ namespace osu.Game.Tests.Visual.Multiplayer private Player getPlayer(int userId) => getInstance(userId).ChildrenOfType().Single(); private PlayerArea getInstance(int userId) => spectatorScreen.ChildrenOfType().Single(p => p.UserId == userId); - - internal class TestUserLookupCache : UserLookupCache - { - protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) - { - return Task.FromResult(new User - { - Id = lookup, - Username = $"User {lookup}" - }); - } - } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index c5a6723508..36dd9c2de3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -6,15 +6,25 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens; using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Screens.OnlinePlay.Lounge; +using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Tests.Resources; @@ -25,19 +35,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMultiplayer : ScreenTestScene { - private TestMultiplayer multiplayerScreen; - private BeatmapManager beatmaps; private RulesetStore rulesets; private BeatmapSetInfo importedSet; - private TestMultiplayerClient client => multiplayerScreen.Client; - private Room room => client.APIRoom; + private DependenciesScreen dependenciesScreen; + private TestMultiplayer multiplayerScreen; + private TestMultiplayerClient client; - public TestSceneMultiplayer() - { - loadMultiplayer(); - } + [Cached(typeof(UserLookupCache))] + private UserLookupCache lookupCache = new TestUserLookupCache(); [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -46,18 +53,191 @@ namespace osu.Game.Tests.Visual.Multiplayer Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); } - [SetUp] - public void Setup() => Schedule(() => + public override void SetUpSteps() { - beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); - importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); - }); + base.SetUpSteps(); + + AddStep("import beatmap", () => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); + }); + + AddStep("create multiplayer screen", () => multiplayerScreen = new TestMultiplayer()); + + AddStep("load dependencies", () => + { + client = new TestMultiplayerClient(multiplayerScreen.RoomManager); + + // The screen gets suspended so it stops receiving updates. + Child = client; + + LoadScreen(dependenciesScreen = new DependenciesScreen(client)); + }); + + AddUntilStep("wait for dependencies to load", () => dependenciesScreen.IsLoaded); + + AddStep("load multiplayer", () => LoadScreen(multiplayerScreen)); + AddUntilStep("wait for multiplayer to load", () => multiplayerScreen.IsLoaded); + } + + [Test] + public void TestEmpty() + { + // used to test the flow of multiplayer from visual tests. + } + + [Test] + public void TestCreateRoomWithoutPassword() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + } + + [Test] + public void TestExitMidJoin() + { + Room room = null; + + AddStep("create room", () => + { + room = new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }; + }); + + AddStep("refresh rooms", () => multiplayerScreen.RoomManager.Filter.Value = new FilterCriteria()); + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("join room and immediately exit", () => + { + multiplayerScreen.ChildrenOfType().Single().Open(room); + Schedule(() => Stack.CurrentScreen.Exit()); + }); + } + + [Test] + public void TestJoinRoomWithoutPassword() + { + AddStep("create room", () => + { + API.Queue(new CreateRoomRequest(new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + })); + }); + + AddStep("refresh rooms", () => multiplayerScreen.RoomManager.Filter.Value = new FilterCriteria()); + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("join room", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + AddUntilStep("wait for join", () => client.Room != null); + } + + [Test] + public void TestCreateRoomWithPassword() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Password = { Value = "password" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddAssert("room has password", () => client.APIRoom?.Password.Value == "password"); + } + + [Test] + public void TestJoinRoomWithPassword() + { + AddStep("create room", () => + { + API.Queue(new CreateRoomRequest(new Room + { + Name = { Value = "Test Room" }, + Password = { Value = "password" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + })); + }); + + AddStep("refresh rooms", () => multiplayerScreen.RoomManager.Filter.Value = new FilterCriteria()); + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("join room", () => InputManager.Key(Key.Enter)); + + DrawableRoom.PasswordEntryPopover passwordEntryPopover = null; + AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType().FirstOrDefault()) != null); + AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType().First().Text = "password"); + AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType().First().Click()); + + AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + AddUntilStep("wait for join", () => client.Room != null); + } + + [Test] + public void TestLocalPasswordUpdatedWhenMultiplayerSettingsChange() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Password = { Value = "password" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddStep("change password", () => client.ChangeSettings(password: "password2")); + AddUntilStep("local password changed", () => client.APIRoom?.Password.Value == "password2"); + } [Test] public void TestUserSetToIdleWhenBeatmapDeleted() { - loadMultiplayer(); - createRoom(() => new Room { Name = { Value = "Test Room" }, @@ -80,8 +260,6 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestLocalPlayDoesNotStartWhileSpectatingWithNoBeatmap() { - loadMultiplayer(); - createRoom(() => new Room { Name = { Value = "Test Room" }, @@ -118,8 +296,6 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestLocalPlayStartsWhileSpectatingWhenBeatmapBecomesAvailable() { - loadMultiplayer(); - createRoom(() => new Room { Name = { Value = "Test Room" }, @@ -159,6 +335,68 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("play started", () => !multiplayerScreen.IsCurrentScreen()); } + [Test] + public void TestSubScreenExitedWhenDisconnectedFromMultiplayerServer() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddStep("disconnect", () => client.Disconnect()); + AddUntilStep("back in lounge", () => this.ChildrenOfType().FirstOrDefault()?.IsCurrentScreen() == true); + } + + [Test] + public void TestLeaveNavigation() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + AllowedMods = { new OsuModHidden() } + } + } + }); + + AddStep("open mod overlay", () => this.ChildrenOfType().ElementAt(2).Click()); + + AddStep("invoke on back button", () => multiplayerScreen.OnBackButton()); + + AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); + + AddAssert("dialog overlay is hidden", () => DialogOverlay.State.Value == Visibility.Hidden); + + testLeave("lounge tab item", () => this.ChildrenOfType.BreadcrumbTabItem>().First().Click()); + + testLeave("back button", () => multiplayerScreen.OnBackButton()); + + // mimics home button and OS window close + testLeave("forced exit", () => multiplayerScreen.Exit()); + + void testLeave(string actionName, Action action) + { + AddStep($"leave via {actionName}", action); + + AddAssert("dialog overlay is visible", () => DialogOverlay.State.Value == Visibility.Visible); + + AddStep("close dialog overlay", () => InputManager.Key(Key.Escape)); + } + } + private void createRoom(Func room) { AddStep("open room", () => @@ -178,32 +416,25 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for join", () => client.Room != null); } - private void loadMultiplayer() - { - AddStep("show", () => - { - multiplayerScreen = new TestMultiplayer(); - - // Needs to be added at a higher level since the multiplayer screen becomes non-current. - Child = multiplayerScreen.Client; - - LoadScreen(multiplayerScreen); - }); - - AddUntilStep("wait for loaded", () => multiplayerScreen.IsLoaded); - } - - private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer + /// + /// Used for the sole purpose of adding as a resolvable dependency. + /// + private class DependenciesScreen : OsuScreen { [Cached(typeof(MultiplayerClient))] public readonly TestMultiplayerClient Client; - public TestMultiplayer() + public DependenciesScreen(TestMultiplayerClient client) { - Client = new TestMultiplayerClient((TestMultiplayerRoomManager)RoomManager); + Client = client; } + } - protected override RoomManager CreateRoomManager() => new TestMultiplayerRoomManager(); + private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer + { + public new TestMultiplayerRoomManager RoomManager { get; private set; } + + protected override RoomManager CreateRoomManager() => RoomManager = new TestMultiplayerRoomManager(); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs index af2f6fa5fe..0e368b59dd 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerGameplayLeaderboard.cs @@ -6,12 +6,11 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Configuration; -using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; @@ -19,37 +18,20 @@ using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; -using osu.Game.Tests.Visual.Online; +using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Tests.Visual.Spectator; namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMultiplayerGameplayLeaderboard : MultiplayerTestScene { - private const int users = 16; + private static IEnumerable users => Enumerable.Range(0, 16); - [Cached(typeof(SpectatorClient))] - private TestMultiplayerSpectatorClient spectatorClient = new TestMultiplayerSpectatorClient(); - - [Cached(typeof(UserLookupCache))] - private UserLookupCache lookupCache = new TestSceneCurrentlyPlayingDisplay.TestUserLookupCache(); + public new TestMultiplayerSpectatorClient SpectatorClient => (TestMultiplayerSpectatorClient)OnlinePlayDependencies?.SpectatorClient; private MultiplayerGameplayLeaderboard leaderboard; - - protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; - private OsuConfigManager config; - public TestSceneMultiplayerGameplayLeaderboard() - { - base.Content.Children = new Drawable[] - { - spectatorClient, - lookupCache, - Content - }; - } - [BackgroundDependencyLoader] private void load() { @@ -59,7 +41,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUpSteps] public override void SetUpSteps() { - AddStep("set local user", () => ((DummyAPIAccess)API).LocalUser.Value = lookupCache.GetUserAsync(1).Result); + AddStep("set local user", () => ((DummyAPIAccess)API).LocalUser.Value = LookupCache.GetUserAsync(1).Result); AddStep("create leaderboard", () => { @@ -70,14 +52,11 @@ namespace osu.Game.Tests.Visual.Multiplayer var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); - for (int i = 0; i < users; i++) - spectatorClient.StartPlay(i, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); + foreach (var user in users) + SpectatorClient.StartPlay(user, Beatmap.Value.BeatmapInfo.OnlineBeatmapID ?? 0); - spectatorClient.Schedule(() => - { - Client.CurrentMatchPlayingUserIds.Clear(); - Client.CurrentMatchPlayingUserIds.AddRange(spectatorClient.PlayingUsers); - }); + // Todo: This is REALLY bad. + Client.CurrentMatchPlayingUserIds.AddRange(users); Children = new Drawable[] { @@ -86,7 +65,7 @@ namespace osu.Game.Tests.Visual.Multiplayer scoreProcessor.ApplyBeatmap(playable); - LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, spectatorClient.PlayingUsers.ToArray()) + LoadComponentAsync(leaderboard = new MultiplayerGameplayLeaderboard(scoreProcessor, users.ToArray()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -100,24 +79,32 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestScoreUpdates() { - AddRepeatStep("update state", () => spectatorClient.RandomlyUpdateState(), 100); + AddRepeatStep("update state", () => SpectatorClient.RandomlyUpdateState(), 100); AddToggleStep("switch compact mode", expanded => leaderboard.Expanded.Value = expanded); } [Test] public void TestUserQuit() { - AddRepeatStep("mark user quit", () => Client.CurrentMatchPlayingUserIds.RemoveAt(0), users); + foreach (var user in users) + AddStep($"mark user {user} quit", () => Client.RemoveUser(LookupCache.GetUserAsync(user).Result.AsNonNull())); } [Test] public void TestChangeScoringMode() { - AddRepeatStep("update state", () => spectatorClient.RandomlyUpdateState(), 5); + AddRepeatStep("update state", () => SpectatorClient.RandomlyUpdateState(), 5); AddStep("change to classic", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic)); AddStep("change to standardised", () => config.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); } + protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); + + protected class TestDependencies : MultiplayerTestSceneDependencies + { + protected override TestSpectatorClient CreateSpectatorClient() => new TestMultiplayerSpectatorClient(); + } + public class TestMultiplayerSpectatorClient : TestSpectatorClient { private readonly Dictionary lastHeaders = new Dictionary(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs new file mode 100644 index 0000000000..de46d9e25a --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.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.Graphics.UserInterface; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Lounge; +using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerLoungeSubScreen : OnlinePlayTestScene + { + protected new BasicTestRoomManager RoomManager => (BasicTestRoomManager)base.RoomManager; + + private LoungeSubScreen loungeScreen; + + private Room lastJoinedRoom; + private string lastJoinedPassword; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("push screen", () => LoadScreen(loungeScreen = new MultiplayerLoungeSubScreen())); + + AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); + + AddStep("bind to event", () => + { + lastJoinedRoom = null; + lastJoinedPassword = null; + RoomManager.JoinRoomRequested = onRoomJoined; + }); + } + + [Test] + public void TestJoinRoomWithoutPassword() + { + AddStep("add room", () => RoomManager.AddRooms(1, withPassword: false)); + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("join room", () => InputManager.Key(Key.Enter)); + + AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First()); + AddAssert("room join password correct", () => lastJoinedPassword == null); + } + + [Test] + public void TestPopoverHidesOnLeavingScreen() + { + AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("attempt join room", () => InputManager.Key(Key.Enter)); + + AddUntilStep("password prompt appeared", () => InputManager.ChildrenOfType().Any()); + AddStep("exit screen", () => Stack.Exit()); + AddUntilStep("password prompt hidden", () => !InputManager.ChildrenOfType().Any()); + } + + [Test] + public void TestJoinRoomWithPassword() + { + DrawableRoom.PasswordEntryPopover passwordEntryPopover = null; + + AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("attempt join room", () => InputManager.Key(Key.Enter)); + AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType().FirstOrDefault()) != null); + AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType().First().Text = "password"); + AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType().First().Click()); + + AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First()); + AddAssert("room join password correct", () => lastJoinedPassword == "password"); + } + + private void onRoomJoined(Room room, string password) + { + lastJoinedRoom = room; + lastJoinedPassword = password; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs index 6b03b53b4b..4e08ffef17 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.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 osu.Framework.Allocation; +using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; @@ -10,18 +10,17 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMultiplayerMatchFooter : MultiplayerTestScene { - [Cached] - private readonly OnlinePlayBeatmapAvailabilityTracker availablilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); - - [BackgroundDependencyLoader] - private void load() + [SetUp] + public new void Setup() => Schedule(() => { + SelectedRoom.Value = new Room(); + Child = new MultiplayerMatchFooter { Anchor = Anchor.Centre, Origin = Anchor.Centre, Height = 50 }; - } + }); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 5b059c06f5..8bcb9cebbc 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -29,7 +29,7 @@ using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestSceneMultiplayerMatchSongSelect : RoomTestScene + public class TestSceneMultiplayerMatchSongSelect : MultiplayerTestScene { private BeatmapManager manager; private RulesetStore rulesets; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index e8ebc0c426..955be6ca21 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -49,13 +49,13 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUp] public new void Setup() => Schedule(() => { - Room.Name.Value = "Test Room"; + SelectedRoom.Value = new Room { Name = { Value = "Test Room" } }; }); [SetUpSteps] public void SetupSteps() { - AddStep("load match", () => LoadScreen(screen = new MultiplayerMatchSubScreen(Room))); + AddStep("load match", () => LoadScreen(screen = new MultiplayerMatchSubScreen(SelectedRoom.Value))); AddUntilStep("wait for load", () => screen.IsCurrentScreen()); } @@ -66,7 +66,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("set playlist", () => { - Room.Playlist.Add(new PlaylistItem + SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, Ruleset = { Value = new OsuRuleset().RulesetInfo }, @@ -81,7 +81,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("set playlist", () => { - Room.Playlist.Add(new PlaylistItem + SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, Ruleset = { Value = new OsuRuleset().RulesetInfo }, @@ -102,7 +102,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("set playlist", () => { - Room.Playlist.Add(new PlaylistItem + SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo }, Ruleset = { Value = new OsuRuleset().RulesetInfo }, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 7f8f04b718..6526f7eea7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -22,8 +22,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMultiplayerParticipantsList : MultiplayerTestScene { - [SetUp] - public new void Setup() => Schedule(createNewParticipantsList); + [SetUpSteps] + public void SetupSteps() + { + createNewParticipantsList(); + } [Test] public void TestAddUser() @@ -88,7 +91,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestCorrectInitialState() { AddStep("set to downloading map", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); - AddStep("recreate list", createNewParticipantsList); + createNewParticipantsList(); checkProgressBarVisibility(true); } @@ -233,7 +236,17 @@ namespace osu.Game.Tests.Visual.Multiplayer private void createNewParticipantsList() { - Child = new ParticipantsList { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Y, Size = new Vector2(380, 0.7f) }; + ParticipantsList participantsList = null; + + AddStep("create new list", () => Child = participantsList = new ParticipantsList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Size = new Vector2(380, 0.7f) + }); + + AddUntilStep("wait for list to load", () => participantsList.IsLoaded); } private void checkProgressBarVisibility(bool visible) => diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs index 929cd6ca80..820b403a10 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -27,7 +28,6 @@ namespace osu.Game.Tests.Visual.Multiplayer public class TestSceneMultiplayerReadyButton : MultiplayerTestScene { private MultiplayerReadyButton button; - private OnlinePlayBeatmapAvailabilityTracker beatmapTracker; private BeatmapSetInfo importedSet; private readonly Bindable selectedItem = new Bindable(); @@ -43,18 +43,13 @@ namespace osu.Game.Tests.Visual.Multiplayer Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); - - Add(beatmapTracker = new OnlinePlayBeatmapAvailabilityTracker - { - SelectedItem = { BindTarget = selectedItem } - }); - - Dependencies.Cache(beatmapTracker); } [SetUp] public new void Setup() => Schedule(() => { + AvailabilityTracker.SelectedItem.BindTo(selectedItem); + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); selectedItem.Value = new PlaylistItem @@ -71,18 +66,22 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200, 50), - OnReadyClick = async () => + OnReadyClick = () => { readyClickOperation = OngoingOperationTracker.BeginOperation(); - if (Client.IsHost && Client.LocalUser?.State == MultiplayerUserState.Ready) + Task.Run(async () => { - await Client.StartMatch(); - return; - } + if (Client.IsHost && Client.LocalUser?.State == MultiplayerUserState.Ready) + { + await Client.StartMatch(); + return; + } - await Client.ToggleReady(); - readyClickOperation.Dispose(); + await Client.ToggleReady(); + + readyClickOperation.Dispose(); + }); } }); }); @@ -114,10 +113,10 @@ namespace osu.Game.Tests.Visual.Multiplayer }); addClickButtonStep(); - AddAssert("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready); + AddUntilStep("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready); addClickButtonStep(); - AddAssert("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); + AddUntilStep("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); } [TestCase(true)] @@ -133,7 +132,7 @@ namespace osu.Game.Tests.Visual.Multiplayer }); addClickButtonStep(); - AddAssert("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready); + AddUntilStep("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready); verifyGameplayStartFlow(); } @@ -207,8 +206,9 @@ namespace osu.Game.Tests.Visual.Multiplayer private void verifyGameplayStartFlow() { + AddUntilStep("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready); addClickButtonStep(); - AddAssert("user waiting for load", () => Client.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad); + AddUntilStep("user waiting for load", () => Client.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad); AddAssert("ready button disabled", () => !button.ChildrenOfType().Single().Enabled.Value); AddStep("transitioned to gameplay", () => readyClickOperation.Dispose()); @@ -219,7 +219,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Client.ChangeUserState(Client.Room?.Users[0].UserID ?? 0, MultiplayerUserState.FinishedPlay); }); - AddAssert("ready button enabled", () => button.ChildrenOfType().Single().Enabled.Value); + AddUntilStep("ready button enabled", () => button.ChildrenOfType().Single().Enabled.Value); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs index c008771fd9..b17427a30b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs @@ -3,37 +3,38 @@ using System; using NUnit.Framework; -using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual.OnlinePlay; namespace osu.Game.Tests.Visual.Multiplayer { [HeadlessTest] - public class TestSceneMultiplayerRoomManager : RoomTestScene + public class TestSceneMultiplayerRoomManager : MultiplayerTestScene { - private TestMultiplayerRoomContainer roomContainer; - private TestMultiplayerRoomManager roomManager => roomContainer.RoomManager; + protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); + + public TestSceneMultiplayerRoomManager() + : base(false) + { + } [Test] public void TestPollsInitially() { AddStep("create room manager with a few rooms", () => { - createRoomManager().With(d => d.OnLoadComplete += _ => - { - roomManager.CreateRoom(createRoom(r => r.Name.Value = "1")); - roomManager.PartRoom(); - roomManager.CreateRoom(createRoom(r => r.Name.Value = "2")); - roomManager.PartRoom(); - roomManager.ClearRooms(); - }); + RoomManager.CreateRoom(createRoom(r => r.Name.Value = "1")); + RoomManager.PartRoom(); + RoomManager.CreateRoom(createRoom(r => r.Name.Value = "2")); + RoomManager.PartRoom(); + RoomManager.ClearRooms(); }); - AddAssert("manager polled for rooms", () => ((RoomManager)roomManager).Rooms.Count == 2); - AddAssert("initial rooms received", () => roomManager.InitialRoomsReceived.Value); + AddAssert("manager polled for rooms", () => ((RoomManager)RoomManager).Rooms.Count == 2); + AddAssert("initial rooms received", () => RoomManager.InitialRoomsReceived.Value); } [Test] @@ -41,19 +42,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room manager with a few rooms", () => { - createRoomManager().With(d => d.OnLoadComplete += _ => - { - roomManager.CreateRoom(createRoom()); - roomManager.PartRoom(); - roomManager.CreateRoom(createRoom()); - roomManager.PartRoom(); - }); + RoomManager.CreateRoom(createRoom()); + RoomManager.PartRoom(); + RoomManager.CreateRoom(createRoom()); + RoomManager.PartRoom(); }); - AddStep("disconnect", () => roomContainer.Client.Disconnect()); + AddStep("disconnect", () => Client.Disconnect()); - AddAssert("rooms cleared", () => ((RoomManager)roomManager).Rooms.Count == 0); - AddAssert("initial rooms not received", () => !roomManager.InitialRoomsReceived.Value); + AddAssert("rooms cleared", () => ((RoomManager)RoomManager).Rooms.Count == 0); + AddAssert("initial rooms not received", () => !RoomManager.InitialRoomsReceived.Value); } [Test] @@ -61,20 +59,17 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room manager with a few rooms", () => { - createRoomManager().With(d => d.OnLoadComplete += _ => - { - roomManager.CreateRoom(createRoom()); - roomManager.PartRoom(); - roomManager.CreateRoom(createRoom()); - roomManager.PartRoom(); - }); + RoomManager.CreateRoom(createRoom()); + RoomManager.PartRoom(); + RoomManager.CreateRoom(createRoom()); + RoomManager.PartRoom(); }); - AddStep("disconnect", () => roomContainer.Client.Disconnect()); - AddStep("connect", () => roomContainer.Client.Connect()); + AddStep("disconnect", () => Client.Disconnect()); + AddStep("connect", () => Client.Connect()); - AddAssert("manager polled for rooms", () => ((RoomManager)roomManager).Rooms.Count == 2); - AddAssert("initial rooms received", () => roomManager.InitialRoomsReceived.Value); + AddAssert("manager polled for rooms", () => ((RoomManager)RoomManager).Rooms.Count == 2); + AddAssert("initial rooms received", () => RoomManager.InitialRoomsReceived.Value); } [Test] @@ -82,15 +77,12 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room manager with a room", () => { - createRoomManager().With(d => d.OnLoadComplete += _ => - { - roomManager.CreateRoom(createRoom()); - roomManager.ClearRooms(); - }); + RoomManager.CreateRoom(createRoom()); + RoomManager.ClearRooms(); }); - AddAssert("manager not polled for rooms", () => ((RoomManager)roomManager).Rooms.Count == 0); - AddAssert("initial rooms not received", () => !roomManager.InitialRoomsReceived.Value); + AddAssert("manager not polled for rooms", () => ((RoomManager)RoomManager).Rooms.Count == 0); + AddAssert("initial rooms not received", () => !RoomManager.InitialRoomsReceived.Value); } [Test] @@ -98,13 +90,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room manager with a room", () => { - createRoomManager().With(d => d.OnLoadComplete += _ => - { - roomManager.CreateRoom(createRoom()); - }); + RoomManager.CreateRoom(createRoom()); }); - AddUntilStep("multiplayer room joined", () => roomContainer.Client.Room != null); + AddUntilStep("multiplayer room joined", () => Client.Room != null); } [Test] @@ -112,14 +101,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room manager with a room", () => { - createRoomManager().With(d => d.OnLoadComplete += _ => - { - roomManager.CreateRoom(createRoom()); - roomManager.PartRoom(); - }); + RoomManager.CreateRoom(createRoom()); + RoomManager.PartRoom(); }); - AddAssert("multiplayer room parted", () => roomContainer.Client.Room == null); + AddAssert("multiplayer room parted", () => Client.Room == null); } [Test] @@ -127,16 +113,13 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create room manager with a room", () => { - createRoomManager().With(d => d.OnLoadComplete += _ => - { - var r = createRoom(); - roomManager.CreateRoom(r); - roomManager.PartRoom(); - roomManager.JoinRoom(r); - }); + var r = createRoom(); + RoomManager.CreateRoom(r); + RoomManager.PartRoom(); + RoomManager.JoinRoom(r); }); - AddUntilStep("multiplayer room joined", () => roomContainer.Client.Room != null); + AddUntilStep("multiplayer room joined", () => Client.Room != null); } private Room createRoom(Action initFunc = null) @@ -161,18 +144,14 @@ namespace osu.Game.Tests.Visual.Multiplayer return room; } - private TestMultiplayerRoomManager createRoomManager() + private class TestDependencies : MultiplayerTestSceneDependencies { - Child = roomContainer = new TestMultiplayerRoomContainer + public TestDependencies() { - RoomManager = - { - TimeBetweenListingPolls = { Value = 1 }, - TimeBetweenSelectionPolls = { Value = 1 } - } - }; - - return roomManager; + // Need to set these values as early as possible. + RoomManager.TimeBetweenListingPolls.Value = 1; + RoomManager.TimeBetweenSelectionPolls.Value = 1; + } } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs index d00404102c..3d08d5da9e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -37,40 +38,19 @@ namespace osu.Game.Tests.Visual.Multiplayer private IDisposable readyClickOperation; - protected override Container Content => content; - private readonly Container content; - - public TestSceneMultiplayerSpectateButton() - { - base.Content.Add(content = new Container - { - RelativeSizeAxes = Axes.Both - }); - } - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - - return dependencies; - } - [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) { Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); - - var beatmapTracker = new OnlinePlayBeatmapAvailabilityTracker { SelectedItem = { BindTarget = selectedItem } }; - base.Content.Add(beatmapTracker); - Dependencies.Cache(beatmapTracker); - beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); } [SetUp] public new void Setup() => Schedule(() => { + AvailabilityTracker.SelectedItem.BindTo(selectedItem); + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); selectedItem.Value = new PlaylistItem @@ -90,11 +70,15 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200, 50), - OnSpectateClick = async () => + OnSpectateClick = () => { readyClickOperation = OngoingOperationTracker.BeginOperation(); - await Client.ToggleSpectate(); - readyClickOperation.Dispose(); + + Task.Run(async () => + { + await Client.ToggleSpectate(); + readyClickOperation.Dispose(); + }); } }, readyButton = new MultiplayerReadyButton @@ -102,18 +86,22 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200, 50), - OnReadyClick = async () => + OnReadyClick = () => { readyClickOperation = OngoingOperationTracker.BeginOperation(); - if (Client.IsHost && Client.LocalUser?.State == MultiplayerUserState.Ready) + Task.Run(async () => { - await Client.StartMatch(); - return; - } + if (Client.IsHost && Client.LocalUser?.State == MultiplayerUserState.Ready) + { + await Client.StartMatch(); + return; + } - await Client.ToggleReady(); - readyClickOperation.Dispose(); + await Client.ToggleReady(); + + readyClickOperation.Dispose(); + }); } } } @@ -134,10 +122,10 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestToggleWhenIdle(MultiplayerUserState initialState) { addClickSpectateButtonStep(); - AddAssert("user is spectating", () => Client.Room?.Users[0].State == MultiplayerUserState.Spectating); + AddUntilStep("user is spectating", () => Client.Room?.Users[0].State == MultiplayerUserState.Spectating); addClickSpectateButtonStep(); - AddAssert("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); + AddUntilStep("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); } [TestCase(MultiplayerRoomState.Closed)] @@ -186,9 +174,9 @@ namespace osu.Game.Tests.Visual.Multiplayer }); private void assertSpectateButtonEnablement(bool shouldBeEnabled) - => AddAssert($"spectate button {(shouldBeEnabled ? "is" : "is not")} enabled", () => spectateButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled); + => AddUntilStep($"spectate button {(shouldBeEnabled ? "is" : "is not")} enabled", () => spectateButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled); private void assertReadyButtonEnablement(bool shouldBeEnabled) - => AddAssert($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => readyButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled); + => AddUntilStep($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => readyButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled); } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index d95a95ebe5..e4bf9b36ed 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -14,16 +14,18 @@ using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Tests.Visual.OnlinePlay; namespace osu.Game.Tests.Visual.Multiplayer { - public class TestScenePlaylistsSongSelect : RoomTestScene + public class TestScenePlaylistsSongSelect : OnlinePlayTestScene { [Resolved] private BeatmapManager beatmapManager { get; set; } @@ -85,6 +87,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("reset", () => { + SelectedRoom.Value = new Room(); Ruleset.Value = new OsuRuleset().RulesetInfo; Beatmap.SetDefault(); SelectedMods.Value = Array.Empty(); @@ -98,14 +101,14 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestItemAddedIfEmptyOnStart() { AddStep("finalise selection", () => songSelect.FinaliseSelection()); - AddAssert("playlist has 1 item", () => Room.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => SelectedRoom.Value.Playlist.Count == 1); } [Test] public void TestItemAddedWhenCreateNewItemClicked() { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); - AddAssert("playlist has 1 item", () => Room.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => SelectedRoom.Value.Playlist.Count == 1); } [Test] @@ -113,7 +116,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); AddStep("finalise selection", () => songSelect.FinaliseSelection()); - AddAssert("playlist has 1 item", () => Room.Playlist.Count == 1); + AddAssert("playlist has 1 item", () => SelectedRoom.Value.Playlist.Count == 1); } [Test] @@ -121,7 +124,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); - AddAssert("playlist has 2 items", () => Room.Playlist.Count == 2); + AddAssert("playlist has 2 items", () => SelectedRoom.Value.Playlist.Count == 2); } [Test] @@ -131,13 +134,13 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); AddStep("rearrange", () => { - var item = Room.Playlist[0]; - Room.Playlist.RemoveAt(0); - Room.Playlist.Add(item); + var item = SelectedRoom.Value.Playlist[0]; + SelectedRoom.Value.Playlist.RemoveAt(0); + SelectedRoom.Value.Playlist.Add(item); }); AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem()); - AddAssert("new item has id 2", () => Room.Playlist.Last().ID == 2); + AddAssert("new item has id 2", () => SelectedRoom.Value.Playlist.Last().ID == 2); } /// @@ -151,8 +154,8 @@ namespace osu.Game.Tests.Visual.Multiplayer 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)); + AddAssert("item 1 has rate 1.5", () => Precision.AlmostEquals(1.5, ((OsuModDoubleTime)SelectedRoom.Value.Playlist.First().RequiredMods[0]).SpeedChange.Value)); + AddAssert("item 2 has rate 2", () => Precision.AlmostEquals(2, ((OsuModDoubleTime)SelectedRoom.Value.Playlist.Last().RequiredMods[0]).SpeedChange.Value)); } /// @@ -174,7 +177,7 @@ namespace osu.Game.Tests.Visual.Multiplayer 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)); + AddAssert("item has rate 1.5", () => Precision.AlmostEquals(1.5, ((OsuModDoubleTime)SelectedRoom.Value.Playlist.First().RequiredMods[0]).SpeedChange.Value)); } private class TestPlaylistsSongSelect : PlaylistsSongSelect diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs index cec40635f3..8c4133418c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneRoomStatus.cs @@ -2,8 +2,12 @@ // 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.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Screens.OnlinePlay.Lounge.Components; @@ -12,40 +16,66 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneRoomStatus : OsuTestScene { - public TestSceneRoomStatus() + [Test] + public void TestMultipleStatuses() { - Child = new FillFlowContainer + AddStep("create rooms", () => { - RelativeSizeAxes = Axes.Both, - Width = 0.5f, - Children = new Drawable[] + Child = new FillFlowContainer { - new DrawableRoom(new Room + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Children = new Drawable[] { - Name = { Value = "Open - ending in 1 day" }, - Status = { Value = new RoomStatusOpen() }, - EndDate = { Value = DateTimeOffset.Now.AddDays(1) } - }) { MatchingFilter = true }, - new DrawableRoom(new Room - { - Name = { Value = "Playing - ending in 1 day" }, - Status = { Value = new RoomStatusPlaying() }, - EndDate = { Value = DateTimeOffset.Now.AddDays(1) } - }) { MatchingFilter = true }, - new DrawableRoom(new Room - { - Name = { Value = "Ended" }, - Status = { Value = new RoomStatusEnded() }, - EndDate = { Value = DateTimeOffset.Now } - }) { MatchingFilter = true }, - new DrawableRoom(new Room - { - Name = { Value = "Open" }, - Status = { Value = new RoomStatusOpen() }, - Category = { Value = RoomCategory.Realtime } - }) { MatchingFilter = true }, - } - }; + new DrawableRoom(new Room + { + Name = { Value = "Open - ending in 1 day" }, + Status = { Value = new RoomStatusOpen() }, + EndDate = { Value = DateTimeOffset.Now.AddDays(1) } + }) { MatchingFilter = true }, + new DrawableRoom(new Room + { + Name = { Value = "Playing - ending in 1 day" }, + Status = { Value = new RoomStatusPlaying() }, + EndDate = { Value = DateTimeOffset.Now.AddDays(1) } + }) { MatchingFilter = true }, + new DrawableRoom(new Room + { + Name = { Value = "Ended" }, + Status = { Value = new RoomStatusEnded() }, + EndDate = { Value = DateTimeOffset.Now } + }) { MatchingFilter = true }, + new DrawableRoom(new Room + { + Name = { Value = "Open" }, + Status = { Value = new RoomStatusOpen() }, + Category = { Value = RoomCategory.Realtime } + }) { MatchingFilter = true }, + } + }; + }); + } + + [Test] + public void TestEnableAndDisablePassword() + { + DrawableRoom drawableRoom = null; + Room room = null; + + AddStep("create room", () => Child = drawableRoom = new DrawableRoom(room = new Room + { + Name = { Value = "Room with password" }, + Status = { Value = new RoomStatusOpen() }, + Category = { Value = RoomCategory.Realtime }, + }) { MatchingFilter = true }); + + AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha)); + + AddStep("set password", () => room.Password.Value = "password"); + AddAssert("password icon visible", () => Precision.AlmostEquals(1, drawableRoom.ChildrenOfType().Single().Alpha)); + + AddStep("unset password", () => room.Password.Value = string.Empty); + AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha)); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.cs new file mode 100644 index 0000000000..cdeafdc9a3 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneStarRatingRangeDisplay.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.Beatmaps; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneStarRatingRangeDisplay : OnlinePlayTestScene + { + [SetUp] + public new void Setup() => Schedule(() => + { + SelectedRoom.Value = new Room(); + + Child = new StarRatingRangeDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + }); + + [Test] + public void TestRange([Values(0, 2, 3, 4, 6, 7)] double min, [Values(0, 2, 3, 4, 6, 7)] double max) + { + AddStep("set playlist", () => + { + SelectedRoom.Value.Playlist.AddRange(new[] + { + new PlaylistItem { Beatmap = { Value = new BeatmapInfo { StarDifficulty = min } } }, + new PlaylistItem { Beatmap = { Value = new BeatmapInfo { StarDifficulty = max } } }, + }); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs index 3cedaf9d45..4ec76e1e4b 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePerformFromScreen.cs @@ -11,6 +11,7 @@ using osu.Game.Overlays; using osu.Game.Screens; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; +using osu.Game.Tests.Beatmaps.IO; using osuTK.Input; using static osu.Game.Tests.Visual.Navigation.TestSceneScreenNavigation; @@ -57,8 +58,10 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPerformAtSongSelectFromPlayerLoader() { - PushAndConfirm(() => new TestPlaySongSelect()); - PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer())); + importAndWaitForSongSelect(); + + AddStep("Press enter", () => InputManager.Key(Key.Enter)); + AddUntilStep("Wait for new screen", () => Game.ScreenStack.CurrentScreen is PlayerLoader); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true, new[] { typeof(TestPlaySongSelect) })); AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is TestPlaySongSelect); @@ -68,8 +71,10 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPerformAtMenuFromPlayerLoader() { - PushAndConfirm(() => new TestPlaySongSelect()); - PushAndConfirm(() => new PlayerLoader(() => new SoloPlayer())); + importAndWaitForSongSelect(); + + AddStep("Press enter", () => InputManager.Key(Key.Enter)); + AddUntilStep("Wait for new screen", () => Game.ScreenStack.CurrentScreen is PlayerLoader); AddStep("try to perform", () => Game.PerformFromScreen(_ => actionPerformed = true)); AddUntilStep("returned to song select", () => Game.ScreenStack.CurrentScreen is MainMenu); @@ -165,6 +170,13 @@ namespace osu.Game.Tests.Visual.Navigation } } + private void importAndWaitForSongSelect() + { + AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait()); + PushAndConfirm(() => new TestPlaySongSelect()); + AddUntilStep("beatmap updated", () => Game.Beatmap.Value.BeatmapSetInfo.OnlineBeatmapSetID == 241526); + } + public class DialogBlockingScreen : OsuScreen { [Resolved] diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 253e448bb4..52401d32e5 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -9,15 +9,19 @@ 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.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Overlays.Toolbar; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Options; using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Visual.Multiplayer; using osuTK; using osuTK.Input; @@ -95,11 +99,12 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); - AddStep("set autoplay", () => Game.SelectedMods.Value = new[] { new OsuModAutoplay() }); + AddStep("set mods", () => Game.SelectedMods.Value = new Mod[] { new OsuModNoFail(), new OsuModDoubleTime { SpeedChange = { Value = 2 } } }); AddStep("press enter", () => InputManager.Key(Key.Enter)); AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null); - AddStep("seek to end", () => player.ChildrenOfType().First().Seek(beatmap().Track.Length)); + AddUntilStep("wait for track playing", () => beatmap().Track.IsRunning); + AddStep("seek to near end", () => player.ChildrenOfType().First().Seek(beatmap().Beatmap.HitObjects[^1].StartTime - 1000)); AddUntilStep("wait for pass", () => (results = Game.ScreenStack.CurrentScreen as ResultsScreen) != null && results.IsLoaded); AddStep("attempt to retry", () => results.ChildrenOfType().First().Action()); AddUntilStep("wait for player", () => Game.ScreenStack.CurrentScreen != player && Game.ScreenStack.CurrentScreen is Player); @@ -150,6 +155,14 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for track", () => !Game.MusicController.CurrentTrack.IsDummyDevice && Game.MusicController.IsPlaying); } + [Test] + public void TestPushSongSelectAndPressBackButtonImmediately() + { + AddStep("push song select", () => Game.ScreenStack.Push(new TestPlaySongSelect())); + AddStep("press back button", () => Game.ChildrenOfType().First().Action()); + AddWaitStep("wait two frames", 2); + } + [Test] public void TestExitSongSelectWithClick() { @@ -296,6 +309,18 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Toolbar is hidden", () => Game.Toolbar.State.Value == Visibility.Hidden); } + [Test] + public void TestPushMatchSubScreenAndPressBackButtonImmediately() + { + TestMultiplayer multiplayer = null; + + PushAndConfirm(() => multiplayer = new TestMultiplayer()); + + AddStep("open room", () => multiplayer.OpenNewRoom()); + AddStep("press back button", () => Game.ChildrenOfType().First().Action()); + AddWaitStep("wait two frames", 2); + } + private void pushEscape() => AddStep("Press escape", () => InputManager.Key(Key.Escape)); @@ -320,5 +345,18 @@ namespace osu.Game.Tests.Visual.Navigation protected override bool DisplayStableImportPrompt => false; } + + private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer + { + [Cached(typeof(MultiplayerClient))] + public readonly TestMultiplayerClient Client; + + public TestMultiplayer() + { + Client = new TestMultiplayerClient((TestMultiplayerRoomManager)RoomManager); + } + + protected override RoomManager CreateRoomManager() => new TestMultiplayerRoomManager(); + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs index 156d6b744e..5bfb676f81 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.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 NUnit.Framework; @@ -14,6 +15,8 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Users; namespace osu.Game.Tests.Visual.Online { @@ -23,6 +26,8 @@ namespace osu.Game.Tests.Visual.Online private BeatmapListingOverlay overlay; + private BeatmapListingSearchControl searchControl => overlay.ChildrenOfType().Single(); + [BackgroundDependencyLoader] private void load() { @@ -39,6 +44,16 @@ namespace osu.Game.Tests.Visual.Online return true; }; + + AddStep("initialize dummy", () => + { + // non-supporter user + ((DummyAPIAccess)API).LocalUser.Value = new User + { + Username = "TestBot", + Id = API.LocalUser.Value.Id + 1, + }; + }); } [Test] @@ -58,13 +73,164 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); } + [Test] + public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithoutResults() + { + AddStep("fetch for 0 beatmaps", () => fetchFor()); + AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false); + + // only Rank Achieved filter + setRankAchievedFilter(new[] { ScoreRank.XH }); + supporterRequiredPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + notFoundPlaceholderShown(); + + // only Played filter + setPlayedFilter(SearchPlayed.Played); + supporterRequiredPlaceholderShown(); + + setPlayedFilter(SearchPlayed.Any); + notFoundPlaceholderShown(); + + // both RankAchieved and Played filters + setRankAchievedFilter(new[] { ScoreRank.XH }); + setPlayedFilter(SearchPlayed.Played); + supporterRequiredPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + setPlayedFilter(SearchPlayed.Any); + notFoundPlaceholderShown(); + } + + [Test] + public void TestUserWithSupporterUsesSupporterOnlyFiltersWithoutResults() + { + AddStep("fetch for 0 beatmaps", () => fetchFor()); + AddStep("set dummy as supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = true); + + // only Rank Achieved filter + setRankAchievedFilter(new[] { ScoreRank.XH }); + notFoundPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + notFoundPlaceholderShown(); + + // only Played filter + setPlayedFilter(SearchPlayed.Played); + notFoundPlaceholderShown(); + + setPlayedFilter(SearchPlayed.Any); + notFoundPlaceholderShown(); + + // both Rank Achieved and Played filters + setRankAchievedFilter(new[] { ScoreRank.XH }); + setPlayedFilter(SearchPlayed.Played); + notFoundPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + setPlayedFilter(SearchPlayed.Any); + notFoundPlaceholderShown(); + } + + [Test] + public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithResults() + { + AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet)); + AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false); + + // only Rank Achieved filter + setRankAchievedFilter(new[] { ScoreRank.XH }); + supporterRequiredPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + noPlaceholderShown(); + + // only Played filter + setPlayedFilter(SearchPlayed.Played); + supporterRequiredPlaceholderShown(); + + setPlayedFilter(SearchPlayed.Any); + noPlaceholderShown(); + + // both Rank Achieved and Played filters + setRankAchievedFilter(new[] { ScoreRank.XH }); + setPlayedFilter(SearchPlayed.Played); + supporterRequiredPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + setPlayedFilter(SearchPlayed.Any); + noPlaceholderShown(); + } + + [Test] + public void TestUserWithSupporterUsesSupporterOnlyFiltersWithResults() + { + AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet)); + AddStep("set dummy as supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = true); + + // only Rank Achieved filter + setRankAchievedFilter(new[] { ScoreRank.XH }); + noPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + noPlaceholderShown(); + + // only Played filter + setPlayedFilter(SearchPlayed.Played); + noPlaceholderShown(); + + setPlayedFilter(SearchPlayed.Any); + noPlaceholderShown(); + + // both Rank Achieved and Played filters + setRankAchievedFilter(new[] { ScoreRank.XH }); + setPlayedFilter(SearchPlayed.Played); + noPlaceholderShown(); + + setRankAchievedFilter(Array.Empty()); + setPlayedFilter(SearchPlayed.Any); + noPlaceholderShown(); + } + private void fetchFor(params BeatmapSetInfo[] beatmaps) { setsForResponse.Clear(); setsForResponse.AddRange(beatmaps.Select(b => new TestAPIBeatmapSet(b))); // trigger arbitrary change for fetching. - overlay.ChildrenOfType().Single().Query.TriggerChange(); + searchControl.Query.TriggerChange(); + } + + private void setRankAchievedFilter(ScoreRank[] ranks) + { + AddStep($"set Rank Achieved filter to [{string.Join(',', ranks)}]", () => + { + searchControl.Ranks.Clear(); + searchControl.Ranks.AddRange(ranks); + }); + } + + private void setPlayedFilter(SearchPlayed played) + { + AddStep($"set Played filter to {played}", () => searchControl.Played.Value = played); + } + + private void supporterRequiredPlaceholderShown() + { + AddUntilStep("\"supporter required\" placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + } + + private void notFoundPlaceholderShown() + { + AddUntilStep("\"no maps found\" placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + } + + private void noPlaceholderShown() + { + AddUntilStep("no placeholder shown", () => + !overlay.ChildrenOfType().Any() + && !overlay.ChildrenOfType().Any()); } private class TestAPIBeatmapSet : APIBeatmapSet diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index b13dd34ebc..a1549dfbce 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -9,6 +9,8 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; +using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; @@ -36,6 +38,10 @@ namespace osu.Game.Tests.Visual.Online private Channel previousChannel => joinedChannels.ElementAt(joinedChannels.ToList().IndexOf(currentChannel) - 1); private Channel channel1 => channels[0]; private Channel channel2 => channels[1]; + private Channel channel3 => channels[2]; + + [Resolved] + private GameHost host { get; set; } public TestSceneChatOverlay() { @@ -44,7 +50,8 @@ namespace osu.Game.Tests.Visual.Online { Name = $"Channel no. {index}", Topic = index == 3 ? null : $"We talk about the number {index} here", - Type = index % 2 == 0 ? ChannelType.PM : ChannelType.Temporary + Type = index % 2 == 0 ? ChannelType.PM : ChannelType.Temporary, + Id = index }) .ToList(); } @@ -75,7 +82,8 @@ namespace osu.Game.Tests.Visual.Online { switch (req) { - case JoinChannelRequest _: + case JoinChannelRequest joinChannel: + joinChannel.TriggerSuccess(); return true; } @@ -228,6 +236,92 @@ namespace osu.Game.Tests.Visual.Online AddAssert("All channels closed", () => !channelManager.JoinedChannels.Any()); } + [Test] + public void TestCloseTabShortcut() + { + AddStep("Join 2 channels", () => + { + channelManager.JoinChannel(channel1); + channelManager.JoinChannel(channel2); + }); + + // Want to close channel 2 + AddStep("Select channel 2", () => clickDrawable(chatOverlay.TabMap[channel2])); + AddStep("Close tab via shortcut", pressCloseDocumentKeys); + + // Channel 2 should be closed + AddAssert("Channel 1 open", () => channelManager.JoinedChannels.Contains(channel1)); + AddAssert("Channel 2 closed", () => !channelManager.JoinedChannels.Contains(channel2)); + + // Want to close channel 1 + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + + AddStep("Close tab via shortcut", pressCloseDocumentKeys); + // Channel 1 and channel 2 should be closed + AddAssert("All channels closed", () => !channelManager.JoinedChannels.Any()); + } + + [Test] + public void TestNewTabShortcut() + { + AddStep("Join 2 channels", () => + { + channelManager.JoinChannel(channel1); + channelManager.JoinChannel(channel2); + }); + + // Want to join another channel + AddStep("Press new tab shortcut", pressNewTabKeys); + + // Selector should be visible + AddAssert("Selector is visible", () => chatOverlay.SelectionOverlayState == Visibility.Visible); + } + + [Test] + public void TestRestoreTabShortcut() + { + AddStep("Join 3 channels", () => + { + channelManager.JoinChannel(channel1); + channelManager.JoinChannel(channel2); + channelManager.JoinChannel(channel3); + }); + + // Should do nothing + AddStep("Restore tab via shortcut", pressRestoreTabKeys); + AddAssert("All channels still open", () => channelManager.JoinedChannels.Count == 3); + + // Close channel 1 + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + AddStep("Click normal close button", () => clickDrawable(((TestChannelTabItem)chatOverlay.TabMap[channel1]).CloseButton.Child)); + AddAssert("Channel 1 closed", () => !channelManager.JoinedChannels.Contains(channel1)); + AddAssert("Other channels still open", () => channelManager.JoinedChannels.Count == 2); + + // Reopen channel 1 + AddStep("Restore tab via shortcut", pressRestoreTabKeys); + AddAssert("All channels now open", () => channelManager.JoinedChannels.Count == 3); + AddAssert("Current channel is channel 1", () => currentChannel == channel1); + + // Close two channels + AddStep("Select channel 1", () => clickDrawable(chatOverlay.TabMap[channel1])); + AddStep("Close channel 1", () => clickDrawable(((TestChannelTabItem)chatOverlay.TabMap[channel1]).CloseButton.Child)); + AddStep("Select channel 2", () => clickDrawable(chatOverlay.TabMap[channel2])); + AddStep("Close channel 2", () => clickDrawable(((TestPrivateChannelTabItem)chatOverlay.TabMap[channel2]).CloseButton.Child)); + AddAssert("Only one channel open", () => channelManager.JoinedChannels.Count == 1); + AddAssert("Current channel is channel 3", () => currentChannel == channel3); + + // Should first re-open channel 2 + AddStep("Restore tab via shortcut", pressRestoreTabKeys); + AddAssert("Channel 1 still closed", () => !channelManager.JoinedChannels.Contains(channel1)); + AddAssert("Channel 2 now open", () => channelManager.JoinedChannels.Contains(channel2)); + AddAssert("Current channel is channel 2", () => currentChannel == channel2); + + // Should then re-open channel 1 + AddStep("Restore tab via shortcut", pressRestoreTabKeys); + AddAssert("All channels now open", () => channelManager.JoinedChannels.Count == 3); + AddAssert("Current channel is channel 1", () => currentChannel == channel1); + } + private void pressChannelHotkey(int number) { var channelKey = Key.Number0 + number; @@ -236,6 +330,23 @@ namespace osu.Game.Tests.Visual.Online InputManager.ReleaseKey(Key.AltLeft); } + private void pressCloseDocumentKeys() => pressKeysFor(PlatformActionType.DocumentClose); + + private void pressNewTabKeys() => pressKeysFor(PlatformActionType.TabNew); + + private void pressRestoreTabKeys() => pressKeysFor(PlatformActionType.TabRestore); + + private void pressKeysFor(PlatformActionType type) + { + var binding = host.PlatformKeyBindings.First(b => ((PlatformAction)b.Action).ActionType == type); + + foreach (var k in binding.KeyCombination.Keys) + InputManager.PressKey((Key)k); + + foreach (var k in binding.KeyCombination.Keys) + InputManager.ReleaseKey((Key)k); + } + private void clickDrawable(Drawable d) { InputManager.MoveMouseTo(d); diff --git a/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs new file mode 100644 index 0000000000..d193856217 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneMessageNotifier.cs @@ -0,0 +1,240 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.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.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Users; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneMessageNotifier : OsuManualInputManagerTestScene + { + private User friend; + private Channel publicChannel; + private Channel privateMessageChannel; + private TestContainer testContainer; + + private int messageIdCounter; + + [SetUp] + public void Setup() + { + if (API is DummyAPIAccess daa) + { + daa.HandleRequest = dummyAPIHandleRequest; + } + + friend = new User { Id = 0, Username = "Friend" }; + publicChannel = new Channel { Id = 1, Name = "osu" }; + privateMessageChannel = new Channel(friend) { Id = 2, Name = friend.Username, Type = ChannelType.PM }; + + Schedule(() => + { + Child = testContainer = new TestContainer(new[] { publicChannel, privateMessageChannel }) + { + RelativeSizeAxes = Axes.Both, + }; + + testContainer.ChatOverlay.Show(); + }); + } + + private bool dummyAPIHandleRequest(APIRequest request) + { + switch (request) + { + case GetMessagesRequest messagesRequest: + messagesRequest.TriggerSuccess(new List(0)); + return true; + + case CreateChannelRequest createChannelRequest: + var apiChatChannel = new APIChatChannel + { + RecentMessages = new List(0), + ChannelID = (int)createChannelRequest.Channel.Id + }; + createChannelRequest.TriggerSuccess(apiChatChannel); + return true; + + case ListChannelsRequest listChannelsRequest: + listChannelsRequest.TriggerSuccess(new List(1) { publicChannel }); + return true; + + case GetUpdatesRequest updatesRequest: + updatesRequest.TriggerSuccess(new GetUpdatesResponse + { + Messages = new List(0), + Presence = new List(0) + }); + return true; + + case JoinChannelRequest joinChannelRequest: + joinChannelRequest.TriggerSuccess(); + return true; + + default: + return false; + } + } + + [Test] + public void TestPublicChannelMention() + { + AddStep("switch to PMs", () => testContainer.ChannelManager.CurrentChannel.Value = privateMessageChannel); + + AddStep("receive public message", () => receiveMessage(friend, publicChannel, "Hello everyone")); + AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0); + + AddStep("receive message containing mention", () => receiveMessage(friend, publicChannel, $"Hello {API.LocalUser.Value.Username.ToLowerInvariant()}!")); + AddAssert("1 notification fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 1); + + AddStep("open notification overlay", () => testContainer.NotificationOverlay.Show()); + AddStep("click notification", clickNotification); + + AddAssert("chat overlay is open", () => testContainer.ChatOverlay.State.Value == Visibility.Visible); + AddAssert("public channel is selected", () => testContainer.ChannelManager.CurrentChannel.Value == publicChannel); + } + + [Test] + public void TestPrivateMessageNotification() + { + AddStep("switch to public channel", () => testContainer.ChannelManager.CurrentChannel.Value = publicChannel); + + AddStep("receive PM", () => receiveMessage(friend, privateMessageChannel, $"Hello {API.LocalUser.Value.Username}")); + AddAssert("1 notification fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 1); + + AddStep("open notification overlay", () => testContainer.NotificationOverlay.Show()); + AddStep("click notification", clickNotification); + + AddAssert("chat overlay is open", () => testContainer.ChatOverlay.State.Value == Visibility.Visible); + AddAssert("PM channel is selected", () => testContainer.ChannelManager.CurrentChannel.Value == privateMessageChannel); + } + + [Test] + public void TestNoNotificationWhenPMChannelOpen() + { + AddStep("switch to PMs", () => testContainer.ChannelManager.CurrentChannel.Value = privateMessageChannel); + + AddStep("receive PM", () => receiveMessage(friend, privateMessageChannel, "you're reading this, right?")); + + AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0); + } + + [Test] + public void TestNoNotificationWhenMentionedInOpenPublicChannel() + { + AddStep("switch to public channel", () => testContainer.ChannelManager.CurrentChannel.Value = publicChannel); + + AddStep("receive mention", () => receiveMessage(friend, publicChannel, $"{API.LocalUser.Value.Username.ToUpperInvariant()} has been reading this")); + + AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0); + } + + [Test] + public void TestNoNotificationOnSelfMention() + { + AddStep("switch to PM channel", () => testContainer.ChannelManager.CurrentChannel.Value = privateMessageChannel); + + AddStep("receive self-mention", () => receiveMessage(API.LocalUser.Value, publicChannel, $"my name is {API.LocalUser.Value.Username}")); + + AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0); + } + + [Test] + public void TestNoNotificationOnPMFromSelf() + { + AddStep("switch to public channel", () => testContainer.ChannelManager.CurrentChannel.Value = publicChannel); + + AddStep("receive PM from self", () => receiveMessage(API.LocalUser.Value, privateMessageChannel, "hey hey")); + + AddAssert("no notifications fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 0); + } + + [Test] + public void TestNotificationsNotFiredTwice() + { + AddStep("switch to public channel", () => testContainer.ChannelManager.CurrentChannel.Value = publicChannel); + + AddStep("receive same PM twice", () => + { + var message = createMessage(friend, privateMessageChannel, "hey hey"); + privateMessageChannel.AddNewMessages(message, message); + }); + + AddStep("open notification overlay", () => testContainer.NotificationOverlay.Show()); + AddAssert("1 notification fired", () => testContainer.NotificationOverlay.UnreadCount.Value == 1); + } + + private void receiveMessage(User sender, Channel channel, string content) => channel.AddNewMessages(createMessage(sender, channel, content)); + + private Message createMessage(User sender, Channel channel, string content) => new Message(messageIdCounter++) + { + Content = content, + Sender = sender, + ChannelId = channel.Id + }; + + private void clickNotification() where T : Notification + { + var notification = testContainer.NotificationOverlay.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(notification); + InputManager.Click(MouseButton.Left); + } + + private class TestContainer : Container + { + [Cached] + public ChannelManager ChannelManager { get; } = new ChannelManager(); + + [Cached] + public NotificationOverlay NotificationOverlay { get; } = new NotificationOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }; + + [Cached] + public ChatOverlay ChatOverlay { get; } = new ChatOverlay(); + + private readonly MessageNotifier messageNotifier = new MessageNotifier(); + + private readonly Channel[] channels; + + public TestContainer(Channel[] channels) + { + this.channels = channels; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + ChannelManager, + ChatOverlay, + NotificationOverlay, + messageNotifier, + }; + + ((BindableList)ChannelManager.AvailableChannels).AddRange(channels); + + foreach (var channel in channels) + ChannelManager.JoinChannel(channel); + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs index 863fa48ddf..e7e6030c66 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Wiki; @@ -96,7 +97,7 @@ namespace osu.Game.Tests.Visual.Online private class TestHeader : WikiHeader { - public IReadOnlyList TabControlItems => TabControl.Items; + public IReadOnlyList TabControlItems => TabControl.Items; } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs index 9d8f07969c..af2e4fc91a 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiMarkdownContainer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using Markdig.Syntax.Inlines; using NUnit.Framework; using osu.Framework.Allocation; @@ -9,6 +10,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers.Markdown; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Graphics.Containers.Markdown; using osu.Game.Overlays; using osu.Game.Overlays.Wiki.Markdown; @@ -102,7 +106,7 @@ needs_cleanup: true { AddStep("Add absolute image", () => { - markdownContainer.DocumentUrl = "https://dev.ppy.sh"; + markdownContainer.CurrentPath = "https://dev.ppy.sh"; markdownContainer.Text = "![intro](/wiki/Interface/img/intro-screen.jpg)"; }); } @@ -112,8 +116,7 @@ needs_cleanup: true { AddStep("Add relative image", () => { - markdownContainer.DocumentUrl = "https://dev.ppy.sh"; - markdownContainer.CurrentPath = $"{API.WebsiteRootUrl}/wiki/Interface/"; + markdownContainer.CurrentPath = "https://dev.ppy.sh/wiki/Interface/"; markdownContainer.Text = "![intro](img/intro-screen.jpg)"; }); } @@ -123,8 +126,7 @@ needs_cleanup: true { AddStep("Add paragraph with block image", () => { - markdownContainer.DocumentUrl = "https://dev.ppy.sh"; - markdownContainer.CurrentPath = $"{API.WebsiteRootUrl}/wiki/Interface/"; + markdownContainer.CurrentPath = "https://dev.ppy.sh/wiki/Interface/"; markdownContainer.Text = @"Line before image ![play menu](img/play-menu.jpg ""Main Menu in osu!"") @@ -138,20 +140,57 @@ Line after image"; { AddStep("Add inline image", () => { - markdownContainer.DocumentUrl = "https://dev.ppy.sh"; + markdownContainer.CurrentPath = "https://dev.ppy.sh"; markdownContainer.Text = "![osu! mode icon](/wiki/shared/mode/osu.png) osu!"; }); } + [Test] + public void TestTableWithImageContent() + { + AddStep("Add Table", () => + { + markdownContainer.CurrentPath = "https://dev.ppy.sh"; + markdownContainer.Text = @" +| Image | Name | Effect | +| :-: | :-: | :-- | +| ![](/wiki/Skinning/Interface/img/hit300.png ""300"") | 300 | A possible score when tapping a hit circle precisely on time, completing a Slider and keeping the cursor over every tick, or completing a Spinner with the Spinner Metre full. A score of 300 appears in an blue score by default. Scoring nothing except 300s in a beatmap will award the player with the SS or SSH grade. | +| ![](/wiki/Skinning/Interface/img/hit300g.png ""Geki"") | (激) Geki | A term from Ouendan, called Elite Beat! in EBA. Appears when playing the last element in a combo in which the player has scored only 300s. Getting a Geki will give a sizable boost to the Life Bar. By default, it is blue. | +| ![](/wiki/Skinning/Interface/img/hit100.png ""100"") | 100 | A possible score one can get when tapping a Hit Object slightly late or early, completing a Slider and missing a number of ticks, or completing a Spinner with the Spinner Meter almost full. A score of 100 appears in a green score by default. When very skilled players test a beatmap and they get a lot of 100s, this may mean that the beatmap does not have correct timing. | +| ![](/wiki/Skinning/Interface/img/hit300k.png ""300 Katu"") ![](/wiki/Skinning/Interface/img/hit100k.png ""100 Katu"") | (喝) Katu or Katsu | A term from Ouendan, called Beat! in EBA. Appears when playing the last element in a combo in which the player has scored at least one 100, but no 50s or misses. Getting a Katu will give a small boost to the Life Bar. By default, it is coloured green or blue depending on whether the Katu itself is a 100 or a 300. | +| ![](/wiki/Skinning/Interface/img/hit50.png ""50"") | 50 | A possible score one can get when tapping a hit circle rather early or late but not early or late enough to cause a miss, completing a Slider and missing a lot of ticks, or completing a Spinner with the Spinner Metre close to full. A score of 50 appears in a orange score by default. Scoring a 50 in a combo will prevent the appearance of a Katu or a Geki at the combo's end. | +| ![](/wiki/Skinning/Interface/img/hit0.png ""Miss"") | Miss | A possible score one can get when not tapping a hit circle or too early (based on OD and AR, it may *shake* instead), not tapping or holding the Slider at least once, or completing a Spinner with low Spinner Metre fill. Scoring a Miss will reset the current combo to 0 and will prevent the appearance of a Katu or a Geki at the combo's end. | +"; + }); + } + + [Test] + public void TestWideImageNotExceedContainer() + { + AddStep("Add image", () => + { + markdownContainer.CurrentPath = "https://dev.ppy.sh/wiki/osu!_Program_Files/"; + markdownContainer.Text = "![](img/file_structure.jpg \"The file structure of osu!'s installation folder, on Windows and macOS\")"; + }); + + AddUntilStep("Wait image to load", () => markdownContainer.ChildrenOfType().First().DelayedLoadCompleted); + + AddStep("Change container width", () => + { + markdownContainer.Width = 0.5f; + }); + + AddAssert("Image not exceed container width", () => + { + var spriteImage = markdownContainer.ChildrenOfType().First(); + return Precision.DefinitelyBigger(markdownContainer.DrawWidth, spriteImage.DrawWidth); + }); + } + private class TestMarkdownContainer : WikiMarkdownContainer { public LinkInline Link; - public new string DocumentUrl - { - set => base.DocumentUrl = value; - } - public override MarkdownTextFlowContainer CreateTextFlow() => new TestMarkdownTextFlowContainer { UrlAdded = link => Link = link, diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs index da4bf82948..3506d459ce 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiOverlay.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.Net; using NUnit.Framework; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -29,10 +30,17 @@ namespace osu.Game.Tests.Visual.Online public void TestArticlePage() { setUpWikiResponse(responseArticlePage); - AddStep("Show Article Page", () => wiki.ShowPage("Interface")); + AddStep("Show Article Page", () => wiki.ShowPage("Article_styling_criteria/Formatting")); } - private void setUpWikiResponse(APIWikiPage r) + [Test] + public void TestErrorPage() + { + setUpWikiResponse(null, true); + AddStep("Show Error Page", () => wiki.ShowPage("Error")); + } + + private void setUpWikiResponse(APIWikiPage r, bool isFailed = false) => AddStep("set up response", () => { dummyAPI.HandleRequest = request => @@ -40,7 +48,11 @@ namespace osu.Game.Tests.Visual.Online if (!(request is GetWikiRequest getWikiRequest)) return false; - getWikiRequest.TriggerSuccess(r); + if (isFailed) + getWikiRequest.TriggerFailure(new WebException()); + else + getWikiRequest.TriggerSuccess(r); + return true; }; }); @@ -57,16 +69,16 @@ namespace osu.Game.Tests.Visual.Online "---\nlayout: main_page\n---\n\n\n\n
\nWelcome to the osu! wiki, a project containing a wide range of osu! related information.\n
\n\n
\n
\n\n# Getting started\n\n[Welcome](/wiki/Welcome) • [Installation](/wiki/Installation) • [Registration](/wiki/Registration) • [Help Centre](/wiki/Help_Centre) • [FAQ](/wiki/FAQ)\n\n
\n
\n\n# Game client\n\n[Interface](/wiki/Interface) • [Options](/wiki/Options) • [Visual settings](/wiki/Visual_Settings) • [Shortcut key reference](/wiki/Shortcut_key_reference) • [Configuration file](/wiki/osu!_Program_Files/User_Configuration_File) • [Program files](/wiki/osu!_Program_Files)\n\n[File formats](/wiki/osu!_File_Formats): [.osz](/wiki/osu!_File_Formats/Osz_(file_format)) • [.osk](/wiki/osu!_File_Formats/Osk_(file_format)) • [.osr](/wiki/osu!_File_Formats/Osr_(file_format)) • [.osu](/wiki/osu!_File_Formats/Osu_(file_format)) • [.osb](/wiki/osu!_File_Formats/Osb_(file_format)) • [.db](/wiki/osu!_File_Formats/Db_(file_format))\n\n
\n
\n\n# Gameplay\n\n[Game modes](/wiki/Game_mode): [osu!](/wiki/Game_mode/osu!) • [osu!taiko](/wiki/Game_mode/osu!taiko) • [osu!catch](/wiki/Game_mode/osu!catch) • [osu!mania](/wiki/Game_mode/osu!mania)\n\n[Beatmap](/wiki/Beatmap) • [Hit object](/wiki/Hit_object) • [Mods](/wiki/Game_modifier) • [Score](/wiki/Score) • [Replay](/wiki/Replay) • [Multi](/wiki/Multi)\n\n
\n
\n\n# [Beatmap editor](/wiki/Beatmap_Editor)\n\nSections: [Compose](/wiki/Beatmap_Editor/Compose) • [Design](/wiki/Beatmap_Editor/Design) • [Timing](/wiki/Beatmap_Editor/Timing) • [Song setup](/wiki/Beatmap_Editor/Song_Setup)\n\nComponents: [AiMod](/wiki/Beatmap_Editor/AiMod) • [Beat snap divisor](/wiki/Beatmap_Editor/Beat_Snap_Divisor) • [Distance snap](/wiki/Beatmap_Editor/Distance_Snap) • [Menu](/wiki/Beatmap_Editor/Menu) • [SB load](/wiki/Beatmap_Editor/SB_Load) • [Timelines](/wiki/Beatmap_Editor/Timelines)\n\n[Beatmapping](/wiki/Beatmapping) • [Difficulty](/wiki/Beatmap/Difficulty) • [Mapping techniques](/wiki/Mapping_Techniques) • [Storyboarding](/wiki/Storyboarding)\n\n
\n
\n\n# Beatmap submission and ranking\n\n[Submission](/wiki/Submission) • [Modding](/wiki/Modding) • [Ranking procedure](/wiki/Beatmap_ranking_procedure) • [Mappers' Guild](/wiki/Mappers_Guild) • [Project Loved](/wiki/Project_Loved)\n\n[Ranking criteria](/wiki/Ranking_Criteria): [osu!](/wiki/Ranking_Criteria/osu!) • [osu!taiko](/wiki/Ranking_Criteria/osu!taiko) • [osu!catch](/wiki/Ranking_Criteria/osu!catch) • [osu!mania](/wiki/Ranking_Criteria/osu!mania)\n\n
\n
\n\n# Community\n\n[Tournaments](/wiki/Tournaments) • [Skinning](/wiki/Skinning) • [Projects](/wiki/Projects) • [Guides](/wiki/Guides) • [osu!dev Discord server](/wiki/osu!dev_Discord_server) • [How you can help](/wiki/How_You_Can_Help!) • [Glossary](/wiki/Glossary)\n\n
\n
\n\n# People\n\n[The Team](/wiki/People/The_Team): [Developers](/wiki/People/The_Team/Developers) • [Global Moderation Team](/wiki/People/The_Team/Global_Moderation_Team) • [Support Team](/wiki/People/The_Team/Support_Team) • [Nomination Assessment Team](/wiki/People/The_Team/Nomination_Assessment_Team) • [Beatmap Nominators](/wiki/People/The_Team/Beatmap_Nominators) • [osu! Alumni](/wiki/People/The_Team/osu!_Alumni) • [Project Loved Team](/wiki/People/The_Team/Project_Loved_Team)\n\nOrganisations: [osu! UCI](/wiki/Organisations/osu!_UCI)\n\n[Community Contributors](/wiki/People/Community_Contributors) • [Users with unique titles](/wiki/People/Users_with_unique_titles)\n\n
\n
\n\n# For developers\n\n[API](/wiki/osu!api) • [Bot account](/wiki/Bot_account) • [Brand identity guidelines](/wiki/Brand_identity_guidelines)\n\n
\n
\n\n# About the wiki\n\n[Sitemap](/wiki/Sitemap) • [Contribution guide](/wiki/osu!_wiki_Contribution_Guide) • [Article styling criteria](/wiki/Article_Styling_Criteria) • [News styling criteria](/wiki/News_Styling_Criteria)\n\n
\n
\n", }; - // From https://osu.ppy.sh/api/v2/wiki/en/Interface + // From https://osu.ppy.sh/api/v2/wiki/en/Article_styling_criteria/Formatting private APIWikiPage responseArticlePage => new APIWikiPage { - Title = "Interface", + Title = "Formatting", Layout = "markdown_page", - Path = "Interface", + Path = "Article_styling_criteria/Formatting", Locale = "en", - Subtitle = null, + Subtitle = "Article styling criteria", Markdown = - "# Interface\n\n![](img/intro-screen.jpg \"Introduction screen\")\n\n## Main Menu\n\n![](img/main-menu.jpg \"Main Menu\")\n\nThe [osu!cookie](/wiki/Glossary#cookie) \\[1\\] pulses according to the [BPM](/wiki/Beatmapping/Beats_per_minute) of any song currently playing on the main menu. In addition, bars will extend out of the osu!cookie in accordance to the song's volume. If no song is playing, it pulses at a slow 60 BPM. The elements of the main menu are as follows:\n\n- \\[2\\] Click Play (`P`) or the logo to switch to the Solo mode song selection screen.\n- \\[3\\] Click Edit (`E`) to open the Editor mode song selection screen.\n- \\[4\\] Click Options (`O`) to go to the Options screen.\n- \\[5\\] Click Exit (`Esc`) to exit osu!.\n- \\[6\\] A random useful tip is displayed below the menu.\n- \\[7\\] In the lower-left is a link to the osu! website, as well as copyright information.\n- \\[8\\] Connection result to [Bancho](/wiki/Glossary#bancho)! In this picture it is not shown, but the connection result looks like a chain link.\n- \\[9\\] In the bottom right are the chat controls for the extended [chat window](/wiki/Chat_Console) (called \"Player List\" here) and the regular chat window (`F9` & `F8`, respectively).\n- \\[10\\] In the upper right is the osu! jukebox which plays the songs in random order. The top shows the song currently playing. The buttons, from left to right, do as follows:\n - Previous Track\n - Play\n - Pause\n - Stop (the difference between Play and Stop is that Stop will reset the song to the beginning, while Pause simply pauses it)\n - Next Track\n - View Song Info. This toggles the top bar showing the song info between being permanent and temporary. When permanent, the info bar will stay visible until it fades out with the rest of the UI. When temporary, it will disappear a little while after a song has been chosen. It will stay hidden until it is toggled again, or another song plays.\n- \\[11\\] The number of beatmaps you have available, how long your osu!client has been running, and your system clock.\n- \\[12\\] Your profile, click on it to display the User Options (see below).\n\n## User Options\n\n![](img/user-options.jpg \"User Options\")\n\nAccess this screen by clicking your profile at the top left of the main menu. You cannot access the Chat Consoles while viewing the user option screen. You can select any item by pressing the corresponding number on the option:\n\n1. `View Profile`: Opens up your profile page in your default web browser.\n2. `Sign Out`: Sign out of your account (after signing out, the [Options](/wiki/Options) sidebar will prompt you to sign in).\n3. `Change Avatar`: Open up the edit avatar page in your default web browser.\n4. `Close`: Close this dialog\n\n## Play Menu\n\n![](img/play-menu.jpg \"Play Menu\")\n\n- Click `Solo` (`P`) to play alone.\n- Click `Multi` (`M`) to play with other people. You will be directed to the [Multi](/wiki/Multi) Lobby (see below).\n- Click `Back` to return to the main menu.\n\n## Multi Lobby\n\n*Main page: [Multi](/wiki/Multi)*\n\n![](img/multi-lobby.jpg \"Multi Lobby\")\n\n![](img/multi-room.jpg \"Multi Host\")\n\n1. Your rank in the match. This is also shown next to your name.\n2. Your profile information.\n3. The jukebox.\n4. Player list - displays player names, their rank (host or player), their [mods](/wiki/Game_modifier) activated (if any, see \\#7), their osu! ranking, and their team (if applicable).\n5. The name of the match and the password settings.\n6. The beatmap selected. It shows the beatmap as it would in the solo song selection screen.\n7. The [mods](/wiki/Game_modifier) that you have activated (see #12), as well as the option to select them. The option marked \"Free Mods\" toggles whether or not players can select their own mods. If yes, they can pick any combination of mods *except for speed-altering mods like [Double Time](/wiki/Game_modifier/Double_Time)*. If no, the host decides what mods will be used. The host can pick speed-altering mods regardless of whether or not Free Mods is turned on.\n8. The team mode and win conditions.\n9. The ready button.\n10. The [chat console](/wiki/Chat_Console).\n11. The leave button.\n12. Where your activated mods appear.\n\n## Song Selection Screen\n\n![](img/song-selection.jpg \"Song Selection\")\n\nYou can identify the current mode selected by either looking at the icon in the bottom left, above Mode, or by looking at the transparent icon in the center of the screen. These are the four you will see:\n\n- ![](/wiki/shared/mode/osu.png) is [osu!](/wiki/Game_mode/osu!)\n- ![](/wiki/shared/mode/taiko.png) is [osu!taiko](/wiki/Game_mode/osu!taiko)\n- ![](/wiki/shared/mode/catch.png) is [osu!catch](/wiki/Game_mode/osu!catch)\n- ![](/wiki/shared/mode/mania.png) is [osu!mania](/wiki/Game_mode/osu!mania)\n\nBefore continuing on, this screen has too many elements to note with easily, noticeable numbers. The subsections below will focus on one part of the screen at a time, starting from the top down and left to right.\n\n### Beatmap Information\n\n![](img/metadata-comparison.jpg)\n\n![](img/beatmap-metadata.jpg)\n\nThis area displays **information on the beatmap difficulty currently selected.** By default, the beatmap whose song is heard in the osu! jukebox is selected when entering the selection screen. In the top left is the ranked status of the beatmap. The title is next. Normally, the romanised title is shown, but if you select `Prefer metadata in original language` in the [Options](/wiki/Options), it will show the Unicode title; this is shown in the upper picture. The beatmapper is also shown, and beatmap information is shown below. From left to right, the values are as follows:\n\n- **Length**: The total length of the beatmap, from start to finish and including breaks. Not to be confused with [drain time](/wiki/Glossary#drain-time).\n- **BPM**: The BPM of the beatmap. If (like in the lower picture) there are two BPMS and one in parentheses, this means that the BPM changes throughout the song. It shows the slowest and fastest BPMs, and the value in parentheses is the BPM at the start of the beatmap.\n- **Objects**: The total amount of [hit objects](/wiki/Hit_Objects) in the beatmap.\n- **Circles**: The total amount of hit circles in the beatmap.\n- **Sliders**: The total amount of sliders in the beatmap.\n- **Spinners**: The total amount of spinners in the beatmap.\n- **OD**: The Overall Difficulty of the beatmap.\n- **HP**: The drain rate of your HP. In osu!, this is how much of an HP loss you receive upon missing a note, how fast the life bar idly drains, and how much HP is received for hitting a note. In osu!mania, this is the same except there is no idle HP drain. In osu!taiko, this determines how slowly the HP bar fills and how much HP is lost when a note is missed. osu!catch is the same as osu!.\n- **Stars**: The star difficulty of the beatmap. This is graphically visible in the beatmap rectangle itself.\n\n### Group and Sort\n\n![](img/beatmap-filters.jpg)\n\nClick on one of the tabs to **sort your song list according to the selected criterion**.\n\n**Group** - Most options organize beatmaps into various expandable groups:\n\n- `No grouping` - Beatmaps will not be grouped but will still be sorted in the order specified by Sort.\n- `By Difficulty` - Beatmaps will be grouped by their star difficulty, rounded to the nearest whole number.\n- `By Artist` - Beatmaps will be grouped by the artist's first character of their name.\n- `Recently Played` - Beatmaps will be grouped by when you last played them.\n- `Collections` - This will show the collections you have created. *Note that this will hide beatmaps not listed in a collection!*\n- `By BPM` - Beatmaps will be grouped according to BPM in multiples of 60, starting at 120.\n- `By Creator` - Beatmaps will be grouped by the beatmap creator's name's first character.\n- `By Date Added` - Beatmaps will be grouped according to when they were added, from today to 4+ months ago.\n- `By Length` - Beatmaps will be grouped according to their length: 1 minute or less, 2 minutes or less, 3, 4, 5, and 10.\n- `By Mode` - Beatmaps will be grouped according to their game mode.\n- `By Rank Achieved` - Beatmaps will be sorted by the highest rank achieved on them.\n- `By Title` - Beatmaps will be grouped by the first letter of their title.\n- `Favourites` - Only beatmaps you have favorited online will be shown.\n- `My Maps` - Only beatmaps you have mapped (that is, whose creator matches your profile name) will be shown.\n- `Ranked Status` - Beatmaps will be grouped by their ranked status: ranked, pending, not submitted, unknown, or loved.\n\nThe first five groupings are available in tabs below Group and Sort.\n\n**Sort** - Sorts beatmaps in a certain order\n\n- `By Artist` - Beatmaps will be sorted alphabetically by the artist's name's first character.\n- `By BPM` - Beatmaps will be sorted lowest to highest by their BPM. For maps with multiple BPMs, the highest will be used.\n- `By Creator` - Beatmaps will be sorted alphabetically by the creator's name's first character.\n- `By Date Added` - Beatmaps will be sorted from oldest to newest by when they were added.\n- `By Difficulty` - Beatmaps will be sorted from easiest to hardest by star difficulty. *Note that this will split apart mapsets!*\n- `By Length` - Beatmaps will be sorted from shortest to longest by length.\n- `By Rank Achieved` - Beatmaps will be sorted from poorest to best by the highest rank achieved on them.\n- `By Title` - Beatmaps will be sorted alphabetically by the first character of their name.\n\n### Search\n\n![](img/search-bar.jpg)\n\n*Note: You cannot have the chat console or the options sidebar open if you want to search; otherwise, anything you type will be perceived as chat text or as an options search query.*\n\nOnly beatmaps that match the criteria of your search will be shown. By default, any search will be matched against the beatmaps' artists, titles, creators, and tags.\n\nIn addition to searching these fields, you can use filters to search through other metadata by combining one of the supported filters with a comparison to a value (for example, `ar=9`).\n\nSupported filters:\n\n- `artist`: Name of the artist\n- `creator`: Name of the beatmap creator\n- `ar`: Approach Rate\n- `cs`: Circle Size\n- `od`: Overall Difficulty\n- `hp`: HP Drain Rate\n- `keys`: Number of keys (osu!mania and converted beatmaps only)\n- `stars`: Star Difficulty\n- `bpm`: Beats per minute\n- `length`: Length in seconds\n- `drain`: Drain Time in seconds\n- `mode`: Mode. Value can be `osu`, `taiko`, `catchthebeat`, or `mania`, or `o`/`t`/`c`/`m` for short.\n- `status`: Ranked status. Value can be `ranked`, `approved`, `pending`, `notsubmitted`, `unknown`, or `loved`, or `r`/`a`/`p`/`n`/`u`/`l` for short.\n- `played`: Time since last played in days\n- `unplayed`: Shows only unplayed maps. A comparison with no set value must be used. The comparison itself is ignored.\n- `speed`: Saved osu!mania scroll speed. Always 0 for unplayed maps or if the [Remember osu!mania scroll speed per beatmap](/wiki/Options#gameplay) option is off\n\nSupported comparisons:\n\n- `=` or `==`: Equal to\n- `!=`: Not equal to\n- `<`: Less than\n- `>`: Greater than\n- `<=`: Less than or equal to\n- `>=`: Greater than or equal to\n\nYou may also enter a beatmap or beatmapset ID in your search to get a single result.\n\n### Rankings\n\n![](img/leaderboards.jpg)\n\n A variety of things can appear in this space:\n\n- A \"Not Submitted\" box denotes a beatmap that has not been uploaded to the osu! site using the Beatmap Submission System or was deleted by the mapper.\n- An \"Update to latest version\" box appears if there is a new version of the beatmap available for download. Click on the button to update.\n - **Note:** Once you update the beatmap, it cannot be reversed. If you want to preserve the older version for some reason (say, to keep scores), then do not update.\n- A \"Latest pending version\" box appears means that the beatmap has been uploaded to the osu!website but is not ranked yet.\n- If replays matching the view setting of the beatmap exist, they will be displayed instead of a box denoting the ranked/played status of the beatmap. This is shown in the above picture.\n - Under public rankings (e.g. Global, Friends, etc.), your high score will be shown at the bottom, as well as your rank on the leaderboard.\n- A \"No records set!\" box means that there are no replays for the current view setting (this is typically seen in the Local view setting if you just downloaded or edited the beatmap).\n - Note: Scores for Multi are not counted as records.\n\nThese are the view settings:\n\n- Local Ranking\n- Country Ranking\\*\n- Global Ranking\n- Global Ranking (Selected Mods)\\*\n- Friend Ranking\\*\n\n\\*Requires you to be an [osu!supporter](/wiki/osu!supporter) to access them.\n\nClick the word bubble icon to call up the **Quick Web Access** screen for the selected beatmap:\n\n- Press `1` or click the `Beatmap Listing/Scores` button and your default internet browser will pull up the Beatmap Listing and score page of the beatmap set the selected beatmap belongs to.\n- Press `2` or click `Beatmap Modding` and your default internet browser will pull up the modding page of the beatmap set the selected beatmap belongs to.\n- Press `3` or `Esc` or click `Cancel` to return to the Song Selection Screen.\n\nWhile you are on the Quick Web Access Screen, you cannot access the Chat and Extended Chat Consoles.\n\n### Song\n\n![](img/beatmap-cards.jpg)\n\nThe song list displays all available beatmaps. Different beatmaps may have different coloured boxes:\n\n- **Pink**: This beatmap has not been played yet.\n- **Orange**: At least one beatmap from the beatmapset has been completed.\n- **Light Blue**: Other beatmaps in the same set, shown when a mapset is expanded.\n- **White**: Currently selected beatmap.\n\nYou can navigate the beatmap list by using the mouse wheel, using the up and down arrow keys, dragging it while holding the left mouse button or clicking the right mouse button (previously known as Absolute Scrolling), which will move the scroll bar to your mouse's Y position. Click on a box to select that beatmap and display its information on the upper left, high scores (if any) on the left and, if you've cleared it, the letter grade of the highest score you've achieved. Click the box again, press `Enter` or click the osu!cookie at the lower right to begin playing the beatmap.\n\n### Gameplay toolbox\n\n![](img/game-mode-selector.jpg \"List of available game modes\")\n\n![](img/gameplay-toolbox.jpg)\n\nThis section can be called the gameplay toolbox. We will cover each button's use from left to right.\n\nPress `Esc` or click the `Back` button to return to main menu.\n\nClick on the `Mode` button to open up a list of gameplay modes available on osu!. Click on your desired gameplay mode and osu! will switch to that gameplay mode style - the scoreboard will change accordingly. Alternatively, you can press `Ctrl` and `1` (osu!), `2` (osu!taiko), `3` (osu!catch), or `4` (osu!mania) to change the gamemode.\n\nThe background transparent icon and the \"Mode\" box will change to depict what mode is currently selected.\n\n![](img/game-modifiers.jpg \"Mod Selection Screen\")\n\nClick the `Mods` button or press `F1` to open the **[Mod Selection Screen](/wiki/Game_modifier)**.\n\nIn this screen, you can apply modifications (\"mods\" for short) to gameplay. Some mods lower difficulty and apply a multiplier that lowers the score you achieve. Conversely, some mods increase the difficulty, but apply a multiplier that increases the score you achieve. Finally, some mods modify gameplay in a different way. [Relax](/wiki/Game_modifier/Relax) and [Auto Pilot](/wiki/Game_modifier/Autopilot) fall in that category.\n\nPlace your mouse on a mod's icon to see a short description of its effect. Click on an icon to select or deselect that mod. Some mods, like Double Time, have multiple variations; click on the mod again to cycle through. The score multiplier value displays the combined effect the multipliers of the mod(s) of you have selected will have on your score. Click `Reset all mods` or press `1` to deselect all currently selected mods. Click `Close` or press `2` or `Esc` to return to the Song Selection Screen.\n\nWhile you are on the Mod Selection Screen, you cannot access the Chat and Extended Chat Consoles. In addition, skins can alter the text and/or icon of the mods, but the effects will still be the same.\n\nClick the `Random` button or press `F2` to have the game **randomly scroll through all of your beatmaps and pick one.** You cannot select a beatmap yourself until it has finished scrolling.\n\n*Note: You can press `Shift` + the `Random` button or `F2` to go back to the beatmap you had selected before you randomized your selection.*\n\n![](img/beatmap-options.jpg \"Possible commands for a beatmap\")\n\nClick the `Beatmap Options` button, press `F3` or right-click your mouse while hovering over the beatmap to call up the **Beatmap Options Menu for options on the currently selected beatmap**.\n\n- Press `1` or click the `Manage Collections` button to bring up the Collections screen - here, you can manage pre-existing collections, as well as add or remove the currently selected beatmap or mapset to or from a collection.\n- Press `2` or click `Delete...` to delete the \\[1\\] currently selected beatmapset, \\[2\\] delete the currently selected beatmap, or \\[3\\] delete **all VISIBLE beatmaps**.\n - Note that deleted beatmaps are moved to the Recycle Bin.\n- Press `3` or click `Remove from Unplayed` to mark an unplayed beatmap as played (that is, change its box colour from pink to orange).\n- Press `4` or click `Clear local scores` to delete all records of the scores you have achieved in this beatmap.\n- Press `5` or click `Edit` to open the selected beatmap in osu!'s Editor.\n- Press `6` or `Esc` or click `Close` to return to the Song Selection Screen.\n\nClick on **your user panel** to access the **User Options Menu**.\n\nClick the **[osu!cookie](/wiki/Glossary#cookie)** to **start playing the selected beatmap**.\n\n## Results screen\n\n![](img/results-osu.jpg \"Accuracy in osu!\")\n\nThis is the results screen shown after you have successfully passed the beatmap. You can access your online results by scrolling down or pressing the obvious button.\n\n**Note:** The results screen may change depending on the used skin.\n\nBelow are the results screens of the other game modes.\n\n![](img/results-taiko.jpg \"Accuracy in osu!taiko\")\n\n![](img/results-mania.jpg \"Accuracy in osu!mania\")\n\n![](img/results-catch.jpg \"Accuracy in osu!catch\")\n\n### Online Leaderboard\n\n![](img/extended-results-screen.jpg \"An example of an osu!online score\")\n\nThis is your online leaderboard. You can go here by scrolling down from the results screen. Your Local Scoreboard will show your name and the score as usual.\n\n1. Your player bar. It shows your [PP](/wiki/Performance_Points), Global Rank, Total Score, Overall [Accuracy](/wiki/Accuracy), and level bar.\n2. `Save replay to Replays folder`: You can watch the replay later either by opening it from a local leaderboard, or by going to `Replays` directory and double clicking it.\n3. `Add as online favourite`: Include the beatmap into your list of favourites, which is located on your osu! profile page under the \"Beatmaps\" section.\n4. Local Leaderboard: All your results are stored on your computer. To see them, navigate to the [song selection screen](#song-selection-screen), then select `Local Rankings` from the drop-down menu on the left.\n5. `Beatmap Ranking` section. Available only for maps with online leaderboards ([qualified](/wiki/Beatmap/Category#qualified), [ranked](/wiki/Beatmap/Category#ranked), or [loved](/wiki/Beatmap/Category#loved)). You also need to be online to see this section.\n 1. `Overall`: Your position on the map's leaderboard, where you compete against players that used [mods](/wiki/Game_modifier), even if you didn't use any yourself.\n 2. `Accuracy`: How [precisely](/wiki/Accuracy) did you play the beatmap. Will only be counted when your old score is surpassed.\n 3. `Max Combo`: Your longest combo on the map you played.\n 4. `Ranked Score`: Your [best result](/wiki/Score#ranked-score) on the beatmap.\n 5. `Total Score`: Not taken into account, since it does not affect your position in online rankings.\n 6. `Performance`: The amount of [unweighted PP](/wiki/Performance_points#why-didnt-i-gain-the-full-amount-of-pp-from-a-map-i-played) you would receive for the play.\n6. `Overall Ranking` section. It's available only for beatmaps with online leaderboards. You also need to be online to see this section.\n 1. `Overall`: Your global ranking in the world.\n 2. `Accuracy`: Your average [accuracy](/wiki/Accuracy#accuracy) over all beatmaps you have played.\n 3. `Max Combo`: The longest combo over all beatmaps you have played.\n 4. [`Ranked Score`](/wiki/Score#ranked-score): The number of points earned from all ranked beatmaps that you have ever played, with every map being counted exactly once.\n 5. [`Total Score`](/wiki/Score#total-score): Same as ranked score, but it takes into account all beatmaps available on the osu! website, and also underplayed or failed beatmaps. This counts towards your level.\n 6. `Perfomance`: Displays your total amount of Performance Points, and also how many PP the submitted play was worth.\n7. Information about the beatmap with its playcount and pass rate.\n8. Beatmap rating. Use your personal discretion based on whether you enjoy the beatmap or not. Best left alone if you can't decide.\n9. Click here to return to the song selection screen.\n\n![](img/medal-unlock.jpg \"Unlocking a medal\")\n\nAbove is what it looks like to receive a medal.\n", + "# Formatting\n\n*For the writing standards, see: [Article style criteria/Writing](../Writing)*\n\n*Notice: This article uses [RFC 2119](https://tools.ietf.org/html/rfc2119 \"IETF Tools\") to describe requirement levels.*\n\n## Locales\n\nListed below are the properly-supported locales for the wiki:\n\n| File Name | Locale Name | Native Script |\n| :-- | :-- | :-- |\n| `en.md` | English | English |\n| `ar.md` | Arabic | اَلْعَرَبِيَّةُ |\n| `be.md` | Belarusian | Беларуская мова |\n| `bg.md` | Bulgarian | Български |\n| `cs.md` | Czech | Česky |\n| `da.md` | Danish | Dansk |\n| `de.md` | German | Deutsch |\n| `gr.md` | Greek | Ελληνικά |\n| `es.md` | Spanish | Español |\n| `fi.md` | Finnish | Suomi |\n| `fr.md` | French | Français |\n| `hu.md` | Hungarian | Magyar |\n| `id.md` | Indonesian | Bahasa Indonesia |\n| `it.md` | Italian | Italiano |\n| `ja.md` | Japanese | 日本語 |\n| `ko.md` | Korean | 한국어 |\n| `nl.md` | Dutch | Nederlands |\n| `no.md` | Norwegian | Norsk |\n| `pl.md` | Polish | Polski |\n| `pt.md` | Portuguese | Português |\n| `pt-br.md` | Brazilian Portuguese | Português (Brasil) |\n| `ro.md` | Romanian | Română |\n| `ru.md` | Russian | Русский |\n| `sk.md` | Slovak | Slovenčina |\n| `sv.md` | Swedish | Svenska |\n| `th.md` | Thai | ไทย |\n| `tr.md` | Turkish | Türkçe |\n| `uk.md` | Ukrainian | Українська мова |\n| `vi.md` | Vietnamese | Tiếng Việt |\n| `zh.md` | Chinese (Simplified) | 简体中文 |\n| `zh-tw.md` | Traditional Chinese (Taiwan) | 繁體中文(台灣) |\n\n*Note: The website will give readers their selected language's version of an article. If it is not available, the English version will be given.*\n\n### Content parity\n\nTranslations are subject to strict content parity with their English article, in the sense that they must have the same message, regardless of grammar and syntax. Any changes to the translations' meanings must be accompanied by equivalent changes to the English article.\n\nThere are some cases where the content is allowed to differ:\n\n- Articles originally written in a language other than English (in this case, English should act as the translation)\n- Explanations of English words that are common terms in the osu! community\n- External links\n- Tags\n- Subcommunity-specific explanations\n\n## Front matter\n\nFront matter must be placed at the very top of the file. It is written in [YAML](https://en.wikipedia.org/wiki/YAML#Example \"YAML Wikipedia article\") and describes additional information about the article. This must be surrounded by three hyphens (`---`) on the lines above and below it, and an empty line must follow it before the title heading.\n\n### Articles that need help\n\n*Note: Avoid translating English articles with this tag. In addition to this, this tag should be added when the translation needs its own clean up.*\n\nThe `needs_cleanup` tag may be added to articles that need rewriting or formatting help. It is also acceptable to open an issue on GitHub for this purpose. This tag must be written as shown below:\n\n```yaml\nneeds_cleanup: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be done to remove the tag.\n\n### Outdated articles\n\n*Note: Avoid translating English articles with this tag. If the English article has this tag, the translation must also have this tag.*\n\nTranslated articles that are outdated must use the `outdated` tag when the English variant is updated. English articles may also become outdated when the content they contain is misleading or no longer relevant. This tag must be written as shown below:\n\n```yaml\noutdated: true\n```\n\nWhen adding this tag to an article, [comments](#comments) should also be added to explain what needs to be updated to remove the tag.\n\n### Tagging articles\n\nTags help the website's search engine query articles better. Tags should be written in the same language as the article and include the original list of tags. Tags should use lowercase letters where applicable.\n\nFor example, an article called \"Beatmap discussion\" may include the following tags:\n\n```yaml\ntags:\n - beatmap discussions\n - modding V2\n - MV2\n```\n\n### Translations without reviews\n\n*Note: Wiki maintainers will determine and apply this mark prior to merging.*\n\nSometimes, translations are added to the wiki without review from other native speakers of the language. In this case, the `no_native_review` mark is added to let future translators know that it may need to be checked again. This tag must be written as shown below:\n\n```yaml\nno_native_review: true\n```\n\n## Article naming\n\n*See also: [Folder names](#folder-names) and [Titles](#titles)*\n\nArticle titles should be singular and use sentence case. See [Wikipedia's naming conventions article](https://en.wikipedia.org/wiki/Wikipedia:Naming_conventions_(plurals) \"Wikipedia\") for more details.\n\nArticle titles should match the folder name it is in (spaces may replace underscores (`_`) where appropriate). If the folder name changes, the article title should be changed to match it and vice versa.\n\n---\n\nContest and tournament articles are an exception. The folder name must use abbreviations, acronyms, or initialisms. The article's title must be the full name of the contest or tournament.\n\n## Folder and file structure\n\n### Folder names\n\n*See also: [Article naming](#article-naming)*\n\nFolder names must be in English and use sentence case.\n\nFolder names must only use these characters:\n\n- uppercase and lowercase letters\n- numbers\n- underscores (`_`)\n- hyphens (`-`)\n- exclamation marks (`!`)\n\n### Article file names\n\nThe file name of an article can be found in the `File Name` column of the [locales section](#locales). The location of a translated article must be placed in the same folder as the English article.\n\n### Index articles\n\nAn index article must be created if the folder is intended to only hold other articles. Index articles must contain a list of articles that are inside its own folder. They may also contain other information, such as a lead paragraph or descriptions of the linked articles.\n\n### Disambiguation articles\n\n[Disambiguation](/wiki/Disambiguation) articles must be placed in the `/wiki/Disambiguation` folder. The main page must be updated to include the disambiguation article. Refer to [Disambiguation/Mod](/wiki/Disambiguation/Mod) as an example.\n\nRedirects must be updated to have the ambiguous keyword(s) redirect to the disambiguation article.\n\nArticles linked from a disambiguation article must have a [For other uses](#for-other-uses) hatnote.\n\n## HTML\n\nHTML must not be used, with exception for [comments](#comments). The structure of the article must be redone if HTML is used.\n\n### Comments\n\nHTML comments should be used for marking to-dos, but may also be used to annotate text. They should be on their own line, but can be placed inline in a paragraph. If placed inline, the start of the comment must not have a space.\n\nBad example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\nGood example:\n\n```markdown\nHTML comments should be used for marking to-dos or annotate text.\n```\n\n## Editing\n\n### End of line sequence\n\n*Caution: Uploading Markdown files using `CRLF` (carriage return and line feed) via GitHub will result in those files using `CRLF`. To prevent this, set the line ending to `LF` (line feed) before uploading.*\n\nMarkdown files must be checked in using the `LF` end of line sequence.\n\n### Escaping\n\nMarkdown syntax should be escaped as needed. However, article titles are parsed as plain text and so must not be escaped.\n\n### Paragraphs\n\nEach paragraph must be followed by one empty line.\n\n### Line breaks\n\nLine breaks must use a backslash (`\\`).\n\nLine breaks must be used sparingly.\n\n## Hatnote\n\n*Not to be confused with [Notice](#notice).*\n\nHatnotes are short notes placed at the top of an article or section to help readers navigate to related articles or inform them about related topics.\n\nHatnotes must be italicised and be placed immediately after the heading. If multiple hatnotes are used, they must be on the same paragraph separated with a line break.\n\n### Main page\n\n*Main page* hatnotes direct the reader to the main article of a topic. When this hatnote is used, it implies that the section it is on is a summary of what the linked page is about. This hatnote should have only one link. These must be formatted as follows:\n\n```markdown\n*Main page: {article}*\n\n*Main pages: {article} and {article}*\n```\n\n### See also\n\n*See also* hatnotes suggest to readers other points of interest from a given article or section. These must be formatted as follows:\n\n```markdown\n*See also: {article}*\n\n*See also: {article} and {article}*\n```\n\n### For see\n\n*For see* hatnotes are similar to *see also* hatnotes, but are generally more descriptive and direct. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*For {description}, see: {article}`*\n\n*For {description}, see: {article} and {article}`*\n```\n\n### Not to be confused with\n\n*Not to be confused with* hatnotes help distinguish ambiguous or misunderstood article titles or sections. This hatnote may use more than one link if necessary. These must be formatted as follows:\n\n```markdown\n*Not to be confused with {article}.*\n\n*Not to be confused with {article} or {article}.*\n```\n\n### For other uses\n\n*For other uses* hatnotes are similar to *not to be confused with* hatnotes, but links directly to the [disambiguation article](#disambiguation-articles). This hatnote must only link to the disambiguation article. These must be formatted as follows:\n\n```markdown\n*For other uses, see {disambiguation article}.*\n```\n\n## Notice\n\n*Not to be confused with [Hatnote](#hatnote).*\n\nA notice should be placed where appropriate in a section, but must start off the paragraph and use italics. Notices may contain bolding where appropriate, but should be kept to a minimum. Notices must be written as complete sentences. Thus, unlike most [hatnotes](#hatnotes), must use a full stop (`.`) or an exclamation mark (`!`) if appropriate. Anything within the same paragraph of a notice must also be italicised. These must be formatted as follows:\n\n```markdown\n*Note: {note}.*\n\n*Notice: {notice}.*\n\n*Caution: {caution}.*\n\n*Warning: {warning}.*\n```\n\n- `Note` should be used for factual or trivial details.\n- `Notice` should be used for reminders or to draw attention to something that the reader should be made aware of.\n- `Caution` should be used to warn the reader to avoid unintended consequences.\n- `Warning` should be used to warn the reader that action may be taken against them.\n\n## Emphasising\n\n### Bold\n\nBold must use double asterisks (`**`).\n\nLead paragraphs may bold the first occurrence of the article's title.\n\n### Italics\n\nItalics must use single asterisks (`*`).\n\nNames of work or video games should be italicised. osu!—the game—is exempt from this.\n\nThe first occurrence of an abbreviation, acronym, or initialism may be italicised.\n\nItalics may also be used to provide emphasis or help with readability.\n\n## Headings\n\nAll headings must use sentence case.\n\nHeadings must use the [ATX (hash) style](https://github.github.com/gfm/#atx-headings \"GitHub\") and must have an empty line before and after the heading. The title heading is an exception when it is on the first line. If this is the case, there only needs to be an empty line after the title heading.\n\nHeadings must not exceed a heading level of 5 and must not be used to style or format text.\n\n### Titles\n\n*See also: [Article naming](#article-naming)*\n\n*Caution: Titles are parsed as plain text; they must not be escaped.*\n\nThe first heading in all articles must be a level 1 heading, being the article's title. All headings afterwards must be [section headings](#sections). Titles must not contain formatting, links, or images.\n\nThe title heading must be on the first line, unless [front matter](#front-matter) is being used. If that is the case, the title heading must go after it and have an empty line before the title heading.\n\n### Sections\n\nSection headings must use levels 2 to 5. The section heading proceeding the [title heading](#titles) must be a level 2 heading. Unlike titles, section headings may have small image icons.\n\nSection headings must not skip a heading level (i.e. do not go from a level 2 heading to a level 4 heading) and must not contain formatting or links.\n\n*Notice: On the website, heading levels 4 and 5 will not appear in the table of contents. They cannot be linked to directly either.*\n\n## Lists\n\nLists should not go over 4 levels of indentation and should not have an empty line in between each item.\n\nFor nested lists, bullets or numbers must align with the item content of their parent lists.\n\nThe following example was done incorrectly (take note of the spacing before the bullet):\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\nThe following example was done correctly:\n\n```markdown\n1. Fly a kite\n - Don't fly a kite if it's raining\n```\n\n### Bulleted\n\nBulleted lists must use a hyphen (`-`). These must then be followed by one space. (Example shown below.)\n\n```markdown\n- osu!\n - Hit circle\n - Combo number\n - Approach circle\n - Slider\n - Hit circles\n - Slider body\n - Slider ticks\n - Spinner\n- osu!taiko\n```\n\n### Numbered\n\nThe numbers in a numbered list must be incremented to represent their step.\n\n```markdown\n1. Download the osu! installer.\n2. Run the installer.\n 1. To change the installation location, click the text underneath the progression bar.\n 2. The installer will prompt for a new location, choose the installation folder.\n3. osu! will start up once installation is complete.\n4. Sign in.\n```\n\n### Mixed\n\nCombining both bulleted and numbered lists should be done sparingly.\n\n```markdown\n1. Download a skin from the forums.\n2. Load the skin file into osu!.\n - If the file is a `.zip`, unzip it and move the contents into the `Skins/` folder (found in your osu! installation folder).\n - If the file is a `.osk`, open it on your desktop or drag-and-drop it into the game client.\n3. Open osu!, if it is not opened, and select the skin in the options.\n - This may have been completed if you opened the `.osk` file or drag-and-dropped it into the game client.\n```\n\n## Code\n\nThe markup for code is a grave mark (`` ` ``). To put grave marks in code, use double grave marks instead. If the grave mark is at the start or end, pad it with one space. (Example shown below.)\n\n```markdown\n`` ` ``\n`` `Space` ``\n```\n\n### Keyboard keys\n\n*Notice: When denoting the letter itself, and not the keyboard key, use quotation marks instead.*\n\nWhen representing keyboard keys, use capital letters for single characters and title case for modifiers. Use the plus symbol (`+`) (without code) to represent key combinations. (Example shown below.)\n\n```markdown\npippi is spelt with a lowercase \"p\" like peppy.\n\nPress `Ctrl` + `O` to open the open dialog.\n```\n\nWhen representing a space or the spacebar, use `` `Space` ``.\n\n### Button and menu text\n\nWhen copying the text from a menu or button, the letter casing should be copied as it appears. (Example shown below.)\n\n```markdown\nThe `osu!direct` button is visible in the main menu on the right side, if you have an active osu!supporter tag.\n```\n\n### Folder and directory names\n\nWhen copying the name of a folder or directory, the letter casing should be copied as it appears, but prefer lowercased paths when possible. Directory paths must not be absolute (i.e. do not start the directory name from the drive letter or from the root folder). (Example shown below.)\n\n```markdown\nosu! is installed in the `AppData/Local` folder by default, unless specified otherwise during installation.\n```\n\n### Keywords and commands\n\nWhen copying a keyword or command, the letter casing should be copied as it appears or how someone normally would type it. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nAs of now, the `Name` and `Author` commands in the skin configuration file (`skin.ini`) do nothing.\n```\n\n### File names\n\nWhen copying the name of a file, the letter casing should be copied as it appears. If applicable, prefer lowercase letters. (Example shown below.)\n\n```markdown\nTo play osu!, double click the `osu!.exe` icon.\n```\n\n### File extensions\n\n*Notice: File formats (not to be confused with file extensions) must be written in capital letters without the prefixed fullstop (`.`).*\n\nFile extensions must be prefixed with a fullstop (`.`) and be followed by the file extension in lowercase letters. (Example shown below.)\n\n```markdown\nThe JPG (or JPEG) file format has the `.jpg` (or `.jpeg`) extension.\n```\n\n### Chat channels\n\nWhen copying the name of a chat channel, start it with a hash (`#`), followed by the channel name in lowercase letters. (Example shown below.)\n\n```markdown\n`#lobby` is where you can advertise your multi room.\n```\n\n## Preformatted text (code blocks)\n\n*Notice: Syntax highlighting for preformatted text is not implemented on the website yet.*\n\nPreformatted text (also known as code blocks) must be fenced using three grave marks. They should set the language identifier for syntax highlighting.\n\n## Links\n\nThere are two types of links: inline and reference. Inline has two styles.\n\nThe following is an example of both inline styles:\n\n```markdown\n[Game Modifiers](/wiki/Game_Modifiers)\n\n\n```\n\nThe following is an example of the reference style:\n\n```markdown\n[Game Modifiers][game mods link]\n\n[game mods link]: /wiki/Game_Modifiers\n```\n\n---\n\nLinks must use the inline style if they are only referenced once. The inline angle brackets style should be avoided. References to reference links must be placed at the bottom of the article.\n\n### Internal links\n\n*Note: Internal links refer to links that stay inside the `https://osu.ppy.sh/` domain.*\n\n#### Wiki links\n\nAll links that point to an wiki article should start with `/wiki/` followed by the path to get to the article you are targeting. Relative links may also be used. Some examples include the following:\n\n```markdown\n[FAQ](/wiki/FAQ)\n[pippi](/wiki/Mascots#-pippi)\n[Beatmaps](../)\n[Pattern](./Pattern)\n```\n\nWiki links must not use redirects and must not have a trailing forward slash (`/`).\n\nBad examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/ASC)\n[Developers](/wiki/Developers/)\n[Developers](/wiki/Developers/#game-client-developers)\n```\n\nGood examples include the following:\n\n```markdown\n[Article styling criteria](/wiki/Article_styling_criteria)\n[Developers](/wiki/Developers)\n[Developers](/wiki/Developers#game-client-developers)\n```\n\n##### Sub-article links\n\nWiki links that point to a sub-article should include the parent article's folder name in its link text. See the following example:\n\n```markdown\n*See also: [Beatmap Editor/Design](/wiki/Beatmap_Editor/Design)*\n```\n\n##### Section links\n\n*Notice: On the website, heading levels 4 and 5 are not given the id attribute. This means that they can not be linked to directly.*\n\nWiki links that point to a section of an article may use the section sign symbol (`§`). See the following example:\n\n```markdown\n*For timing rules, see: [Ranking Criteria § Timing](/wiki/Ranking_Criteria#timing)*\n```\n\n#### Other osu! links\n\nThe URL from the address bar of your web browser should be copied as it is when linking to other osu! web pages. The `https://osu.ppy.sh` part of the URL must be kept.\n\n##### User profiles\n\nAll usernames must be linked on first occurrence. Other occurrences are optional, but must be consistent throughout the entire article for all usernames. If it is difficult to determine the user's id, it may be skipped over.\n\nWhen linking to a user profile, the user's id number must be used. Use the new website (`https://osu.ppy.sh/users/{username})`) to get the user's id.\n\nThe link text of the user link should be the user's current name.\n\n##### Difficulties\n\nWhenever linking to a single difficulty, use this format as the link text:\n\n```\n{artist} - {title} ({creator}) [{difficuty_name}]\n```\n\nThe link must actually link to that difficulty. Beatmap difficulty URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}#{mode}/{BeatmapID}\n```\n\nThe difficulty name may be left outside of the link text, but doing so must be consistent throughout the entire article.\n\n##### Beatmaps\n\nWhenever linking to a beatmap, use this format as the link text:\n\n```\n{artist} - {title} ({creator})\n```\n\nAll beatmap URLs must be formatted as follows:\n\n```\nhttps://osu.ppy.sh/beatmapsets/{BeatmapSetID}\n```\n\n### External links\n\n*Notice: External links refers to links that go outside the `https://osu.ppy.sh/` domain.*\n\nThe `https` protocol must be used, unless the site does not support it. External links must be a clean and direct link to a reputable source. The link text should be the title of the page it is linking to. The URL from the address bar of your web browser should be copied as it is when linking to other external pages.\n\nThere are no visual differences between external and osu! web links. Due to this, the website name should be included in the title text. See the following example:\n\n```markdown\n*For more information about music theory, see: [Music theory](https://en.wikipedia.org/wiki/Music_theory \"Wikipedia\")*\n```\n\n## Images\n\nThere are two types of image links: inline and reference. Examples:\n\n**Inline style:**\n\n```markdown\n![](/wiki/shared/flag/AU.gif)\n```\n\n**Reference style:**\n\n```markdown\n![][flag_AU]\n\n[flag_AU]: /wiki/shared/flag/AU.gif\n```\n\nImages should use the inline linking style. References to reference links must be placed at the bottom of the article.\n\nImages must be placed in a folder named `img`, located in the article's folder. Images that are used in multiple articles should be stored in the `/wiki/shared/` folder.\n\n### Image caching\n\nImages on the website are cached for up to 60 days. The cached image is matched with the image link's URL.\n\nWhen updating an image, either change the image's name or append a query string to the URL. In both cases, all translations linking to the updated image should also be updated.\n\n### Formats and quality\n\nImages should use the JPG format at quality 8 (80 or 80%, depending on the program). If the image contains transparency or has text that must be readable, use the PNG format instead. If the image contains an animation, the GIF format can be used; however, this should be used sparingly as these may take longer to load or can be bigger then the [max file size](#file-size).\n\n### File size\n\nImages must be under 1 megabyte, otherwise they will fail to load. Downscaling and using JPG at 80% is almost always under the size limit.\n\nAll images should be optimised as much as possible. Use [jpeg-archive](https://github.com/danielgtaylor/jpeg-archive \"GitHub\") to compress JPEG images. For consistency, use the following command for jpeg-archive:\n\n```sh\njpeg-recompress -am smallfry \n```\n\nWhere `` is the file name to be compressed and `` is the compressed file name.\n\n### File names\n\n*Notice: File extensions must use lowercase letters, otherwise they will fail to load!*\n\nUse hyphens (`-`) when spacing words. When naming an image, the file name should be meaningful or descriptive but short.\n\n### Formatting and positioning\n\n*Note: It is currently not possible to float an image or have text wrap around it.*\n\nImages on the website will be centred when it is on a single line, by themself. Otherwise, they will be positioned inline with the paragraph. The following example will place the image in the center:\n\n```markdown\nInstalling osu! is easy. First, download the installer from the download page.\n\n![](img/download-page.jpg)\n\nThen locate the installer and run it.\n```\n\n### Alt text\n\nImages should have alt text unless it is for decorative purposes.\n\n### Captions\n\nImages are given captions on the website if they fulfill these conditions:\n\n1. The image is by itself.\n2. The image is not inside a heading.\n3. The image has title text.\n\nCaptions are assumed via the title text, which must be in plain text. Images with captions are also centred with the image on the website.\n\n### Max image width\n\nThe website's max image width is the width of the article body. Images should be no wider than 800 pixels.\n\n### Annotating images\n\nWhen annotating images, use *Torus Regular*. For Chinese, Korean, Japanese characters, use *Microsoft YaHei*.\n\nAnnotating images should be avoided, as it is difficult for translators (and other editors) to edit them.\n\n#### Translating annotated images\n\nWhen translating annotated images, the localised image version must be placed in the same directory as the original version (i.e. the English version). The filename of a localised image version must start with the original version's name, followed by a hyphen, followed by the locale name (in capital letters). See the following examples:\n\n- `hardrock-mod-vs-easy-mod.jpg` for English\n- `hardrock-mod-vs-easy-mod-DE.jpg` for German\n- `hardrock-mod-vs-easy-mod-ZH-TW.jpg` for Traditional Chinese\n\n### Screenshots of gameplay\n\nAll screenshots of gameplay must be done in the stable build, unless it is for a specific feature that is unavailable in the stable build. You should use the in-game screenshot feature (`F12`).\n\n#### Game client settings\n\n*Note: If you do not want to change your current settings for the wiki, you can move your `osu!..cfg` out of the osu! folder and move it back later.*\n\nYou must set these settings before taking a screenshot of the game client (settings not stated below are assumed to be at their defaults):\n\n- Select language: `English`\n- Prefer metadata in original language: `Enabled`\n- Resolution: `1280x720`\n- Fullscreen mode: `Disabled`\n- Parallax: `Disabled`\n- Menu tips: `Disabled`\n- Seasonal backgrounds: `Never`\n- Always show key overlay: `Enabled`\n- Current skin: `Default` (first option)\n\n*Notice to translators: If you are translating an article containing screenshots of the game, you may set the game client's language to the language you are translating in.*\n\n### Image links\n\nImages must not be part of a link text.\n\nFlag icons next to user links must be separate from the link text. See the following example:\n\n```markdown\n![][flag_AU] [peppy](https://osu.ppy.sh/users/2)\n```\n\n### Flag icons\n\n*For a list of flag icons, see: [issue \\#328](https://github.com/ppy/osu-wiki/issues/328 \"GitHub\")*\n\nThe flag icons use the two letter code (in all capital letters) and end with `.gif`. When adding a flag inline, use this format:\n\n```markdown\n![](/wiki/shared/flag/xx.gif)\n```\n\nWhere `xx` is the [ISO 3166-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 \"Wikipedia\") two-lettered country code for the flag.\n\nThe full country name should be added in the title text. The country code in the alternate text is optional, but must be applied to all flag icons in the article.\n\n## Tables\n\nTables on the website only support headings along the first row.\n\nTables must not be beautified (do not pad cells with extra spaces to make their widths uniform). They must have a vertical bar (`|`) on the left and right sides and the text of each cell must be padded with one space on both sides. Empty cells must use a vertical bar (`|`) followed by two spaces then another vertical bar (`|`).\n\nThe delimiter row (the next line after the table heading) must use only three characters per column (and be padded with a space on both sides), which must look like one of the following:\n\n- `:--` (for left align)\n- `:-:` (for centre align)\n- `--:` (for right align)\n\n---\n\nThe following is an example of what a table should look like:\n\n```markdown\n| Team \"Picturesque\" Red | Score | Team \"Statuesque\" Blue | Average Beatmap Stars |\n| :-- | :-: | --: | :-- |\n| **peppy** | 5 - 2 | pippi | 9.3 stars |\n| Aiko | 1 - 6 | **Alisa** | 4.2 stars |\n| Ryūta | 3 - 4 | **Yuzu** | 5.1 stars |\n| **Taikonator** | 7 - 0 | Tama | 13.37 stars |\n| Maria | No Contest | Mocha | |\n```\n\n## Blockquotes\n\nThe blockquote is limited to quoting text from someone. It must not be used to format text otherwise.\n\n## Thematic breaks\n\nThe thematic break (also known as the horizontal rule or line) should be used sparingly. A few uses of the thematic break may include (but is not limited to):\n\n- separating images from text\n- separating multiple images that follow one another\n- shifting the topic within a section\n\nThese must have an empty line before and after the markup. Thematic breaks must use only three hyphens, as depicted below:\n\n```markdown\n---\n```\n" }; } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiSidebar.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiSidebar.cs new file mode 100644 index 0000000000..b4f1997bb0 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiSidebar.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 Markdig.Parsers; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Graphics.Containers.Markdown; +using osu.Game.Overlays; +using osu.Game.Overlays.Wiki; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneWikiSidebar : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Orange); + + [Cached] + private readonly OverlayScrollContainer scrollContainer = new OverlayScrollContainer(); + + private WikiSidebar sidebar; + + [SetUp] + public void SetUp() => Schedule(() => Child = sidebar = new WikiSidebar()); + + [Test] + public void TestNoContent() + { + AddStep("No Content", () => { }); + } + + [Test] + public void TestOnlyMainTitle() + { + AddStep("Add TOC", () => + { + for (var i = 0; i < 10; i++) + addTitle($"This is a very long title {i + 1}"); + }); + } + + [Test] + public void TestWithSubtitle() + { + AddStep("Add TOC", () => + { + for (var i = 0; i < 10; i++) + addTitle($"This is a very long title {i + 1}", i % 4 != 0); + }); + } + + private void addTitle(string text, bool subtitle = false) + { + var headingBlock = new HeadingBlock(new HeadingBlockParser()) + { + Inline = new ContainerInline().AppendChild(new LiteralInline(text)), + Level = subtitle ? 3 : 2, + }; + var heading = new OsuMarkdownHeading(headingBlock); + sidebar.AddEntry(headingBlock, heading); + } + } +} diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs index 618447eae2..7bf161d1d0 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs @@ -3,25 +3,21 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Graphics.Containers; using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.OnlinePlay.Playlists; -using osu.Game.Tests.Visual.Multiplayer; +using osu.Game.Tests.Visual.OnlinePlay; namespace osu.Game.Tests.Visual.Playlists { - public class TestScenePlaylistsLoungeSubScreen : RoomManagerTestScene + public class TestScenePlaylistsLoungeSubScreen : OnlinePlayTestScene { - private LoungeSubScreen loungeScreen; + protected new BasicTestRoomManager RoomManager => (BasicTestRoomManager)base.RoomManager; - [BackgroundDependencyLoader] - private void load() - { - } + private LoungeSubScreen loungeScreen; public override void SetUpSteps() { @@ -37,11 +33,11 @@ namespace osu.Game.Tests.Visual.Playlists [Test] public void TestScrollSelectedIntoView() { - AddRooms(30); + AddStep("add rooms", () => RoomManager.AddRooms(30)); AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms.First())); - AddStep("select last room", () => roomsContainer.Rooms.Last().Action?.Invoke()); + AddStep("select last room", () => roomsContainer.Rooms.Last().Click()); AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms.First())); AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms.Last())); diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs index 44a79b6598..cdc655500d 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs @@ -3,7 +3,6 @@ using System; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,26 +11,28 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Tests.Visual.OnlinePlay; namespace osu.Game.Tests.Visual.Playlists { - public class TestScenePlaylistsMatchSettingsOverlay : RoomTestScene + public class TestScenePlaylistsMatchSettingsOverlay : OnlinePlayTestScene { - [Cached(Type = typeof(IRoomManager))] - private TestRoomManager roomManager = new TestRoomManager(); + protected new TestRoomManager RoomManager => (TestRoomManager)base.RoomManager; private TestRoomSettings settings; + protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new TestDependencies(); + [SetUp] public new void Setup() => Schedule(() => { - settings = new TestRoomSettings + SelectedRoom.Value = new Room(); + + Child = settings = new TestRoomSettings { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible } }; - - Child = settings; }); [Test] @@ -39,19 +40,19 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("clear name and beatmap", () => { - Room.Name.Value = ""; - Room.Playlist.Clear(); + SelectedRoom.Value.Name.Value = ""; + SelectedRoom.Value.Playlist.Clear(); }); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); - AddStep("set name", () => Room.Name.Value = "Room name"); + AddStep("set name", () => SelectedRoom.Value.Name.Value = "Room name"); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); - AddStep("set beatmap", () => Room.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } })); + AddStep("set beatmap", () => SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } })); AddAssert("button enabled", () => settings.ApplyButton.Enabled.Value); - AddStep("clear name", () => Room.Name.Value = ""); + AddStep("clear name", () => SelectedRoom.Value.Name.Value = ""); AddAssert("button disabled", () => !settings.ApplyButton.Enabled.Value); } @@ -67,9 +68,9 @@ namespace osu.Game.Tests.Visual.Playlists { settings.NameField.Current.Value = expected_name; settings.DurationField.Current.Value = expectedDuration; - Room.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } }); + SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } }); - roomManager.CreateRequested = r => + RoomManager.CreateRequested = r => { createdRoom = r; return true; @@ -88,11 +89,11 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("setup", () => { - Room.Name.Value = "Test Room"; - Room.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } }); + SelectedRoom.Value.Name.Value = "Test Room"; + SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = CreateBeatmap(Ruleset.Value).BeatmapInfo } }); fail = true; - roomManager.CreateRequested = _ => !fail; + RoomManager.CreateRequested = _ => !fail; }); AddAssert("error not displayed", () => !settings.ErrorText.IsPresent); @@ -119,7 +120,12 @@ namespace osu.Game.Tests.Visual.Playlists public OsuSpriteText ErrorText => ((MatchSettings)Settings).ErrorText; } - private class TestRoomManager : IRoomManager + private class TestDependencies : OnlinePlayTestSceneDependencies + { + protected override IRoomManager CreateRoomManager() => new TestRoomManager(); + } + + protected class TestRoomManager : IRoomManager { public const string FAILED_TEXT = "failed"; @@ -146,7 +152,7 @@ namespace osu.Game.Tests.Visual.Playlists onSuccess?.Invoke(room); } - public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) => throw new NotImplementedException(); + public void JoinRoom(Room room, string password, Action onSuccess = null, Action onError = null) => throw new NotImplementedException(); public void PartRoom() => throw new NotImplementedException(); } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs index 255f147ec9..76a78c0a3c 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsParticipantsList.cs @@ -3,21 +3,23 @@ using NUnit.Framework; using osu.Framework.Graphics; +using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Users; namespace osu.Game.Tests.Visual.Playlists { - public class TestScenePlaylistsParticipantsList : RoomTestScene + public class TestScenePlaylistsParticipantsList : OnlinePlayTestScene { [SetUp] public new void Setup() => Schedule(() => { - Room.RoomID.Value = 7; + SelectedRoom.Value = new Room { RoomID = { Value = 7 } }; for (int i = 0; i < 50; i++) { - Room.RecentParticipants.Add(new User + SelectedRoom.Value.RecentParticipants.Add(new User { Username = "peppy", Statistics = new UserStatistics { GlobalRank = 1234 }, diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs index a08a91314b..f2bfb80beb 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs @@ -15,20 +15,17 @@ using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; -using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual.OnlinePlay; using osu.Game.Users; using osuTK.Input; namespace osu.Game.Tests.Visual.Playlists { - public class TestScenePlaylistsRoomSubScreen : RoomTestScene + public class TestScenePlaylistsRoomSubScreen : OnlinePlayTestScene { - [Cached(typeof(IRoomManager))] - private readonly TestRoomManager roomManager = new TestRoomManager(); - private BeatmapManager manager; private RulesetStore rulesets; @@ -40,8 +37,6 @@ namespace osu.Game.Tests.Visual.Playlists Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); - manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait(); - ((DummyAPIAccess)API).HandleRequest = req => { switch (req) @@ -58,7 +53,9 @@ namespace osu.Game.Tests.Visual.Playlists [SetUpSteps] public void SetupSteps() { - AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(Room))); + AddStep("set room", () => SelectedRoom.Value = new Room()); + AddStep("ensure has beatmap", () => manager.Import(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo.BeatmapSet).Wait()); + AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(SelectedRoom.Value))); AddUntilStep("wait for load", () => match.IsCurrentScreen()); } @@ -67,12 +64,12 @@ namespace osu.Game.Tests.Visual.Playlists { 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.EndDate.Value = DateTimeOffset.Now.AddMinutes(5); - Room.Playlist.Add(new PlaylistItem + SelectedRoom.Value.RoomID.Value = 1; + SelectedRoom.Value.Name.Value = "my awesome room"; + SelectedRoom.Value.Host.Value = new User { Id = 2, Username = "peppy" }; + SelectedRoom.Value.RecentParticipants.Add(SelectedRoom.Value.Host.Value); + SelectedRoom.Value.EndDate.Value = DateTimeOffset.Now.AddMinutes(5); + SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, Ruleset = { Value = new OsuRuleset().RulesetInfo } @@ -88,9 +85,9 @@ namespace osu.Game.Tests.Visual.Playlists { AddStep("set room properties", () => { - Room.Name.Value = "my awesome room"; - Room.Host.Value = new User { Id = 2, Username = "peppy" }; - Room.Playlist.Add(new PlaylistItem + SelectedRoom.Value.Name.Value = "my awesome room"; + SelectedRoom.Value.Host.Value = new User { Id = 2, Username = "peppy" }; + SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo }, Ruleset = { Value = new OsuRuleset().RulesetInfo } @@ -104,17 +101,34 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("click", () => InputManager.Click(MouseButton.Left)); - AddAssert("first playlist item selected", () => match.SelectedItem.Value == Room.Playlist[0]); + AddAssert("first playlist item selected", () => match.SelectedItem.Value == SelectedRoom.Value.Playlist[0]); } [Test] public void TestBeatmapUpdatedOnReImport() { BeatmapSetInfo importedSet = null; + TestBeatmap beatmap = null; + + // this step is required to make sure the further imports actually get online IDs. + // all the playlist logic relies on online ID matching. + AddStep("remove all matching online IDs", () => + { + beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo); + + var existing = manager.QueryBeatmapSets(s => s.OnlineBeatmapSetID == beatmap.BeatmapInfo.BeatmapSet.OnlineBeatmapSetID).ToList(); + + foreach (var s in existing) + { + s.OnlineBeatmapSetID = null; + foreach (var b in s.Beatmaps) + b.OnlineBeatmapID = null; + manager.Update(s); + } + }); AddStep("import altered beatmap", () => { - var beatmap = new TestBeatmap(new OsuRuleset().RulesetInfo); beatmap.BeatmapInfo.BaseDifficulty.CircleSize = 1; importedSet = manager.Import(beatmap.BeatmapInfo.BeatmapSet).Result; @@ -122,9 +136,9 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("load room", () => { - Room.Name.Value = "my awesome room"; - Room.Host.Value = new User { Id = 2, Username = "peppy" }; - Room.Playlist.Add(new PlaylistItem + SelectedRoom.Value.Name.Value = "my awesome room"; + SelectedRoom.Value.Host.Value = new User { Id = 2, Username = "peppy" }; + SelectedRoom.Value.Playlist.Add(new PlaylistItem { Beatmap = { Value = importedSet.Beatmaps[0] }, Ruleset = { Value = new OsuRuleset().RulesetInfo } @@ -155,30 +169,5 @@ namespace osu.Game.Tests.Visual.Playlists { } } - - private class TestRoomManager : IRoomManager - { - public event Action RoomsUpdated - { - add => throw new NotImplementedException(); - remove => throw new NotImplementedException(); - } - - public IBindable 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/Ranking/TestSceneAccuracyCircle.cs b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs index f305b7255e..a5e2f02f31 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneAccuracyCircle.cs @@ -55,7 +55,7 @@ namespace osu.Game.Tests.Visual.Ranking } } }, - new AccuracyCircle(score) + new AccuracyCircle(score, true) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs index 082d85603e..227bce0c60 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs @@ -12,7 +12,7 @@ namespace osu.Game.Tests.Visual.Settings [BackgroundDependencyLoader] private void load() { - Add(new DirectorySelector { RelativeSizeAxes = Axes.Both }); + Add(new OsuDirectorySelector { RelativeSizeAxes = Axes.Both }); } } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs index 311e4c3362..84a0fc6e4c 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneFileSelector.cs @@ -12,13 +12,13 @@ namespace osu.Game.Tests.Visual.Settings [Test] public void TestAllFiles() { - AddStep("create", () => Child = new FileSelector { RelativeSizeAxes = Axes.Both }); + AddStep("create", () => Child = new OsuFileSelector { RelativeSizeAxes = Axes.Both }); } [Test] public void TestJpgFilesOnly() { - AddStep("create", () => Child = new FileSelector(validFileExtensions: new[] { ".jpg" }) { RelativeSizeAxes = Axes.Both }); + AddStep("create", () => Child = new OsuFileSelector(validFileExtensions: new[] { ".jpg" }) { RelativeSizeAxes = Axes.Both }); } } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs index f63145f534..df59b9284b 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs @@ -4,7 +4,6 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Overlays.Settings; using osu.Game.Overlays; @@ -17,28 +16,65 @@ namespace osu.Game.Tests.Visual.Settings [Test] public void TestRestoreDefaultValueButtonVisibility() { - TestSettingsTextBox textBox = null; + SettingsTextBox textBox = null; + RestoreDefaultValueButton restoreDefaultValueButton = null; - AddStep("create settings item", () => Child = textBox = new TestSettingsTextBox + AddStep("create settings item", () => { - Current = new Bindable + Child = textBox = new SettingsTextBox { - Default = "test", - Value = "test" - } + Current = new Bindable + { + Default = "test", + Value = "test" + } + }; + + restoreDefaultValueButton = textBox.ChildrenOfType>().Single(); }); - AddAssert("restore button hidden", () => textBox.RestoreDefaultValueButton.Alpha == 0); + AddAssert("restore button hidden", () => restoreDefaultValueButton.Alpha == 0); AddStep("change value from default", () => textBox.Current.Value = "non-default"); - AddUntilStep("restore button shown", () => textBox.RestoreDefaultValueButton.Alpha > 0); + AddUntilStep("restore button shown", () => restoreDefaultValueButton.Alpha > 0); AddStep("restore default", () => textBox.Current.SetDefault()); - AddUntilStep("restore button hidden", () => textBox.RestoreDefaultValueButton.Alpha == 0); + AddUntilStep("restore button hidden", () => restoreDefaultValueButton.Alpha == 0); } - private class TestSettingsTextBox : SettingsTextBox + /// + /// Ensures that the reset to default button uses the correct implementation of IsDefault to determine whether it should be shown or not. + /// Values have been chosen so that after being set, Value != Default (but they are close enough that the difference is negligible compared to Precision). + /// + [TestCase(4.2f)] + [TestCase(9.9f)] + public void TestRestoreDefaultValueButtonPrecision(float initialValue) { - public Drawable RestoreDefaultValueButton => this.ChildrenOfType>().Single(); + BindableFloat current = null; + SettingsSlider sliderBar = null; + RestoreDefaultValueButton restoreDefaultValueButton = null; + + AddStep("create settings item", () => + { + Child = sliderBar = new SettingsSlider + { + Current = current = new BindableFloat(initialValue) + { + MinValue = 0f, + MaxValue = 10f, + Precision = 0.1f, + } + }; + + restoreDefaultValueButton = sliderBar.ChildrenOfType>().Single(); + }); + + AddAssert("restore button hidden", () => restoreDefaultValueButton.Alpha == 0); + + AddStep("change value to next closest", () => sliderBar.Current.Value += current.Precision * 0.6f); + AddUntilStep("restore button shown", () => restoreDefaultValueButton.Alpha > 0); + + AddStep("restore default", () => sliderBar.Current.SetDefault()); + AddUntilStep("restore button hidden", () => restoreDefaultValueButton.Alpha == 0); } } -} \ No newline at end of file +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs index 06572f66bf..b4544fbc85 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Visual.SongSelect Version = "All Metrics", Metadata = new BeatmapMetadata { - Source = "osu!lazer", + Source = "osu!", Tags = "this beatmap has all the metrics", }, BaseDifficulty = new BeatmapDifficulty @@ -100,7 +100,7 @@ namespace osu.Game.Tests.Visual.SongSelect Version = "Only Ratings", Metadata = new BeatmapMetadata { - Source = "osu!lazer", + Source = "osu!", Tags = "this beatmap has ratings metrics but not retries or fails", }, BaseDifficulty = new BeatmapDifficulty @@ -122,7 +122,7 @@ namespace osu.Game.Tests.Visual.SongSelect Version = "Only Retries and Fails", Metadata = new BeatmapMetadata { - Source = "osu!lazer", + Source = "osu!", Tags = "this beatmap has retries and fails but no ratings", }, BaseDifficulty = new BeatmapDifficulty @@ -149,7 +149,7 @@ namespace osu.Game.Tests.Visual.SongSelect Version = "No Metrics", Metadata = new BeatmapMetadata { - Source = "osu!lazer", + Source = "osu!", Tags = "this beatmap has no metrics", }, BaseDifficulty = new BeatmapDifficulty diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs index 67cd720260..184a2e59da 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs @@ -2,15 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; +using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; +using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Tests.Resources; using osu.Game.Users; using osuTK; @@ -23,32 +30,98 @@ namespace osu.Game.Tests.Visual.SongSelect [Cached] private readonly DialogOverlay dialogOverlay; + private ScoreManager scoreManager; + + private RulesetStore rulesetStore; + private BeatmapManager beatmapManager; + + 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(), Resources, dependencies.Get(), Beatmap.Default)); + dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, null, ContextFactory)); + + return dependencies; + } + public TestSceneBeatmapLeaderboard() { - Add(dialogOverlay = new DialogOverlay + AddRange(new Drawable[] { - Depth = -1 + dialogOverlay = new DialogOverlay + { + Depth = -1 + }, + leaderboard = new FailableLeaderboard + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Size = new Vector2(550f, 450f), + Scope = BeatmapLeaderboardScope.Global, + } + }); + } + + [Test] + public void TestLocalScoresDisplay() + { + BeatmapInfo beatmapInfo = null; + + AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Local); + + AddStep(@"Set beatmap", () => + { + beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First(); + + leaderboard.Beatmap = beatmapInfo; }); - Add(leaderboard = new FailableLeaderboard - { - Origin = Anchor.Centre, - Anchor = Anchor.Centre, - Size = new Vector2(550f, 450f), - Scope = BeatmapLeaderboardScope.Global, - }); + clearScores(); + checkCount(0); - AddStep(@"New Scores", newScores); + loadMoreScores(() => beatmapInfo); + checkCount(10); + + loadMoreScores(() => beatmapInfo); + checkCount(20); + + clearScores(); + checkCount(0); + } + + [Test] + public void TestGlobalScoresDisplay() + { + AddStep(@"Set scope", () => leaderboard.Scope = BeatmapLeaderboardScope.Global); + AddStep(@"New Scores", () => leaderboard.Scores = generateSampleScores(null)); + } + + [Test] + public void TestPersonalBest() + { AddStep(@"Show personal best", showPersonalBest); + AddStep("null personal best position", showPersonalBestWithNullPosition); + } + + [Test] + public void TestPlaceholderStates() + { 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)); + } + + [Test] + public void TestBeatmapStates() + { foreach (BeatmapSetOnlineStatus status in Enum.GetValues(typeof(BeatmapSetOnlineStatus))) AddStep($"{status} beatmap", () => showBeatmapWithStatus(status)); - AddStep("null personal best position", showPersonalBestWithNullPosition); } private void showPersonalBestWithNullPosition() @@ -96,9 +169,26 @@ namespace osu.Game.Tests.Visual.SongSelect }; } - private void newScores() + private void loadMoreScores(Func beatmapInfo) { - var scores = new[] + AddStep(@"Load new scores via manager", () => + { + foreach (var score in generateSampleScores(beatmapInfo())) + scoreManager.Import(score).Wait(); + }); + } + + private void clearScores() + { + AddStep("Clear all scores", () => scoreManager.Delete(scoreManager.GetAllUsableScores())); + } + + private void checkCount(int expected) => + AddUntilStep("Correct count displayed", () => leaderboard.ChildrenOfType().Count() == expected); + + private static ScoreInfo[] generateSampleScores(BeatmapInfo beatmap) + { + return new[] { new ScoreInfo { @@ -107,6 +197,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + Beatmap = beatmap, User = new User { Id = 6602580, @@ -125,6 +216,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + Beatmap = beatmap, User = new User { Id = 4608074, @@ -143,6 +235,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + Beatmap = beatmap, User = new User { Id = 1014222, @@ -161,6 +254,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + Beatmap = beatmap, User = new User { Id = 1541390, @@ -179,6 +273,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + Beatmap = beatmap, User = new User { Id = 2243452, @@ -197,6 +292,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + Beatmap = beatmap, User = new User { Id = 2705430, @@ -215,6 +311,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + Beatmap = beatmap, User = new User { Id = 7151382, @@ -233,6 +330,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + Beatmap = beatmap, User = new User { Id = 2051389, @@ -251,6 +349,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + Beatmap = beatmap, User = new User { Id = 6169483, @@ -269,6 +368,7 @@ namespace osu.Game.Tests.Visual.SongSelect MaxCombo = 244, TotalScore = 1707827, //Mods = new Mod[] { new OsuModHidden(), new OsuModHardRock(), }, + Beatmap = beatmap, User = new User { Id = 6702666, @@ -281,8 +381,6 @@ namespace osu.Game.Tests.Visual.SongSelect }, }, }; - - leaderboard.Scores = scores; } private void showBeatmapWithStatus(BeatmapSetOnlineStatus status) diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs index 271fbde5c3..449401c0bf 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs @@ -31,10 +31,11 @@ namespace osu.Game.Tests.Visual.SongSelect private readonly TestBeatmapDifficultyCache testDifficultyCache = new TestBeatmapDifficultyCache(); [Test] - public void TestLocal([Values("Beatmap", "Some long title and stuff")] - string title, - [Values("Trial", "Some1's very hardest difficulty")] - string version) + public void TestLocal( + [Values("Beatmap", "Some long title and stuff")] + string title, + [Values("Trial", "Some1's very hardest difficulty")] + string version) { showMetadataForBeatmap(() => CreateWorkingBeatmap(new Beatmap { diff --git a/osu.Game.Tests/Visual/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/TestSceneOsuGame.cs index b347c39c1e..4e5e8517a4 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/TestSceneOsuGame.cs @@ -74,7 +74,6 @@ namespace osu.Game.Tests.Visual typeof(FileStore), typeof(ScoreManager), typeof(BeatmapManager), - typeof(KeyBindingStore), typeof(SettingsStore), typeof(RulesetConfigCache), typeof(OsuColour), diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs index 82b7e65c4f..e5bcc08924 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs @@ -5,18 +5,19 @@ 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.Timing; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Overlays; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play; using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface @@ -24,37 +25,125 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneBeatSyncedContainer : OsuTestScene { - private readonly NowPlayingOverlay np; + private TestBeatSyncedContainer beatContainer; - public TestSceneBeatSyncedContainer() + private MasterGameplayClockContainer gameplayClockContainer; + + [SetUpSteps] + public void SetUpSteps() { - Clock = new FramedClock(); - Clock.ProcessFrame(); - - AddRange(new Drawable[] + AddStep("Set beatmap", () => { - new BeatContainer - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - }, - np = new NowPlayingOverlay - { - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - } + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); }); + + AddStep("Create beat sync container", () => + { + Children = new Drawable[] + { + gameplayClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0) + { + Child = beatContainer = new TestBeatSyncedContainer + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + } + }; + }); + + AddStep("Start playback", () => gameplayClockContainer.Start()); } - protected override void LoadComplete() + [TestCase(false)] + [TestCase(true)] + public void TestDisallowMistimedEventFiring(bool allowMistimed) { - base.LoadComplete(); - np.ToggleVisibility(); + int? lastBeatIndex = null; + double? lastActuationTime = null; + TimingControlPoint lastTimingPoint = null; + + AddStep($"set mistimed to {(allowMistimed ? "allowed" : "disallowed")}", () => beatContainer.AllowMistimedEventFiring = allowMistimed); + + AddStep("Set time before zero", () => + { + beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => + { + lastActuationTime = gameplayClockContainer.CurrentTime; + lastTimingPoint = timingControlPoint; + lastBeatIndex = i; + beatContainer.NewBeat = null; + }; + + gameplayClockContainer.Seek(-1000); + }); + + AddUntilStep("wait for trigger", () => lastBeatIndex != null); + + if (!allowMistimed) + { + AddAssert("trigger is near beat length", () => lastActuationTime != null && lastBeatIndex != null && Precision.AlmostEquals(lastTimingPoint.Time + lastBeatIndex.Value * lastTimingPoint.BeatLength, lastActuationTime.Value, BeatSyncedContainer.MISTIMED_ALLOWANCE)); + } + else + { + AddAssert("trigger is not near beat length", () => lastActuationTime != null && lastBeatIndex != null && !Precision.AlmostEquals(lastTimingPoint.Time + lastBeatIndex.Value * lastTimingPoint.BeatLength, lastActuationTime.Value, BeatSyncedContainer.MISTIMED_ALLOWANCE)); + } } - private class BeatContainer : BeatSyncedContainer + [Test] + public void TestNegativeBeatsStillUsingBeatmapTiming() { - private const int flash_layer_heigth = 150; + int? lastBeatIndex = null; + double? lastBpm = null; + + AddStep("Set time before zero", () => + { + beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => + { + lastBeatIndex = i; + lastBpm = timingControlPoint.BPM; + }; + + gameplayClockContainer.Seek(-1000); + }); + + AddUntilStep("wait for trigger", () => lastBpm != null); + AddAssert("bpm is from beatmap", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 128)); + AddAssert("beat index is less than zero", () => lastBeatIndex < 0); + } + + [Test] + public void TestIdleBeatOnPausedClock() + { + double? lastBpm = null; + + AddStep("bind event", () => + { + beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => lastBpm = timingControlPoint.BPM; + }); + + AddUntilStep("wait for trigger", () => lastBpm != null); + AddAssert("bpm is from beatmap", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 128)); + + AddStep("pause gameplay clock", () => + { + lastBpm = null; + gameplayClockContainer.Stop(); + }); + + AddUntilStep("wait for trigger", () => lastBpm != null); + AddAssert("bpm is default", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 60)); + } + + private class TestBeatSyncedContainer : BeatSyncedContainer + { + private const int flash_layer_height = 150; + + public new bool AllowMistimedEventFiring + { + get => base.AllowMistimedEventFiring; + set => base.AllowMistimedEventFiring = value; + } private readonly InfoString timingPointCount; private readonly InfoString currentTimingPoint; @@ -64,13 +153,11 @@ namespace osu.Game.Tests.Visual.UserInterface private readonly InfoString adjustedBeatLength; private readonly InfoString timeUntilNextBeat; private readonly InfoString timeSinceLastBeat; + private readonly InfoString currentTime; private readonly Box flashLayer; - [Resolved] - private MusicController musicController { get; set; } - - public BeatContainer() + public TestBeatSyncedContainer() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -82,7 +169,7 @@ namespace osu.Game.Tests.Visual.UserInterface Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Bottom = flash_layer_heigth }, + Margin = new MarginPadding { Bottom = flash_layer_height }, Children = new Drawable[] { new Box @@ -98,6 +185,7 @@ namespace osu.Game.Tests.Visual.UserInterface Direction = FillDirection.Vertical, Children = new Drawable[] { + currentTime = new InfoString(@"Current time"), timingPointCount = new InfoString(@"Timing points amount"), currentTimingPoint = new InfoString(@"Current timing point"), beatCount = new InfoString(@"Beats amount (in the current timing point)"), @@ -116,7 +204,7 @@ namespace osu.Game.Tests.Visual.UserInterface Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, RelativeSizeAxes = Axes.X, - Height = flash_layer_heigth, + Height = flash_layer_height, Children = new Drawable[] { new Box @@ -133,8 +221,13 @@ namespace osu.Game.Tests.Visual.UserInterface } } }; + } - Beatmap.ValueChanged += delegate + protected override void LoadComplete() + { + base.LoadComplete(); + + Beatmap.BindValueChanged(_ => { timingPointCount.Value = 0; currentTimingPoint.Value = 0; @@ -144,7 +237,7 @@ namespace osu.Game.Tests.Visual.UserInterface adjustedBeatLength.Value = 0; timeUntilNextBeat.Value = 0; timeSinceLastBeat.Value = 0; - }; + }, true); } private List timingPoints => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ToList(); @@ -164,7 +257,7 @@ namespace osu.Game.Tests.Visual.UserInterface if (timingPoints.Count == 0) return 0; if (timingPoints[^1] == current) - return (int)Math.Ceiling((musicController.CurrentTrack.Length - current.Time) / current.BeatLength); + return (int)Math.Ceiling((BeatSyncClock.CurrentTime - current.Time) / current.BeatLength); return (int)Math.Ceiling((getNextTimingPoint(current).Time - current.Time) / current.BeatLength); } @@ -174,8 +267,11 @@ namespace osu.Game.Tests.Visual.UserInterface base.Update(); timeUntilNextBeat.Value = TimeUntilNextBeat; timeSinceLastBeat.Value = TimeSinceLastBeat; + currentTime.Value = BeatSyncClock.CurrentTime; } + public Action NewBeat; + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); @@ -187,7 +283,9 @@ namespace osu.Game.Tests.Visual.UserInterface beatsPerMinute.Value = 60000 / timingPoint.BeatLength; adjustedBeatLength.Value = timingPoint.BeatLength; - flashLayer.FadeOutFromOne(timingPoint.BeatLength); + flashLayer.FadeOutFromOne(timingPoint.BeatLength / 4); + + NewBeat?.Invoke(beatIndex, timingPoint, effectPoint, amplitudes); } } @@ -200,7 +298,7 @@ namespace osu.Game.Tests.Visual.UserInterface public double Value { - set => valueText.Text = $"{value:G}"; + set => valueText.Text = $"{value:0.##}"; } public InfoString(string header) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneColourPicker.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneColourPicker.cs new file mode 100644 index 0000000000..fa9179443d --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneColourPicker.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneColourPicker : OsuTestScene + { + private readonly Bindable colour = new Bindable(Colour4.Aquamarine); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create pickers", () => Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = @"No OverlayColourProvider", + Font = OsuFont.Default.With(size: 40) + }, + new OsuColourPicker + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = { BindTarget = colour }, + } + } + }, + new ColourProvidingContainer(OverlayColourScheme.Blue) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = @"With blue OverlayColourProvider", + Font = OsuFont.Default.With(size: 40) + }, + new OsuColourPicker + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Current = { BindTarget = colour }, + } + } + } + } + } + }); + + AddStep("set green", () => colour.Value = Colour4.LimeGreen); + AddStep("set white", () => colour.Value = Colour4.White); + AddStep("set red", () => colour.Value = Colour4.Red); + } + + private class ColourProvidingContainer : Container + { + [Cached] + private OverlayColourProvider provider { get; } + + public ColourProvidingContainer(OverlayColourScheme colourScheme) + { + provider = new OverlayColourProvider(colourScheme); + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.cs index 8179f92ffc..fe312ccc8f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledDrawable.cs @@ -2,11 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2; +using osuTK; using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface @@ -21,6 +24,45 @@ namespace osu.Game.Tests.Visual.UserInterface [TestCase(true)] public void TestNonPadded(bool hasDescription) => createPaddedComponent(hasDescription, false); + [Test] + public void TestFixedWidth() + { + const float label_width = 200; + + AddStep("create components", () => Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + new NonPaddedLabelledDrawable + { + Label = "short", + FixedLabelWidth = label_width + }, + new NonPaddedLabelledDrawable + { + Label = "very very very very very very very very very very very long", + FixedLabelWidth = label_width + }, + new PaddedLabelledDrawable + { + Label = "short", + FixedLabelWidth = label_width + }, + new PaddedLabelledDrawable + { + Label = "very very very very very very very very very very very long", + FixedLabelWidth = label_width + } + } + }); + + AddStep("unset label width", () => this.ChildrenOfType>().ForEach(d => d.FixedLabelWidth = null)); + AddStep("reset label width", () => this.ChildrenOfType>().ForEach(d => d.FixedLabelWidth = label_width)); + } + private void createPaddedComponent(bool hasDescription = false, bool padded = true) { AddStep("create component", () => diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.cs new file mode 100644 index 0000000000..e0d76b3e4a --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDifficultyAdjustSettings.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 System.Linq; +using NUnit.Framework; +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.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModDifficultyAdjustSettings : OsuManualInputManagerTestScene + { + private OsuModDifficultyAdjust modDifficultyAdjust; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create control", () => + { + modDifficultyAdjust = new OsuModDifficultyAdjust(); + + Child = new Container + { + Size = new Vector2(300), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ChildrenEnumerable = modDifficultyAdjust.CreateSettingsControls(), + }, + } + }; + }); + } + + [Test] + public void TestFollowsBeatmapDefaultsVisually() + { + setBeatmapWithDifficultyParameters(5); + + checkSliderAtValue("Circle Size", 5); + checkBindableAtValue("Circle Size", null); + + setBeatmapWithDifficultyParameters(8); + + checkSliderAtValue("Circle Size", 8); + checkBindableAtValue("Circle Size", null); + } + + [Test] + public void TestOutOfRangeValueStillApplied() + { + AddStep("set override cs to 11", () => modDifficultyAdjust.CircleSize.Value = 11); + + checkSliderAtValue("Circle Size", 11); + checkBindableAtValue("Circle Size", 11); + + // this is a no-op, just showing that it won't reset the value during deserialisation. + setExtendedLimits(false); + + checkSliderAtValue("Circle Size", 11); + checkBindableAtValue("Circle Size", 11); + + // setting extended limits will reset the serialisation exception. + // this should be fine as the goal is to allow, at most, the value of extended limits. + setExtendedLimits(true); + + checkSliderAtValue("Circle Size", 11); + checkBindableAtValue("Circle Size", 11); + } + + [Test] + public void TestExtendedLimits() + { + setSliderValue("Circle Size", 99); + + checkSliderAtValue("Circle Size", 10); + checkBindableAtValue("Circle Size", 10); + + setExtendedLimits(true); + + checkSliderAtValue("Circle Size", 10); + checkBindableAtValue("Circle Size", 10); + + setSliderValue("Circle Size", 99); + + checkSliderAtValue("Circle Size", 11); + checkBindableAtValue("Circle Size", 11); + + setExtendedLimits(false); + + checkSliderAtValue("Circle Size", 10); + checkBindableAtValue("Circle Size", 10); + } + + [Test] + public void TestUserOverrideMaintainedOnBeatmapChange() + { + setSliderValue("Circle Size", 9); + + setBeatmapWithDifficultyParameters(2); + + checkSliderAtValue("Circle Size", 9); + checkBindableAtValue("Circle Size", 9); + } + + [Test] + public void TestResetToDefault() + { + setBeatmapWithDifficultyParameters(2); + + setSliderValue("Circle Size", 9); + checkSliderAtValue("Circle Size", 9); + checkBindableAtValue("Circle Size", 9); + + resetToDefault("Circle Size"); + checkSliderAtValue("Circle Size", 2); + checkBindableAtValue("Circle Size", null); + } + + [Test] + public void TestUserOverrideMaintainedOnMatchingBeatmapValue() + { + setBeatmapWithDifficultyParameters(3); + + checkSliderAtValue("Circle Size", 3); + checkBindableAtValue("Circle Size", null); + + // need to initially change it away from the current beatmap value to trigger an override. + setSliderValue("Circle Size", 4); + setSliderValue("Circle Size", 3); + + checkSliderAtValue("Circle Size", 3); + checkBindableAtValue("Circle Size", 3); + + setBeatmapWithDifficultyParameters(4); + + checkSliderAtValue("Circle Size", 3); + checkBindableAtValue("Circle Size", 3); + } + + [Test] + public void TestResetToDefaults() + { + setBeatmapWithDifficultyParameters(5); + + setSliderValue("Circle Size", 3); + setExtendedLimits(true); + + checkSliderAtValue("Circle Size", 3); + checkBindableAtValue("Circle Size", 3); + + AddStep("reset mod settings", () => modDifficultyAdjust.ResetSettingsToDefaults()); + + checkSliderAtValue("Circle Size", 5); + checkBindableAtValue("Circle Size", null); + } + + private void resetToDefault(string name) + { + AddStep($"Reset {name} to default", () => + this.ChildrenOfType().First(c => c.LabelText == name) + .Current.SetDefault()); + } + + private void setExtendedLimits(bool status) => + AddStep($"Set extended limits {status}", () => modDifficultyAdjust.ExtendedLimits.Value = status); + + private void setSliderValue(string name, float value) + { + AddStep($"Set {name} slider to {value}", () => + this.ChildrenOfType().First(c => c.LabelText == name) + .ChildrenOfType>().First().Current.Value = value); + } + + private void checkBindableAtValue(string name, float? expectedValue) + { + AddAssert($"Bindable {name} is {(expectedValue?.ToString() ?? "null")}", () => + this.ChildrenOfType().First(c => c.LabelText == name) + .Current.Value == expectedValue); + } + + private void checkSliderAtValue(string name, float expectedValue) + { + AddAssert($"Slider {name} at {expectedValue}", () => + this.ChildrenOfType().First(c => c.LabelText == name) + .ChildrenOfType>().First().Current.Value == expectedValue); + } + + private void setBeatmapWithDifficultyParameters(float value) + { + AddStep($"set beatmap with all {value}", () => Beatmap.Value = CreateWorkingBeatmap(new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BaseDifficulty = new BeatmapDifficulty + { + OverallDifficulty = value, + CircleSize = value, + DrainRate = value, + ApproachRate = value, + } + } + })); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 2885dbee00..3485d7fbc3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -10,9 +10,9 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mania.Mods; @@ -51,6 +51,38 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("show", () => modSelect.Show()); } + /// + /// Ensure that two mod overlays are not cross polluting via central settings instances. + /// + [Test] + public void TestSettingsNotCrossPolluting() + { + Bindable> selectedMods2 = null; + + AddStep("select diff adjust", () => SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); + + AddStep("set setting", () => modSelect.ChildrenOfType>().First().Current.Value = 8); + + AddAssert("ensure setting is propagated", () => SelectedMods.Value.OfType().Single().CircleSize.Value == 8); + + AddStep("create second bindable", () => selectedMods2 = new Bindable>(new Mod[] { new OsuModDifficultyAdjust() })); + + AddStep("create second overlay", () => + { + Add(modSelect = new TestModSelectOverlay().With(d => + { + d.Origin = Anchor.TopCentre; + d.Anchor = Anchor.TopCentre; + d.SelectedMods.BindTarget = selectedMods2; + })); + }); + + AddStep("show", () => modSelect.Show()); + + AddAssert("ensure first is unchanged", () => SelectedMods.Value.OfType().Single().CircleSize.Value == 8); + AddAssert("ensure second is default", () => selectedMods2.Value.OfType().Single().CircleSize.Value == null); + } + [Test] public void TestSettingsResetOnDeselection() { @@ -104,15 +136,11 @@ namespace osu.Game.Tests.Visual.UserInterface var easierMods = osu.GetModsFor(ModType.DifficultyReduction); var harderMods = osu.GetModsFor(ModType.DifficultyIncrease); - var conversionMods = osu.GetModsFor(ModType.Conversion); 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 targetMod = conversionMods.FirstOrDefault(m => m is OsuModTarget); - var easy = easierMods.FirstOrDefault(m => m is OsuModEasy); var hardRock = harderMods.FirstOrDefault(m => m is OsuModHardRock); @@ -120,10 +148,6 @@ 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); - - testUnimplementedMod(targetMod); } [Test] @@ -149,7 +173,7 @@ namespace osu.Game.Tests.Visual.UserInterface changeRuleset(0); - AddAssert("ensure mods still selected", () => modDisplay.Current.Value.Single(m => m is OsuModNoFail) != null); + AddAssert("ensure mods still selected", () => modDisplay.Current.Value.SingleOrDefault(m => m is OsuModNoFail) != null); changeRuleset(3); @@ -253,6 +277,19 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("DT + HD still selected", () => modSelect.ChildrenOfType().Count(b => b.Selected) == 2); } + [Test] + public void TestUnimplementedModIsUnselectable() + { + var testRuleset = new TestUnimplementedModOsuRuleset(); + changeTestRuleset(testRuleset.RulesetInfo); + + var conversionMods = testRuleset.GetModsFor(ModType.Conversion); + + var unimplementedMod = conversionMods.FirstOrDefault(m => m is TestUnimplementedMod); + + testUnimplementedMod(unimplementedMod); + } + private void testSingleMod(Mod mod) { selectNext(mod); @@ -316,17 +353,6 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("check for no selection", () => !modSelect.SelectedMods.Value.Any()); } - private void testMultiplierTextColour(Mod mod, Func getCorrectColour) - { - checkLabelColor(() => Color4.White); - selectNext(mod); - AddWaitStep("wait for changing colour", 1); - checkLabelColor(getCorrectColour); - selectPrevious(mod); - AddWaitStep("wait for changing colour", 1); - checkLabelColor(() => Color4.White); - } - private void testModsWithSameBaseType(Mod modA, Mod modB) { selectNext(modA); @@ -348,7 +374,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert($"check {mod.Name} is selected", () => { var button = modSelect.GetModButton(mod); - return modSelect.SelectedMods.Value.Single(m => m.Name == mod.Name) != null && button.SelectedMod.GetType() == mod.GetType() && button.Selected; + return modSelect.SelectedMods.Value.SingleOrDefault(m => m.Name == mod.Name) != null && button.SelectedMod.GetType() == mod.GetType() && button.Selected; }); } @@ -358,6 +384,12 @@ namespace osu.Game.Tests.Visual.UserInterface waitForLoad(); } + private void changeTestRuleset(RulesetInfo rulesetInfo) + { + AddStep($"change ruleset to {rulesetInfo.Name}", () => { Ruleset.Value = rulesetInfo; }); + waitForLoad(); + } + private void waitForLoad() => AddUntilStep("wait for icons to load", () => modSelect.AllLoaded); @@ -370,8 +402,6 @@ namespace osu.Game.Tests.Visual.UserInterface }); } - private void checkLabelColor(Func getColour) => AddAssert("check label has expected colour", () => modSelect.MultiplierLabel.Colour.AverageColour == getColour()); - private void createDisplay(Func createOverlayFunc) { Children = new Drawable[] @@ -408,7 +438,6 @@ namespace osu.Game.Tests.Visual.UserInterface return section.ButtonsContainer.OfType().Single(b => b.Mods.Any(m => m.GetType() == mod.GetType())); } - public new OsuSpriteText MultiplierLabel => base.MultiplierLabel; public new TriangleButton DeselectAllButton => base.DeselectAllButton; public new Color4 LowMultiplierColour => base.LowMultiplierColour; @@ -419,5 +448,24 @@ namespace osu.Game.Tests.Visual.UserInterface { protected override bool Stacked => false; } + + private class TestUnimplementedMod : Mod + { + public override string Name => "Unimplemented mod"; + public override string Acronym => "UM"; + public override string Description => "A mod that is not implemented."; + public override double ScoreMultiplier => 1; + public override ModType Type => ModType.Conversion; + } + + private class TestUnimplementedModOsuRuleset : OsuRuleset + { + public override IEnumerable GetModsFor(ModType type) + { + if (type == ModType.Conversion) return base.GetModsFor(type).Concat(new[] { new TestUnimplementedMod() }); + + return base.GetModsFor(type); + } + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs index bda1973354..65db2e9644 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs @@ -88,7 +88,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("create mods", () => { original = new OsuModDoubleTime(); - copy = (OsuModDoubleTime)original.CreateCopy(); + copy = (OsuModDoubleTime)original.DeepClone(); }); AddStep("change property", () => original.SpeedChange.Value = 2); @@ -106,7 +106,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("create mods", () => { original = new MultiMod(new OsuModDoubleTime()); - copy = (MultiMod)original.CreateCopy(); + copy = (MultiMod)original.DeepClone(); }); AddStep("change property", () => ((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value = 2); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs index c5374d50ab..096bccae9e 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuIcon.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osuTK; @@ -59,7 +60,7 @@ namespace osu.Game.Tests.Visual.UserInterface private class Icon : Container, IHasTooltip { - public string TooltipText { get; } + public LocalisableString TooltipText { get; } public SpriteIcon SpriteIcon { get; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs index 931af7bc95..82e26cb87d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs @@ -95,6 +95,15 @@ _**italic with underscore, bold with asterisk**_"; }); } + [Test] + public void TestAutoLink() + { + AddStep("Add autolink", () => + { + markdownContainer.Text = ""; + }); + } + [Test] public void TestInlineCode() { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs new file mode 100644 index 0000000000..1848cf6a5e --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.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.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuPopover : OsuGridTestScene + { + public TestSceneOsuPopover() + : base(1, 2) + { + Cell(0, 0).Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = @"No OverlayColourProvider", + Font = OsuFont.Default.With(size: 40) + }, + new TriangleButtonWithPopover() + } + }; + + Cell(0, 1).Child = new ColourProvidingContainer(OverlayColourScheme.Orange) + { + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = @"With OverlayColourProvider (orange)", + Font = OsuFont.Default.With(size: 40) + }, + new TriangleButtonWithPopover() + } + } + }; + } + + private class TriangleButtonWithPopover : TriangleButton, IHasPopover + { + public TriangleButtonWithPopover() + { + Width = 100; + Height = 30; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Text = @"open"; + Action = this.ShowPopover; + } + + public Popover GetPopover() => new OsuPopover + { + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = @"sample text" + }, + new OsuTextBox + { + Width = 150, + Height = 30 + } + } + } + }; + } + + private class ColourProvidingContainer : Container + { + [Cached] + private OverlayColourProvider provider { get; } + + public ColourProvidingContainer(OverlayColourScheme colourScheme) + { + provider = new OverlayColourProvider(colourScheme); + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs index cd226662d7..4ce684d5af 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneProfileSubsectionHeader.cs @@ -57,7 +57,7 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestInitialVisibility() { - AddStep("Create header with 0 value", () => createHeader("Header with visible when zero counter", CounterVisibilityState.VisibleWhenZero, 0)); + AddStep("Create header with 0 value", () => 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); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.cs new file mode 100644 index 0000000000..64708c4858 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneVolumeOverlay.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.Overlays; +using osu.Game.Overlays.Volume; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneVolumeOverlay : OsuTestScene + { + private VolumeOverlay volume; + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddRange(new Drawable[] + { + volume = new VolumeOverlay(), + new VolumeControlReceptor + { + RelativeSizeAxes = Axes.Both, + ActionRequested = action => volume.Adjust(action), + ScrollActionRequested = (action, amount, isPrecise) => volume.Adjust(action, amount, isPrecise), + }, + }); + + AddStep("show controls", () => volume.Show()); + } + } +} diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index 35d3c7f202..161e248d96 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -5,7 +5,7 @@ - + diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs index 46c3b8bc3b..d2369056e1 100644 --- a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tournament.Tests.NonVisual [Test] public void TestDefaultDirectory() { - using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestDefaultDirectory))) { try { @@ -139,8 +139,13 @@ namespace osu.Game.Tournament.Tests.NonVisual } finally { - host.Storage.Delete("tournament.ini"); - host.Storage.DeleteDirectory("tournaments"); + try + { + host.Storage.Delete("tournament.ini"); + host.Storage.DeleteDirectory("tournaments"); + } + catch { } + host.Exit(); } } @@ -149,7 +154,8 @@ namespace osu.Game.Tournament.Tests.NonVisual private TournamentGameBase loadOsu(GameHost host) { var osu = new TournamentGameBase(); - Task.Run(() => host.Run(osu)); + Task.Run(() => host.Run(osu)) + .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); return osu; } diff --git a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs index 4c5f5a7a1a..e4eb5a36fb 100644 --- a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs @@ -55,7 +55,8 @@ namespace osu.Game.Tournament.Tests.NonVisual private TournamentGameBase loadOsu(GameHost host) { var osu = new TournamentGameBase(); - Task.Run(() => host.Run(osu)); + Task.Run(() => host.Run(osu)) + .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); return osu; } diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs index 0da8d1eb4a..bd1bacd549 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs @@ -28,6 +28,12 @@ namespace osu.Game.Tournament.Tests.Screens setMatchDate(TimeSpan.FromHours(3)); } + [Test] + public void TestNoCurrentMatch() + { + AddStep("Set null current match", () => Ladder.CurrentMatch.Value = null); + } + private void setMatchDate(TimeSpan relativeTime) // Humanizer cannot handle negative timespans. => AddStep($"start time is {relativeTime}", () => diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs index d414d8e36e..a18e73e38f 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs @@ -1,9 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; +using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Tournament.Models; +using osu.Game.Tournament.Screens.Ladder.Components; using osu.Game.Tournament.Screens.TeamIntro; namespace osu.Game.Tournament.Tests.Screens @@ -11,16 +15,41 @@ namespace osu.Game.Tournament.Tests.Screens public class TestSceneSeedingScreen : TournamentTestScene { [Cached] - private readonly LadderInfo ladder = new LadderInfo(); - - [BackgroundDependencyLoader] - private void load() + private readonly LadderInfo ladder = new LadderInfo { - Add(new SeedingScreen + Teams = + { + new TournamentTeam + { + FullName = { Value = @"Japan" }, + Acronym = { Value = "JPN" }, + SeedingResults = + { + new SeedingResult + { + // Mod intentionally left blank. + Seed = { Value = 4 } + }, + new SeedingResult + { + Mod = { Value = "DT" }, + Seed = { Value = 8 } + } + } + } + } + }; + + [Test] + public void TestBasic() + { + AddStep("create seeding screen", () => Add(new SeedingScreen { FillMode = FillMode.Fit, FillAspectRatio = 16 / 9f - }); + })); + + AddStep("set team to Japan", () => this.ChildrenOfType().Single().Current.Value = ladder.Teams.Single()); } } } diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj index 2084be765a..ba096abd36 100644 --- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj +++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj @@ -7,7 +7,7 @@ - + WinExe diff --git a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs index 0a3163ef43..50498304ca 100644 --- a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs +++ b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs @@ -11,7 +11,7 @@ using osu.Game.Tournament.IPC; namespace osu.Game.Tournament.Screens { - public abstract class BeatmapInfoScreen : TournamentScreen + public abstract class BeatmapInfoScreen : TournamentMatchScreen { protected readonly SongBar SongBar; diff --git a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs index 069ddfa4db..27ad6650d1 100644 --- a/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/RoundEditorScreen.cs @@ -147,7 +147,7 @@ namespace osu.Game.Tournament.Screens.Editors [Resolved] protected IAPIProvider API { get; private set; } - private readonly Bindable beatmapId = new Bindable(); + private readonly Bindable beatmapId = new Bindable(); private readonly Bindable mods = new Bindable(); @@ -220,14 +220,12 @@ namespace osu.Game.Tournament.Screens.Editors [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - beatmapId.Value = Model.ID.ToString(); - beatmapId.BindValueChanged(idString => + beatmapId.Value = Model.ID; + beatmapId.BindValueChanged(id => { - int.TryParse(idString.NewValue, out var parsed); + Model.ID = id.NewValue ?? 0; - Model.ID = parsed; - - if (idString.NewValue != idString.OldValue) + if (id.NewValue != id.OldValue) Model.BeatmapInfo = null; if (Model.BeatmapInfo != null) diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs index 7bd8d3f6a0..6418bf97da 100644 --- a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -147,7 +147,7 @@ namespace osu.Game.Tournament.Screens.Editors [Resolved] protected IAPIProvider API { get; private set; } - private readonly Bindable beatmapId = new Bindable(); + private readonly Bindable beatmapId = new Bindable(); private readonly Bindable score = new Bindable(); @@ -228,16 +228,12 @@ namespace osu.Game.Tournament.Screens.Editors [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - beatmapId.Value = Model.ID.ToString(); - beatmapId.BindValueChanged(idString => + beatmapId.Value = Model.ID; + beatmapId.BindValueChanged(id => { - int parsed; + Model.ID = id.NewValue ?? 0; - int.TryParse(idString.NewValue, out parsed); - - Model.ID = parsed; - - if (idString.NewValue != idString.OldValue) + if (id.NewValue != id.OldValue) Model.BeatmapInfo = null; if (Model.BeatmapInfo != null) diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index aa1be143ea..0d2e64f300 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -214,7 +214,7 @@ namespace osu.Game.Tournament.Screens.Editors [Resolved] private TournamentGameBase game { get; set; } - private readonly Bindable userId = new Bindable(); + private readonly Bindable userId = new Bindable(); private readonly Container drawableContainer; @@ -278,14 +278,12 @@ namespace osu.Game.Tournament.Screens.Editors [BackgroundDependencyLoader] private void load() { - userId.Value = user.Id.ToString(); - userId.BindValueChanged(idString => + userId.Value = user.Id; + userId.BindValueChanged(id => { - int.TryParse(idString.NewValue, out var parsed); + user.Id = id.NewValue ?? 0; - user.Id = parsed; - - if (idString.NewValue != idString.OldValue) + if (id.NewValue != id.OldValue) user.Username = string.Empty; if (!string.IsNullOrEmpty(user.Username)) diff --git a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs index e4ec45c00e..f61506d7f2 100644 --- a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs +++ b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs @@ -24,8 +24,6 @@ namespace osu.Game.Tournament.Screens.Gameplay { private readonly BindableBool warmup = new BindableBool(); - private readonly Bindable currentMatch = new Bindable(); - public readonly Bindable State = new Bindable(); private OsuButton warmupButton; private MatchIPCInfo ipc; @@ -131,14 +129,6 @@ namespace osu.Game.Tournament.Screens.Gameplay ladder.ChromaKeyWidth.BindValueChanged(width => chroma.Width = width.NewValue, true); - currentMatch.BindValueChanged(m => - { - warmup.Value = m.NewValue.Team1Score.Value + m.NewValue.Team2Score.Value == 0; - scheduledOperation?.Cancel(); - }); - - currentMatch.BindTo(ladder.CurrentMatch); - warmup.BindValueChanged(w => { warmupButton.Alpha = !w.NewValue ? 0.5f : 1; @@ -146,6 +136,17 @@ namespace osu.Game.Tournament.Screens.Gameplay }, true); } + protected override void CurrentMatchChanged(ValueChangedEvent match) + { + base.CurrentMatchChanged(match); + + if (match.NewValue == null) + return; + + warmup.Value = match.NewValue.Team1Score.Value + match.NewValue.Team2Score.Value == 0; + scheduledOperation?.Cancel(); + } + private ScheduledDelegate scheduledOperation; private MatchScoreDisplay scoreDisplay; @@ -161,9 +162,9 @@ namespace osu.Game.Tournament.Screens.Gameplay if (warmup.Value) return; if (ipc.Score1.Value > ipc.Score2.Value) - currentMatch.Value.Team1Score.Value++; + CurrentMatch.Value.Team1Score.Value++; else - currentMatch.Value.Team2Score.Value++; + CurrentMatch.Value.Team2Score.Value++; } scheduledOperation?.Cancel(); @@ -172,7 +173,7 @@ namespace osu.Game.Tournament.Screens.Gameplay { chat?.Contract(); - using (BeginDelayedSequence(300, true)) + using (BeginDelayedSequence(300)) { scoreDisplay.FadeIn(100); SongBar.Expanded = true; @@ -198,9 +199,9 @@ namespace osu.Game.Tournament.Screens.Gameplay // we should automatically proceed after a short delay if (lastState == TourneyState.Ranking && !warmup.Value) { - if (currentMatch.Value?.Completed.Value == true) + if (CurrentMatch.Value?.Completed.Value == true) scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(TeamWinScreen)); }, delay_before_progression); - else if (currentMatch.Value?.Completed.Value == false) + else if (CurrentMatch.Value?.Completed.Value == false) scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(MapPoolScreen)); }, delay_before_progression); } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs index 1c805bb42e..6937c69dbf 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs @@ -303,6 +303,15 @@ namespace osu.Game.Tournament.Screens.Ladder.Components Match.LosersProgression.Value = null; ladderInfo.Matches.Remove(Match); + + foreach (var m in ladderInfo.Matches) + { + if (m.Progression.Value == Match) + m.Progression.Value = null; + + if (m.LosersProgression.Value == Match) + m.LosersProgression.Value = null; + } } } } diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index 2c4fed8d86..d4292c5492 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -21,12 +21,10 @@ using osuTK.Input; namespace osu.Game.Tournament.Screens.MapPool { - public class MapPoolScreen : TournamentScreen + public class MapPoolScreen : TournamentMatchScreen { private readonly FillFlowContainer> mapFlows; - private readonly Bindable currentMatch = new Bindable(); - [Resolved(canBeNull: true)] private TournamentSceneManager sceneManager { get; set; } @@ -96,7 +94,7 @@ namespace osu.Game.Tournament.Screens.MapPool Action = reset }, new ControlPanel.Spacer(), - } + }, } }; } @@ -104,15 +102,12 @@ namespace osu.Game.Tournament.Screens.MapPool [BackgroundDependencyLoader] private void load(MatchIPCInfo ipc) { - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(LadderInfo.CurrentMatch); - ipc.Beatmap.BindValueChanged(beatmapChanged); } private void beatmapChanged(ValueChangedEvent beatmap) { - if (currentMatch.Value == null || currentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) < 2) + if (CurrentMatch.Value == null || CurrentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) < 2) return; // if bans have already been placed, beatmap changes result in a selection being made autoamtically @@ -137,12 +132,12 @@ namespace osu.Game.Tournament.Screens.MapPool { const TeamColour roll_winner = TeamColour.Red; //todo: draw from match - var nextColour = (currentMatch.Value.PicksBans.LastOrDefault()?.Team ?? roll_winner) == TeamColour.Red ? TeamColour.Blue : TeamColour.Red; + var nextColour = (CurrentMatch.Value.PicksBans.LastOrDefault()?.Team ?? roll_winner) == TeamColour.Red ? TeamColour.Blue : TeamColour.Red; - if (pickType == ChoiceType.Ban && currentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) >= 2) + if (pickType == ChoiceType.Ban && CurrentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) >= 2) setMode(pickColour, ChoiceType.Pick); else - setMode(nextColour, currentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) >= 2 ? ChoiceType.Pick : ChoiceType.Ban); + setMode(nextColour, CurrentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) >= 2 ? ChoiceType.Pick : ChoiceType.Ban); } protected override bool OnMouseDown(MouseDownEvent e) @@ -156,11 +151,11 @@ namespace osu.Game.Tournament.Screens.MapPool addForBeatmap(map.Beatmap.OnlineBeatmapID.Value); else { - var existing = currentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == map.Beatmap.OnlineBeatmapID); + var existing = CurrentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == map.Beatmap.OnlineBeatmapID); if (existing != null) { - currentMatch.Value.PicksBans.Remove(existing); + CurrentMatch.Value.PicksBans.Remove(existing); setNextMode(); } } @@ -173,7 +168,7 @@ namespace osu.Game.Tournament.Screens.MapPool private void reset() { - currentMatch.Value.PicksBans.Clear(); + CurrentMatch.Value.PicksBans.Clear(); setNextMode(); } @@ -181,18 +176,18 @@ namespace osu.Game.Tournament.Screens.MapPool private void addForBeatmap(int beatmapId) { - if (currentMatch.Value == null) + if (CurrentMatch.Value == null) return; - if (currentMatch.Value.Round.Value.Beatmaps.All(b => b.BeatmapInfo.OnlineBeatmapID != beatmapId)) + if (CurrentMatch.Value.Round.Value.Beatmaps.All(b => b.BeatmapInfo.OnlineBeatmapID != beatmapId)) // don't attempt to add if the beatmap isn't in our pool return; - if (currentMatch.Value.PicksBans.Any(p => p.BeatmapID == beatmapId)) + if (CurrentMatch.Value.PicksBans.Any(p => p.BeatmapID == beatmapId)) // don't attempt to add if already exists. return; - currentMatch.Value.PicksBans.Add(new BeatmapChoice + CurrentMatch.Value.PicksBans.Add(new BeatmapChoice { Team = pickColour, Type = pickType, @@ -201,17 +196,22 @@ namespace osu.Game.Tournament.Screens.MapPool setNextMode(); - if (pickType == ChoiceType.Pick && currentMatch.Value.PicksBans.Any(i => i.Type == 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); } } - private void matchChanged(ValueChangedEvent match) + protected override void CurrentMatchChanged(ValueChangedEvent match) { + base.CurrentMatchChanged(match); + mapFlows.Clear(); + if (match.NewValue == null) + return; + int totalRows = 0; if (match.NewValue.Round.Value != null) diff --git a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs index c1d8c8ddd3..e08be65465 100644 --- a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs +++ b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs @@ -96,19 +96,18 @@ namespace osu.Game.Tournament.Screens.Schedule } }, }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); - currentMatch.BindValueChanged(matchChanged); currentMatch.BindTo(ladder.CurrentMatch); + currentMatch.BindValueChanged(matchChanged, true); } private void matchChanged(ValueChangedEvent match) { - if (match.NewValue == null) - { - mainContainer.Clear(); - return; - } - var upcoming = ladder.Matches.Where(p => !p.Completed.Value && p.Team1.Value != null && p.Team2.Value != null && Math.Abs(p.Date.Value.DayOfYear - DateTimeOffset.UtcNow.DayOfYear) < 4); var conditionals = ladder .Matches.Where(p => !p.Completed.Value && (p.Team1.Value == null || p.Team2.Value == null) && Math.Abs(p.Date.Value.DayOfYear - DateTimeOffset.UtcNow.DayOfYear) < 4) @@ -117,6 +116,8 @@ namespace osu.Game.Tournament.Screens.Schedule upcoming = upcoming.Concat(conditionals); upcoming = upcoming.OrderBy(p => p.Date.Value).Take(8); + ScheduleContainer comingUpNext; + mainContainer.Child = new FillFlowContainer { RelativeSizeAxes = Axes.Both, @@ -153,57 +154,58 @@ namespace osu.Game.Tournament.Screens.Schedule } } }, - new ScheduleContainer("coming up next") + comingUpNext = new ScheduleContainer("coming up next") { RelativeSizeAxes = Axes.Both, Height = 0.25f, - Children = new Drawable[] - { - new FillFlowContainer - { - 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 ScheduleMatchDate(match.NewValue.Date.Value) - { - Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular) - } - } - }, - } - }, - } } } }; + + if (match.NewValue != null) + { + comingUpNext.Child = new FillFlowContainer + { + 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 ScheduleMatchDate(match.NewValue.Date.Value) + { + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular) + } + } + }, + } + }; + } } public class ScheduleMatch : DrawableTournamentMatch diff --git a/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs b/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs index 03f79b644f..3752d9d3be 100644 --- a/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs +++ b/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs @@ -30,7 +30,7 @@ namespace osu.Game.Tournament.Screens.Setup [Resolved] private MatchIPCInfo ipc { get; set; } - private DirectorySelector directorySelector; + private OsuDirectorySelector directorySelector; private DialogOverlay overlay; [BackgroundDependencyLoader(true)] @@ -79,7 +79,7 @@ namespace osu.Game.Tournament.Screens.Setup }, new Drawable[] { - directorySelector = new DirectorySelector(initialPath) + directorySelector = new OsuDirectorySelector(initialPath) { RelativeSizeAxes = Axes.Both, } diff --git a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs index 9785b7e647..32d458e191 100644 --- a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs +++ b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs @@ -2,10 +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.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Tournament.Components; using osu.Framework.Graphics.Shapes; +using osu.Game.Tournament.Models; using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Showcase @@ -39,5 +41,11 @@ namespace osu.Game.Tournament.Screens.Showcase } }); } + + protected override void CurrentMatchChanged(ValueChangedEvent match) + { + // showcase screen doesn't care about a match being selected. + // base call intentionally omitted to not show match warning. + } } } diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs index 4f66d89b7f..3a0bd232b0 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs @@ -18,12 +18,10 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamIntro { - public class SeedingScreen : TournamentScreen, IProvideVideo + public class SeedingScreen : TournamentMatchScreen, IProvideVideo { private Container mainContainer; - private readonly Bindable currentMatch = new Bindable(); - private readonly Bindable currentTeam = new Bindable(); [BackgroundDependencyLoader] @@ -50,13 +48,13 @@ namespace osu.Game.Tournament.Screens.TeamIntro { RelativeSizeAxes = Axes.X, Text = "Show first team", - Action = () => currentTeam.Value = currentMatch.Value.Team1.Value, + Action = () => currentTeam.Value = CurrentMatch.Value.Team1.Value, }, new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Show second team", - Action = () => currentTeam.Value = currentMatch.Value.Team2.Value, + Action = () => currentTeam.Value = CurrentMatch.Value.Team2.Value, }, new SettingsTeamDropdown(LadderInfo.Teams) { @@ -67,9 +65,6 @@ namespace osu.Game.Tournament.Screens.TeamIntro } }; - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(LadderInfo.CurrentMatch); - currentTeam.BindValueChanged(teamChanged, true); } @@ -84,8 +79,15 @@ namespace osu.Game.Tournament.Screens.TeamIntro showTeam(team.NewValue); } - private void matchChanged(ValueChangedEvent match) => - currentTeam.Value = currentMatch.Value.Team1.Value; + protected override void CurrentMatchChanged(ValueChangedEvent match) + { + base.CurrentMatchChanged(match); + + if (match.NewValue == null) + return; + + currentTeam.Value = match.NewValue.Team1.Value; + } private void showTeam(TournamentTeam team) { @@ -179,44 +181,48 @@ namespace osu.Game.Tournament.Screens.TeamIntro [BackgroundDependencyLoader] private void load(TextureStore textures) { + FillFlowContainer row; + InternalChildren = new Drawable[] { - new FillFlowContainer + row = 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 - }, - } - }, - } }, }; + + if (!string.IsNullOrEmpty(mods)) + { + row.Add(new Sprite + { + Texture = textures.Get($"mods/{mods.ToLower()}"), + Scale = new Vector2(0.5f) + }); + } + + row.Add(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 + }, + } + }); } } } diff --git a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs index 6c2848897b..74957cbca5 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs @@ -12,12 +12,10 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamIntro { - public class TeamIntroScreen : TournamentScreen, IProvideVideo + public class TeamIntroScreen : TournamentMatchScreen, IProvideVideo { private Container mainContainer; - private readonly Bindable currentMatch = new Bindable(); - [BackgroundDependencyLoader] private void load(Storage storage) { @@ -35,18 +33,16 @@ namespace osu.Game.Tournament.Screens.TeamIntro RelativeSizeAxes = Axes.Both, } }; - - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(LadderInfo.CurrentMatch); } - private void matchChanged(ValueChangedEvent match) + protected override void CurrentMatchChanged(ValueChangedEvent match) { + base.CurrentMatchChanged(match); + + mainContainer.Clear(); + if (match.NewValue == null) - { - mainContainer.Clear(); return; - } const float y_flag_offset = 292; diff --git a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs index 7ca262a2e8..ebe2908b74 100644 --- a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs +++ b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs @@ -13,11 +13,10 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamWin { - public class TeamWinScreen : TournamentScreen, IProvideVideo + public class TeamWinScreen : TournamentMatchScreen, IProvideVideo { private Container mainContainer; - private readonly Bindable currentMatch = new Bindable(); private readonly Bindable currentCompleted = new Bindable(); private TourneyVideo blueWinVideo; @@ -48,17 +47,19 @@ namespace osu.Game.Tournament.Screens.TeamWin } }; - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(ladder.CurrentMatch); - currentCompleted.BindValueChanged(_ => update()); } - private void matchChanged(ValueChangedEvent match) + protected override void CurrentMatchChanged(ValueChangedEvent match) { - currentCompleted.UnbindBindings(); - currentCompleted.BindTo(match.NewValue.Completed); + base.CurrentMatchChanged(match); + currentCompleted.UnbindBindings(); + + if (match.NewValue == null) + return; + + currentCompleted.BindTo(match.NewValue.Completed); update(); } @@ -66,7 +67,7 @@ namespace osu.Game.Tournament.Screens.TeamWin private void update() => Schedule(() => { - var match = currentMatch.Value; + var match = CurrentMatch.Value; if (match.Winner == null) { diff --git a/osu.Game.Tournament/Screens/TournamentMatchScreen.cs b/osu.Game.Tournament/Screens/TournamentMatchScreen.cs new file mode 100644 index 0000000000..5f00036653 --- /dev/null +++ b/osu.Game.Tournament/Screens/TournamentMatchScreen.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.Bindables; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Screens +{ + public abstract class TournamentMatchScreen : TournamentScreen + { + protected readonly Bindable CurrentMatch = new Bindable(); + private WarningBox noMatchWarning; + + protected override void LoadComplete() + { + base.LoadComplete(); + + CurrentMatch.BindTo(LadderInfo.CurrentMatch); + CurrentMatch.BindValueChanged(CurrentMatchChanged, true); + } + + protected virtual void CurrentMatchChanged(ValueChangedEvent match) + { + if (match.NewValue == null) + { + AddInternal(noMatchWarning = new WarningBox("Choose a match first from the brackets screen")); + return; + } + + noMatchWarning?.Expire(); + noMatchWarning = null; + } + } +} diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index 87e23e3404..cd0e601a2f 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -97,7 +97,12 @@ namespace osu.Game.Tournament }, } }, - heightWarning = new WarningBox("Please make the window wider"), + heightWarning = new WarningBox("Please make the window wider") + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding(20), + }, new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/.editorconfig b/osu.Game/.editorconfig new file mode 100644 index 0000000000..46a3dafd04 --- /dev/null +++ b/osu.Game/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +dotnet_diagnostic.OLOC001.prefix_namespace = osu.Game.Resources.Localisation \ No newline at end of file diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 00af06703d..0d16294c68 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -181,8 +181,13 @@ namespace osu.Game.Beatmaps if (existingOnlineId != null) { Delete(existingOnlineId); - beatmaps.PurgeDeletable(s => s.ID == existingOnlineId.ID); - LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been purged."); + + // in order to avoid a unique key constraint, immediately remove the online ID from the previous set. + existingOnlineId.OnlineBeatmapSetID = null; + foreach (var b in existingOnlineId.Beatmaps) + b.OnlineBeatmapID = null; + + LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been deleted."); } } } @@ -191,8 +196,6 @@ 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)) { @@ -319,6 +322,14 @@ 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 CanSkipImport(BeatmapSetInfo existing, BeatmapSetInfo import) + { + if (!base.CanSkipImport(existing, import)) + return false; + + return existing.Beatmaps.Any(b => b.OnlineBeatmapID != null); + } + protected override bool CanReuseExisting(BeatmapSetInfo existing, BeatmapSetInfo import) { if (!base.CanReuseExisting(existing, import)) diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs index 5dff4fe282..7824205257 100644 --- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs @@ -48,7 +48,6 @@ namespace osu.Game.Beatmaps public Task UpdateAsync(BeatmapSetInfo beatmapSet, CancellationToken cancellationToken) { - LogForModel(beatmapSet, "Performing online lookups..."); return Task.WhenAll(beatmapSet.Beatmaps.Select(b => UpdateAsync(beatmapSet, b, cancellationToken)).ToArray()); } diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index bfc0236db3..713f80d1fe 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -94,7 +94,10 @@ namespace osu.Game.Beatmaps public RomanisableString ToRomanisableString() { string author = Author == null ? string.Empty : $"({Author})"; - return new RomanisableString($"{ArtistUnicode} - {TitleUnicode} {author}".Trim(), $"{Artist} - {Title} {author}".Trim()); + var artistUnicode = string.IsNullOrEmpty(ArtistUnicode) ? Artist : ArtistUnicode; + var titleUnicode = string.IsNullOrEmpty(TitleUnicode) ? Title : TitleUnicode; + + return new RomanisableString($"{artistUnicode} - {titleUnicode} {author}".Trim(), $"{Artist} - {Title} {author}".Trim()); } [JsonIgnore] diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index e8dc623ddb..643c5d9adb 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -3,11 +3,12 @@ using System; using osu.Game.Graphics; +using osu.Game.Utils; using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public abstract class ControlPoint : IComparable + public abstract class ControlPoint : IComparable, IDeepCloneable { /// /// The time at which the control point takes effect. @@ -32,7 +33,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// Create an unbound copy of this control point. /// - public ControlPoint CreateCopy() + public ControlPoint DeepClone() { var copy = (ControlPoint)Activator.CreateInstance(GetType()); diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index d3a4b635f5..2d0fc17a7b 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -4,16 +4,18 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Lists; using osu.Framework.Utils; using osu.Game.Screens.Edit; +using osu.Game.Utils; namespace osu.Game.Beatmaps.ControlPoints { [Serializable] - public class ControlPointInfo + public class ControlPointInfo : IDeepCloneable { /// /// All control points grouped by time. @@ -66,6 +68,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The time to find the difficulty control point at. /// The difficulty control point. + [NotNull] public DifficultyControlPoint DifficultyPointAt(double time) => binarySearchWithFallback(DifficultyPoints, time, DifficultyControlPoint.DEFAULT); /// @@ -73,6 +76,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The time to find the effect control point at. /// The effect control point. + [NotNull] public EffectControlPoint EffectPointAt(double time) => binarySearchWithFallback(EffectPoints, time, EffectControlPoint.DEFAULT); /// @@ -80,6 +84,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The time to find the sound control point at. /// The sound control point. + [NotNull] public SampleControlPoint SamplePointAt(double time) => binarySearchWithFallback(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : SampleControlPoint.DEFAULT); /// @@ -87,6 +92,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// The time to find the timing control point at. /// The timing control point. + [NotNull] public TimingControlPoint TimingPointAt(double time) => binarySearchWithFallback(TimingPoints, time, TimingPoints.Count > 0 ? TimingPoints[0] : TimingControlPoint.DEFAULT); /// @@ -345,12 +351,12 @@ namespace osu.Game.Beatmaps.ControlPoints } } - public ControlPointInfo CreateCopy() + public ControlPointInfo DeepClone() { var controlPointInfo = new ControlPointInfo(); foreach (var point in AllControlPoints) - controlPointInfo.Add(point.Time, point.CreateCopy()); + controlPointInfo.Add(point.Time, point.DeepClone()); return controlPointInfo; } diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index 340c47d89b..ca910e70b8 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -101,10 +101,20 @@ namespace osu.Game.Beatmaps /// Rulesets ordered descending by their respective recommended difficulties. /// The currently selected ruleset will always be first. /// - private IEnumerable orderedRulesets => - recommendedDifficultyMapping - .OrderByDescending(pair => pair.Value).Select(pair => pair.Key).Where(r => !r.Equals(ruleset.Value)) - .Prepend(ruleset.Value); + private IEnumerable orderedRulesets + { + get + { + if (LoadState < LoadState.Ready || ruleset.Value == null) + return Enumerable.Empty(); + + return recommendedDifficultyMapping + .OrderByDescending(pair => pair.Value) + .Select(pair => pair.Key) + .Where(r => !r.Equals(ruleset.Value)) + .Prepend(ruleset.Value); + } + } private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => { diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index acbf57d25f..f14f6ec10c 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -251,11 +251,8 @@ namespace osu.Game.Beatmaps.Formats switch (beatmap.BeatmapInfo.RulesetID) { case 0: - position = ((IHasPosition)hitObject).Position; - break; - case 2: - position.X = ((IHasXPosition)hitObject).X; + position = ((IHasPosition)hitObject).Position; break; case 3: diff --git a/osu.Game/Beatmaps/MetadataUtils.cs b/osu.Game/Beatmaps/MetadataUtils.cs new file mode 100644 index 0000000000..56f5e3fe35 --- /dev/null +++ b/osu.Game/Beatmaps/MetadataUtils.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. + +#nullable enable + +using System.Linq; +using System.Text; + +namespace osu.Game.Beatmaps +{ + /// + /// Groups utility methods used to handle beatmap metadata. + /// + public static class MetadataUtils + { + /// + /// Returns if the character can be used in and fields. + /// Characters not matched by this method can be placed in and . + /// + public static bool IsRomanised(char c) => c <= 0xFF; + + /// + /// Returns if the string can be used in and fields. + /// Strings not matched by this method can be placed in and . + /// + public static bool IsRomanised(string? str) => string.IsNullOrEmpty(str) || str.All(IsRomanised); + + /// + /// Returns a copy of with all characters that do not match removed. + /// + public static string StripNonRomanisedCharacters(string? str) + { + if (string.IsNullOrEmpty(str)) + return string.Empty; + + var stringBuilder = new StringBuilder(str.Length); + + foreach (var c in str) + { + if (IsRomanised(c)) + stringBuilder.Append(c); + } + + return stringBuilder.ToString().Trim(); + } + } +} diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 0a55678fb7..662d24cc83 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -133,6 +133,9 @@ namespace osu.Game.Beatmaps IBeatmapProcessor processor = rulesetInstance.CreateBeatmapProcessor(converted); + foreach (var mod in mods.OfType()) + mod.ApplyToBeatmapProcessor(processor); + processor?.PreProcess(); // Compute default values for hitobjects, including creating nested hitobjects in-case they're needed @@ -325,6 +328,7 @@ namespace osu.Game.Beatmaps public ISkin Skin => skin.Value; protected abstract ISkin GetSkin(); + private readonly RecyclableLazy skin; public abstract Stream GetStream(string storagePath); diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index 086cc573d5..fe04c70d62 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -35,6 +35,7 @@ namespace osu.Game.Collections private const int database_version = 30000000; private const string database_name = "collection.db"; + private const string database_backup_name = "collection.db.bak"; public readonly BindableList Collections = new BindableList(); @@ -56,6 +57,17 @@ namespace osu.Game.Collections { Collections.CollectionChanged += collectionsChanged; + if (storage.Exists(database_backup_name)) + { + // If a backup file exists, it means the previous write operation didn't run to completion. + // Always prefer the backup file in such a case as it's the most recent copy that is guaranteed to not be malformed. + // + // The database is saved 100ms after any change, and again when the game is closed, so there shouldn't be a large diff between the two files in the worst case. + if (storage.Exists(database_name)) + storage.Delete(database_name); + File.Copy(storage.GetFullPath(database_backup_name), storage.GetFullPath(database_name)); + } + if (storage.Exists(database_name)) { List beatmapCollections; @@ -68,7 +80,7 @@ namespace osu.Game.Collections } } - private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e) + private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() => { switch (e.Action) { @@ -92,7 +104,7 @@ namespace osu.Game.Collections } backgroundSave(); - } + }); /// /// Set an endpoint for notifications to be posted to. @@ -257,23 +269,50 @@ namespace osu.Game.Collections { Interlocked.Increment(ref lastSave); + // This is NOT thread-safe!! try { - // This is NOT thread-safe!! + var tempPath = Path.GetTempFileName(); - using (var sw = new SerializationWriter(storage.GetStream(database_name, FileAccess.Write))) + using (var ms = new MemoryStream()) { - sw.Write(database_version); - sw.Write(Collections.Count); - - foreach (var c in Collections) + using (var sw = new SerializationWriter(ms, true)) { - sw.Write(c.Name.Value); - sw.Write(c.Beatmaps.Count); + sw.Write(database_version); - foreach (var b in c.Beatmaps) - sw.Write(b.MD5Hash); + var collectionsCopy = Collections.ToArray(); + sw.Write(collectionsCopy.Length); + + foreach (var c in collectionsCopy) + { + sw.Write(c.Name.Value); + + var beatmapsCopy = c.Beatmaps.ToArray(); + sw.Write(beatmapsCopy.Length); + + foreach (var b in beatmapsCopy) + sw.Write(b.MD5Hash); + } } + + using (var fs = File.OpenWrite(tempPath)) + ms.WriteTo(fs); + + var databasePath = storage.GetFullPath(database_name); + var databaseBackupPath = storage.GetFullPath(database_backup_name); + + // Back up the existing database, clearing any existing backup. + if (File.Exists(databaseBackupPath)) + File.Delete(databaseBackupPath); + if (File.Exists(databasePath)) + File.Move(databasePath, databaseBackupPath); + + // Move the new database in-place of the existing one. + File.Move(tempPath, databasePath); + + // If everything succeeded up to this point, remove the backup file. + if (File.Exists(databaseBackupPath)) + File.Delete(databaseBackupPath); } if (saveFailures < 10) diff --git a/osu.Game/Configuration/BackgroundSource.cs b/osu.Game/Configuration/BackgroundSource.cs index 5726e96eb1..18e0603860 100644 --- a/osu.Game/Configuration/BackgroundSource.cs +++ b/osu.Game/Configuration/BackgroundSource.cs @@ -1,11 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.ComponentModel; + namespace osu.Game.Configuration { public enum BackgroundSource { Skin, - Beatmap + Beatmap, + + [Description("Beatmap (with storyboard / video)")] + BeatmapWithStoryboard, } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 43bbd725c3..60a0d5a0ac 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -61,6 +61,9 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ShowOnlineExplicitContent, false); + SetDefault(OsuSetting.NotifyOnUsernameMentioned, true); + SetDefault(OsuSetting.NotifyOnPrivateMessage, true); + // Audio SetDefault(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01); @@ -259,6 +262,8 @@ namespace osu.Game.Configuration ScalingSizeY, UIScale, IntroSequence, + NotifyOnUsernameMentioned, + NotifyOnPrivateMessage, UIHoldActivationDelay, HitLighting, MenuBackgroundSource, diff --git a/osu.Game/Configuration/RankingType.cs b/osu.Game/Configuration/RankingType.cs deleted file mode 100644 index 7701e1dd1d..0000000000 --- a/osu.Game/Configuration/RankingType.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.ComponentModel; - -namespace osu.Game.Configuration -{ - public enum RankingType - { - Local, - - [Description("Global")] - Top, - - [Description("Selected Mods")] - SelectedMod, - Friends, - Country - } -} diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 3e50613093..f373e59417 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -12,6 +12,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; namespace osu.Game.Configuration @@ -30,7 +31,7 @@ namespace osu.Game.Configuration { public LocalisableString Label { get; } - public string Description { get; } + public LocalisableString Description { get; } public int? OrderPosition { get; } @@ -149,7 +150,7 @@ namespace osu.Game.Configuration break; case IBindable bindable: - var dropdownType = typeof(SettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]); + var dropdownType = typeof(ModSettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]); var dropdown = (Drawable)Activator.CreateInstance(dropdownType); dropdownType.GetProperty(nameof(SettingsDropdown.LabelText))?.SetValue(dropdown, attr.Label); @@ -183,5 +184,17 @@ namespace osu.Game.Configuration => obj.GetSettingsSourceProperties() .OrderBy(attr => attr.Item1) .ToArray(); + + private class ModSettingsEnumDropdown : SettingsEnumDropdown + where T : struct, Enum + { + protected override OsuDropdown CreateDropdown() => new ModDropdownControl(); + + private class ModDropdownControl : DropdownControl + { + // Set menu's max height low enough to workaround nested scroll issues (see https://github.com/ppy/osu-framework/issues/4536). + protected override DropdownMenu CreateMenu() => base.CreateMenu().With(m => m.MaxHeight = 100); + } + } } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 8efd451857..87bf54f981 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -78,7 +78,7 @@ namespace osu.Game.Database private readonly Bindable> itemRemoved = new Bindable>(); - public virtual IEnumerable HandledExtensions => new[] { ".zip" }; + public virtual IEnumerable HandledExtensions => new[] { @".zip" }; protected readonly FileStore Files; @@ -99,7 +99,7 @@ namespace osu.Game.Database ModelStore.ItemUpdated += item => handleEvent(() => itemUpdated.Value = new WeakReference(item)); ModelStore.ItemRemoved += item => handleEvent(() => itemRemoved.Value = new WeakReference(item)); - exportStorage = storage.GetStorageForDirectory("exports"); + exportStorage = storage.GetStorageForDirectory(@"exports"); Files = new FileStore(contextFactory, storage); @@ -282,7 +282,7 @@ namespace osu.Game.Database } catch (Exception e) { - LogForModel(model, $"Model creation of {archive.Name} failed.", e); + LogForModel(model, @$"Model creation of {archive.Name} failed.", e); return null; } @@ -309,6 +309,12 @@ namespace osu.Game.Database Logger.Log($"{prefix} {message}", LoggingTarget.Database); } + /// + /// Whether the implementation overrides with a custom implementation. + /// Custom hash implementations must bypass the early exit in the import flow (see usage). + /// + protected virtual bool HasCustomHashFunction => false; + /// /// Create a SHA-2 hash from the provided archive based on file content of all files matching . /// @@ -317,7 +323,11 @@ namespace osu.Game.Database /// protected virtual string ComputeHash(TModel item, ArchiveReader reader = null) { - // for now, concatenate all .osu files in the set to create a unique hash. + if (reader != null) + // fast hashing for cases where the item's files may not be populated. + return computeHashFast(reader); + + // for now, concatenate all hashable files in the set to create a unique hash. MemoryStream hashable = new MemoryStream(); foreach (TFileModel file in item.Files.Where(f => HashableFileTypes.Any(ext => f.Filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f.Filename)) @@ -329,9 +339,6 @@ namespace osu.Game.Database if (hashable.Length > 0) return hashable.ComputeSHA2Hash(); - if (reader != null) - return reader.Name.ComputeSHA2Hash(); - return item.Hash; } @@ -346,21 +353,50 @@ namespace osu.Game.Database { cancellationToken.ThrowIfCancellationRequested(); - delayEvents(); + bool checkedExisting = false; + TModel existing = null; + + if (archive != null && !HasCustomHashFunction) + { + // this is a fast bail condition to improve large import performance. + item.Hash = computeHashFast(archive); + + checkedExisting = true; + existing = CheckForExisting(item); + + if (existing != null) + { + // bare minimum comparisons + // + // note that this should really be checking filesizes on disk (of existing files) for some degree of sanity. + // or alternatively doing a faster hash check. either of these require database changes and reprocessing of existing files. + if (CanSkipImport(existing, item) && + getFilenames(existing.Files).SequenceEqual(getShortenedFilenames(archive).Select(p => p.shortened).OrderBy(f => f))) + { + LogForModel(item, @$"Found existing (optimised) {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); + Undelete(existing); + return existing; + } + + LogForModel(item, @"Found existing (optimised) but failed pre-check."); + } + } void rollback() { if (!Delete(item)) { // We may have not yet added the model to the underlying table, but should still clean up files. - LogForModel(item, "Dereferencing files for incomplete import."); + LogForModel(item, @"Dereferencing files for incomplete import."); Files.Dereference(item.Files.Select(f => f.FileInfo).ToArray()); } } + delayEvents(); + try { - LogForModel(item, "Beginning import..."); + LogForModel(item, @"Beginning import..."); item.Files = archive != null ? createFileInfos(archive, Files) : new List(); item.Hash = ComputeHash(item, archive); @@ -371,22 +407,24 @@ namespace osu.Game.Database { try { - if (!write.IsTransactionLeader) throw new InvalidOperationException($"Ensure there is no parent transaction so errors can correctly be handled by {this}"); + if (!write.IsTransactionLeader) throw new InvalidOperationException(@$"Ensure there is no parent transaction so errors can correctly be handled by {this}"); - var existing = CheckForExisting(item); + if (!checkedExisting) + existing = CheckForExisting(item); if (existing != null) { if (CanReuseExisting(existing, item)) { Undelete(existing); - LogForModel(item, $"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); + LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); // existing item will be used; rollback new import and exit early. rollback(); flushEvents(true); return existing; } + LogForModel(item, @"Found existing but failed re-use check."); Delete(existing); ModelStore.PurgeDeletable(s => s.ID == existing.ID); } @@ -403,12 +441,12 @@ namespace osu.Game.Database } } - LogForModel(item, "Import successfully completed!"); + LogForModel(item, @"Import successfully completed!"); } catch (Exception e) { if (!(e is TaskCanceledException)) - LogForModel(item, "Database import or population failed and has been rolled back.", e); + LogForModel(item, @"Database import or population failed and has been rolled back.", e); rollback(); flushEvents(false); @@ -428,7 +466,7 @@ namespace osu.Game.Database var retrievedItem = ModelStore.ConsumableItems.FirstOrDefault(s => s.ID == item.ID); if (retrievedItem == null) - throw new ArgumentException("Specified model could not be found", nameof(item)); + throw new ArgumentException(@"Specified model could not be found", nameof(item)); using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create)) ExportModelTo(retrievedItem, outputStream); @@ -637,6 +675,22 @@ namespace osu.Game.Database } } + private string computeHashFast(ArchiveReader reader) + { + MemoryStream hashable = new MemoryStream(); + + foreach (var file in reader.Filenames.Where(f => HashableFileTypes.Any(ext => f.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f)) + { + using (Stream s = reader.GetStream(file)) + s.CopyTo(hashable); + } + + if (hashable.Length > 0) + return hashable.ComputeSHA2Hash(); + + return reader.Name.ComputeSHA2Hash(); + } + /// /// Create all required s for the provided archive, adding them to the global file store. /// @@ -644,18 +698,14 @@ 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) + foreach (var filenames in getShortenedFilenames(reader)) { - using (Stream s = reader.GetStream(file)) + using (Stream s = reader.GetStream(filenames.original)) { fileInfos.Add(new TFileModel { - Filename = file.Substring(prefix.Length).ToStandardisedPath(), + Filename = filenames.shortened, FileInfo = files.Add(s) }); } @@ -664,6 +714,17 @@ namespace osu.Game.Database return fileInfos; } + private IEnumerable<(string original, string shortened)> getShortenedFilenames(ArchiveReader reader) + { + string prefix = reader.Filenames.GetCommonPrefix(); + if (!(prefix.EndsWith('/') || prefix.EndsWith('\\'))) + prefix = string.Empty; + + // import files to manager + foreach (string file in reader.Filenames) + yield return (file, file.Substring(prefix.Length).ToStandardisedPath()); + } + #region osu-stable import /// @@ -696,7 +757,7 @@ namespace osu.Game.Database { string fullPath = storage.GetFullPath(ImportFromStablePath); - Logger.Log($"Folder \"{fullPath}\" not available in the target osu!stable installation to import {HumanisedModelName}s.", LoggingTarget.Information, LogLevel.Error); + Logger.Log(@$"Folder ""{fullPath}"" not available in the target osu!stable installation to import {HumanisedModelName}s.", LoggingTarget.Information, LogLevel.Error); return Task.CompletedTask; } @@ -727,7 +788,7 @@ namespace osu.Game.Database /// The model to populate. /// The archive to use as a reference for population. May be null. /// An optional cancellation token. - protected virtual Task Populate(TModel model, [CanBeNull] ArchiveReader archive, CancellationToken cancellationToken = default) => Task.CompletedTask; + protected abstract Task Populate(TModel model, [CanBeNull] ArchiveReader archive, CancellationToken cancellationToken = default); /// /// Perform any final actions before the import to database executes. @@ -744,6 +805,15 @@ namespace osu.Game.Database /// An existing model which matches the criteria to skip importing, else null. protected TModel CheckForExisting(TModel model) => model.Hash == null ? null : ModelStore.ConsumableItems.FirstOrDefault(b => b.Hash == model.Hash); + /// + /// Whether inport can be skipped after finding an existing import early in the process. + /// Only valid when is not overridden. + /// + /// The existing model. + /// The newly imported model. + /// Whether to skip this import completely. + protected virtual bool CanSkipImport(TModel existing, TModel import) => true; + /// /// 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. @@ -771,7 +841,7 @@ namespace osu.Game.Database private DbSet queryModel() => ContextFactory.Get().Set(); - protected virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace("Info", "").ToLower()}"; + protected virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}"; #region Event handling / delaying diff --git a/osu.Game/Database/IHasGuidPrimaryKey.cs b/osu.Game/Database/IHasGuidPrimaryKey.cs new file mode 100644 index 0000000000..c9cd9b257a --- /dev/null +++ b/osu.Game/Database/IHasGuidPrimaryKey.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; +using Realms; + +namespace osu.Game.Database +{ + public interface IHasGuidPrimaryKey + { + [JsonIgnore] + [PrimaryKey] + Guid ID { get; set; } + } +} diff --git a/osu.Game/Database/IRealmFactory.cs b/osu.Game/Database/IRealmFactory.cs new file mode 100644 index 0000000000..0e93e5bf4f --- /dev/null +++ b/osu.Game/Database/IRealmFactory.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 Realms; + +namespace osu.Game.Database +{ + public interface IRealmFactory + { + /// + /// The main realm context, bound to the update thread. + /// If querying from a non-update thread is needed, use or to receive a context instead. + /// + Realm Context { get; } + + /// + /// Get a fresh context for read usage. + /// + RealmContextFactory.RealmUsage GetForRead(); + + /// + /// Request a context for write usage. + /// This method may block if a write is already active on a different thread. + /// + /// A usage containing a usable context. + RealmContextFactory.RealmWriteUsage GetForWrite(); + } +} diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs index 2aae62edea..68d186c65d 100644 --- a/osu.Game/Database/OsuDbContext.cs +++ b/osu.Game/Database/OsuDbContext.cs @@ -24,13 +24,15 @@ namespace osu.Game.Database public DbSet BeatmapDifficulty { get; set; } public DbSet BeatmapMetadata { get; set; } public DbSet BeatmapSetInfo { get; set; } - public DbSet DatabasedKeyBinding { get; set; } public DbSet DatabasedSetting { get; set; } public DbSet FileInfo { get; set; } public DbSet RulesetInfo { get; set; } public DbSet SkinInfo { get; set; } public DbSet ScoreInfo { get; set; } + // migrated to realm + public DbSet DatabasedKeyBinding { get; set; } + private readonly string connectionString; private static readonly Lazy logger = new Lazy(() => new OsuDbLoggerFactory()); @@ -75,6 +77,9 @@ namespace osu.Game.Database { cmd.CommandText = "PRAGMA journal_mode=WAL;"; cmd.ExecuteNonQuery(); + + cmd.CommandText = "PRAGMA foreign_keys=OFF;"; + cmd.ExecuteNonQuery(); } } catch diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs new file mode 100644 index 0000000000..ed3dc01f15 --- /dev/null +++ b/osu.Game/Database/RealmContextFactory.cs @@ -0,0 +1,263 @@ +// Copyright (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.Threading; +using osu.Framework.Allocation; +using osu.Framework.Development; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Statistics; +using osu.Game.Input.Bindings; +using Realms; + +namespace osu.Game.Database +{ + public class RealmContextFactory : Component, IRealmFactory + { + private readonly Storage storage; + + private const string database_name = @"client"; + + private const int schema_version = 6; + + /// + /// Lock object which is held for the duration of a write operation (via ). + /// + private readonly object writeLock = new object(); + + /// + /// Lock object which is held during sections. + /// + private readonly SemaphoreSlim blockingLock = new SemaphoreSlim(1); + + private static readonly GlobalStatistic reads = GlobalStatistics.Get("Realm", "Get (Read)"); + private static readonly GlobalStatistic writes = GlobalStatistics.Get("Realm", "Get (Write)"); + private static readonly GlobalStatistic refreshes = GlobalStatistics.Get("Realm", "Dirty Refreshes"); + private static readonly GlobalStatistic contexts_created = GlobalStatistics.Get("Realm", "Contexts (Created)"); + private static readonly GlobalStatistic pending_writes = GlobalStatistics.Get("Realm", "Pending writes"); + private static readonly GlobalStatistic active_usages = GlobalStatistics.Get("Realm", "Active usages"); + + private readonly object updateContextLock = new object(); + + private Realm context; + + public Realm Context + { + get + { + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException($"Use {nameof(GetForRead)} or {nameof(GetForWrite)} when performing realm operations from a non-update thread"); + + lock (updateContextLock) + { + if (context == null) + { + context = createContext(); + Logger.Log($"Opened realm \"{context.Config.DatabasePath}\" at version {context.Config.SchemaVersion}"); + } + + // creating a context will ensure our schema is up-to-date and migrated. + + return context; + } + } + } + + public RealmContextFactory(Storage storage) + { + this.storage = storage; + } + + public RealmUsage GetForRead() + { + reads.Value++; + return new RealmUsage(createContext()); + } + + public RealmWriteUsage GetForWrite() + { + writes.Value++; + pending_writes.Value++; + + Monitor.Enter(writeLock); + return new RealmWriteUsage(createContext(), writeComplete); + } + + /// + /// Flush any active contexts and block any further writes. + /// + /// + /// This should be used in places we need to ensure no ongoing reads/writes are occurring with realm. + /// ie. to move the realm backing file to a new location. + /// + /// An which should be disposed to end the blocking section. + public IDisposable BlockAllOperations() + { + if (IsDisposed) + throw new ObjectDisposedException(nameof(RealmContextFactory)); + + Logger.Log(@"Blocking realm operations.", LoggingTarget.Database); + + blockingLock.Wait(); + flushContexts(); + + return new InvokeOnDisposal(this, endBlockingSection); + + static void endBlockingSection(RealmContextFactory factory) + { + factory.blockingLock.Release(); + Logger.Log(@"Restoring realm operations.", LoggingTarget.Database); + } + } + + protected override void Update() + { + base.Update(); + + lock (updateContextLock) + { + if (context?.Refresh() == true) + refreshes.Value++; + } + } + + private Realm createContext() + { + try + { + if (IsDisposed) + throw new ObjectDisposedException(nameof(RealmContextFactory)); + + blockingLock.Wait(); + + contexts_created.Value++; + + return Realm.GetInstance(new RealmConfiguration(storage.GetFullPath($"{database_name}.realm", true)) + { + SchemaVersion = schema_version, + MigrationCallback = onMigration, + }); + } + finally + { + blockingLock.Release(); + } + } + + private void writeComplete() + { + Monitor.Exit(writeLock); + pending_writes.Value--; + } + + private void onMigration(Migration migration, ulong lastSchemaVersion) + { + switch (lastSchemaVersion) + { + case 5: + // let's keep things simple. changing the type of the primary key is a bit involved. + migration.NewRealm.RemoveAll(); + break; + } + } + + private void flushContexts() + { + Logger.Log(@"Flushing realm contexts...", LoggingTarget.Database); + Debug.Assert(blockingLock.CurrentCount == 0); + + Realm previousContext; + + lock (updateContextLock) + { + previousContext = context; + context = null; + } + + // wait for all threaded usages to finish + while (active_usages.Value > 0) + Thread.Sleep(50); + + previousContext?.Dispose(); + + Logger.Log(@"Realm contexts flushed.", LoggingTarget.Database); + } + + protected override void Dispose(bool isDisposing) + { + if (!IsDisposed) + { + // intentionally block all operations indefinitely. this ensures that nothing can start consuming a new context after disposal. + BlockAllOperations(); + blockingLock?.Dispose(); + } + + base.Dispose(isDisposing); + } + + /// + /// A usage of realm from an arbitrary thread. + /// + public class RealmUsage : IDisposable + { + public readonly Realm Realm; + + internal RealmUsage(Realm context) + { + active_usages.Value++; + Realm = context; + } + + /// + /// Disposes this instance, calling the initially captured action. + /// + public virtual void Dispose() + { + Realm?.Dispose(); + active_usages.Value--; + } + } + + /// + /// A transaction used for making changes to realm data. + /// + public class RealmWriteUsage : RealmUsage + { + private readonly Action onWriteComplete; + private readonly Transaction transaction; + + internal RealmWriteUsage(Realm context, Action onWriteComplete) + : base(context) + { + this.onWriteComplete = onWriteComplete; + transaction = Realm.BeginWrite(); + } + + /// + /// Commit all changes made in this transaction. + /// + public void Commit() => transaction.Commit(); + + /// + /// Revert all changes made in this transaction. + /// + public void Rollback() => transaction.Rollback(); + + /// + /// Disposes this instance, calling the initially captured action. + /// + public override void Dispose() + { + // rollback if not explicitly committed. + transaction?.Dispose(); + + base.Dispose(); + + onWriteComplete(); + } + } + } +} diff --git a/osu.Game/Database/RealmExtensions.cs b/osu.Game/Database/RealmExtensions.cs new file mode 100644 index 0000000000..aee36e81c5 --- /dev/null +++ b/osu.Game/Database/RealmExtensions.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 AutoMapper; +using osu.Game.Input.Bindings; +using Realms; + +namespace osu.Game.Database +{ + public static class RealmExtensions + { + private static readonly IMapper mapper = new MapperConfiguration(c => + { + c.ShouldMapField = fi => false; + c.ShouldMapProperty = pi => pi.SetMethod != null && pi.SetMethod.IsPublic; + + c.CreateMap(); + }).CreateMapper(); + + /// + /// Create a detached copy of the each item in the collection. + /// + /// A list of managed s to detach. + /// The type of object. + /// A list containing non-managed copies of provided items. + public static List Detach(this IEnumerable items) where T : RealmObject + { + var list = new List(); + + foreach (var obj in items) + list.Add(obj.Detach()); + + return list; + } + + /// + /// Create a detached copy of the item. + /// + /// The managed to detach. + /// The type of object. + /// A non-managed copy of provided item. Will return the provided item if already detached. + public static T Detach(this T item) where T : RealmObject + { + if (!item.IsManaged) + return item; + + return mapper.Map(item); + } + } +} diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index 19cc211709..13c37ddfe9 100644 --- a/osu.Game/Database/UserLookupCache.cs +++ b/osu.Game/Database/UserLookupCache.cs @@ -27,6 +27,30 @@ namespace osu.Game.Database [ItemCanBeNull] public Task GetUserAsync(int userId, CancellationToken token = default) => GetAsync(userId, token); + /// + /// Perform an API lookup on the specified users, populating a model. + /// + /// The users to lookup. + /// An optional cancellation token. + /// The populated users. May include null results for failed retrievals. + public Task GetUsersAsync(int[] userIds, CancellationToken token = default) + { + var userLookupTasks = new List>(); + + foreach (var u in userIds) + { + userLookupTasks.Add(GetUserAsync(u, token).ContinueWith(task => + { + if (!task.IsCompletedSuccessfully) + return null; + + return task.Result; + }, token)); + } + + return Task.WhenAll(userLookupTasks); + } + protected override async Task ComputeValueAsync(int lookup, CancellationToken token = default) => await queryUser(lookup).ConfigureAwait(false); diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index 2ac6e6ff22..52d8230fb6 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Threading; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; using osuTK; namespace osu.Game.Extensions @@ -57,6 +58,9 @@ namespace osu.Game.Extensions component.Anchor = info.Anchor; component.Origin = info.Origin; + if (component is ISkinnableDrawable skinnable) + skinnable.UsesFixedAnchor = info.UsesFixedAnchor; + if (component is Container container) { foreach (var child in info.Children) diff --git a/osu.Game/Extensions/LanguageExtensions.cs b/osu.Game/Extensions/LanguageExtensions.cs new file mode 100644 index 0000000000..b67e7fb6fc --- /dev/null +++ b/osu.Game/Extensions/LanguageExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using osu.Game.Localisation; + +namespace osu.Game.Extensions +{ + /// + /// Conversion utilities for the enum. + /// + public static class LanguageExtensions + { + /// + /// Returns the culture code of the that corresponds to the supplied . + /// + /// + /// This is required as enum member names are not allowed to contain hyphens. + /// + public static string ToCultureCode(this Language language) + => language.ToString().Replace("_", "-"); + + /// + /// Attempts to parse the supplied to a value. + /// + /// The code of the culture to parse. + /// The parsed . Valid only if the return value of the method is . + /// Whether the parsing succeeded. + public static bool TryParseCultureCode(string cultureCode, out Language language) + => Enum.TryParse(cultureCode.Replace("-", "_"), out language); + } +} diff --git a/osu.Game/Graphics/Backgrounds/Background.cs b/osu.Game/Graphics/Backgrounds/Background.cs index c90b1e0e98..cfc1eb1806 100644 --- a/osu.Game/Graphics/Backgrounds/Background.cs +++ b/osu.Game/Graphics/Backgrounds/Background.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; @@ -14,7 +15,7 @@ namespace osu.Game.Graphics.Backgrounds /// /// A background which offers blurring via a on demand. /// - public class Background : CompositeDrawable + public class Background : CompositeDrawable, IEquatable { private const float blur_scale = 0.5f; @@ -71,5 +72,14 @@ namespace osu.Game.Graphics.Backgrounds bufferedContainer?.BlurTo(newBlurSigma * blur_scale, duration, easing); } + + public virtual bool Equals(Background other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return other.GetType() == GetType() + && other.textureName == textureName; + } } } diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs index 058d2ed0f9..e0c15dd52a 100644 --- a/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs +++ b/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs @@ -24,5 +24,14 @@ namespace osu.Game.Graphics.Backgrounds { Sprite.Texture = Beatmap?.Background ?? textures.Get(fallbackTextureName); } + + public override bool Equals(Background other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return other.GetType() == GetType() + && ((BeatmapBackground)other).Beatmap == Beatmap; + } } } diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.cs new file mode 100644 index 0000000000..6a42e83305 --- /dev/null +++ b/osu.Game/Graphics/Backgrounds/BeatmapBackgroundWithStoryboard.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.Timing; +using osu.Game.Beatmaps; +using osu.Game.Storyboards.Drawables; + +namespace osu.Game.Graphics.Backgrounds +{ + public class BeatmapBackgroundWithStoryboard : BeatmapBackground + { + public BeatmapBackgroundWithStoryboard(WorkingBeatmap beatmap, string fallbackTextureName = "Backgrounds/bg1") + : base(beatmap, fallbackTextureName) + { + } + + [BackgroundDependencyLoader] + private void load() + { + if (!Beatmap.Storyboard.HasDrawable) + return; + + if (Beatmap.Storyboard.ReplacesBackground) + Sprite.Alpha = 0; + + LoadComponentAsync(new AudioContainer + { + RelativeSizeAxes = Axes.Both, + Volume = { Value = 0 }, + Child = new DrawableStoryboard(Beatmap.Storyboard) { Clock = new InterpolatingFramedClock(Beatmap.Track) } + }, AddInternal); + } + } +} diff --git a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs index a48da37804..f01a26a3a8 100644 --- a/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs +++ b/osu.Game/Graphics/Backgrounds/SeasonalBackgroundLoader.cs @@ -99,5 +99,14 @@ namespace osu.Game.Graphics.Backgrounds // ensure we're not loading in without a transition. this.FadeInFromZero(200, Easing.InOutSine); } + + public override bool Equals(Background other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return other.GetType() == GetType() + && ((SeasonalBackground)other).url == url; + } } } diff --git a/osu.Game/Graphics/Backgrounds/SkinBackground.cs b/osu.Game/Graphics/Backgrounds/SkinBackground.cs new file mode 100644 index 0000000000..9266e7b17b --- /dev/null +++ b/osu.Game/Graphics/Backgrounds/SkinBackground.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Game.Skinning; + +namespace osu.Game.Graphics.Backgrounds +{ + internal class SkinBackground : Background + { + private readonly Skin skin; + + public SkinBackground(Skin skin, string fallbackTextureName) + : base(fallbackTextureName) + { + this.skin = skin; + } + + [BackgroundDependencyLoader] + private void load() + { + Sprite.Texture = skin.GetTexture("menu-background") ?? Sprite.Texture; + } + + public override bool Equals(Background other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + + return other.GetType() == GetType() + && ((SkinBackground)other).skin.SkinInfo.Equals(skin.SkinInfo); + } + } +} diff --git a/osu.Game/Graphics/Backgrounds/Triangles.cs b/osu.Game/Graphics/Backgrounds/Triangles.cs index 67cee883c8..269360c492 100644 --- a/osu.Game/Graphics/Backgrounds/Triangles.cs +++ b/osu.Game/Graphics/Backgrounds/Triangles.cs @@ -57,12 +57,6 @@ namespace osu.Game.Graphics.Backgrounds } } - /// - /// Whether we want to expire triangles as they exit our draw area completely. - /// - [Obsolete("Unused.")] // Can be removed 20210518 - protected virtual bool ExpireOffScreenTriangles => true; - /// /// Whether we should create new triangles as others expire. /// diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index 1c9cdc174a..6e4901ab1a 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -1,19 +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.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Screens.Play; namespace osu.Game.Graphics.Containers { + /// + /// A container which fires a callback when a new beat is reached. + /// Consumes a parent or (whichever is first available). + /// + /// + /// This container does not set its own clock to the source used for beat matching. + /// This means that if the beat source clock is playing faster or slower, animations may unexpectedly overlap. + /// Make sure this container's Clock is also set to the expected source (or within a parent element which provides this). + /// + /// This container will also trigger beat events when the beat matching clock is paused at 's BPM. + /// public class BeatSyncedContainer : Container { - protected readonly IBindable Beatmap = new Bindable(); - private int lastBeat; private TimingControlPoint lastTimingPoint; @@ -23,6 +36,19 @@ namespace osu.Game.Graphics.Containers /// protected double EarlyActivationMilliseconds; + /// + /// While this container automatically applied an animation delay (meaning any animations inside a implementation will + /// always be correctly timed), the event itself can potentially fire away from the related beat. + /// + /// By setting this to false, cases where the event is to be fired more than from the related beat will be skipped. + /// + protected bool AllowMistimedEventFiring = true; + + /// + /// The maximum deviance from the actual beat that an can fire when is set to false. + /// + public const double MISTIMED_ALLOWANCE = 16; + /// /// The time in milliseconds until the next beat. /// @@ -43,16 +69,49 @@ namespace osu.Game.Graphics.Containers /// public double MinimumBeatLength { get; set; } + /// + /// Whether this container is currently tracking a beatmap's timing data. + /// protected bool IsBeatSyncedWithTrack { get; private set; } + protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + } + + [Resolved] + protected IBindable Beatmap { get; private set; } + + [Resolved(canBeNull: true)] + protected GameplayClock GameplayClock { get; private set; } + + protected IClock BeatSyncClock + { + get + { + if (GameplayClock != null) + return GameplayClock; + + if (Beatmap.Value.TrackLoaded) + return Beatmap.Value.Track; + + return null; + } + } + protected override void Update() { ITrack track = null; IBeatmap beatmap = null; - double currentTrackTime = 0; - TimingControlPoint timingPoint = null; - EffectControlPoint effectPoint = null; + TimingControlPoint timingPoint; + EffectControlPoint effectPoint; + + IClock clock = BeatSyncClock; + + if (clock == null) + return; + + double currentTrackTime = clock.CurrentTime; if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded) { @@ -60,23 +119,26 @@ namespace osu.Game.Graphics.Containers beatmap = Beatmap.Value.Beatmap; } - if (track != null && beatmap != null && track.IsRunning && track.Length > 0) + IsBeatSyncedWithTrack = beatmap != null && clock.IsRunning && track?.Length > 0; + + if (IsBeatSyncedWithTrack) { - currentTrackTime = track.CurrentTime + EarlyActivationMilliseconds; + Debug.Assert(beatmap != null); timingPoint = beatmap.ControlPointInfo.TimingPointAt(currentTrackTime); effectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTrackTime); } - - IsBeatSyncedWithTrack = timingPoint?.BeatLength > 0; - - if (timingPoint == null || !IsBeatSyncedWithTrack) + else { + // this may be the case where the beat syncing clock has been paused. + // we still want to show an idle animation, so use this container's time instead. currentTrackTime = Clock.CurrentTime; timingPoint = TimingControlPoint.DEFAULT; effectPoint = EffectControlPoint.DEFAULT; } + currentTrackTime += EarlyActivationMilliseconds; + double beatLength = timingPoint.BeatLength / Divisor; while (beatLength < MinimumBeatLength) @@ -89,7 +151,7 @@ namespace osu.Game.Graphics.Containers beatIndex--; TimeUntilNextBeat = (timingPoint.Time - currentTrackTime) % beatLength; - if (TimeUntilNextBeat < 0) + if (TimeUntilNextBeat <= 0) TimeUntilNextBeat += beatLength; TimeSinceLastBeat = beatLength - TimeUntilNextBeat; @@ -97,21 +159,16 @@ namespace osu.Game.Graphics.Containers if (timingPoint == lastTimingPoint && beatIndex == lastBeat) return; - using (BeginDelayedSequence(-TimeSinceLastBeat, true)) - OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? ChannelAmplitudes.Empty); + // as this event is sometimes used for sound triggers where `BeginDelayedSequence` has no effect, avoid firing it if too far away from the beat. + // this can happen after a seek operation. + if (AllowMistimedEventFiring || Math.Abs(TimeSinceLastBeat) < MISTIMED_ALLOWANCE) + { + using (BeginDelayedSequence(-TimeSinceLastBeat)) + OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? ChannelAmplitudes.Empty); + } lastBeat = beatIndex; lastTimingPoint = timingPoint; } - - [BackgroundDependencyLoader] - private void load(IBindable beatmap) - { - Beatmap.BindTo(beatmap); - } - - protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) - { - } } } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs index 6facf4e26c..81f30bd406 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownContainer.cs @@ -30,9 +30,12 @@ namespace osu.Game.Graphics.Containers.Markdown break; case ListItemBlock listItemBlock: - var isOrdered = ((ListBlock)listItemBlock.Parent).IsOrdered; - var childContainer = CreateListItem(listItemBlock, level, isOrdered); + bool isOrdered = ((ListBlock)listItemBlock.Parent)?.IsOrdered == true; + + OsuMarkdownListItem childContainer = CreateListItem(listItemBlock, level, isOrdered); + container.Add(childContainer); + foreach (var single in listItemBlock) base.AddMarkdownComponent(single, childContainer.Content, level); break; diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs index 40eb4cad15..a3a86df678 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownHeading.cs @@ -20,7 +20,8 @@ namespace osu.Game.Graphics.Containers.Markdown public override MarkdownTextFlowContainer CreateTextFlow() => new HeadingTextFlowContainer { - Weight = GetFontWeightByLevel(level), + FontSize = GetFontSizeByLevel(level), + FontWeight = GetFontWeightByLevel(level), }; protected override float GetFontSizeByLevel(int level) @@ -28,27 +29,25 @@ namespace osu.Game.Graphics.Containers.Markdown // Reference for this font size // https://github.com/ppy/osu-web/blob/376cac43a051b9c85ce95e2c446099be187b3e45/resources/assets/less/bem/osu-md.less#L9 // https://github.com/ppy/osu-web/blob/376cac43a051b9c85ce95e2c446099be187b3e45/resources/assets/less/variables.less#L161 - const float base_font_size = 14; - switch (level) { case 1: - return 30 / base_font_size; + return 30; case 2: - return 26 / base_font_size; + return 26; case 3: - return 20 / base_font_size; + return 20; case 4: - return 18 / base_font_size; + return 18; case 5: - return 16 / base_font_size; + return 16; default: - return 1; + return 14; } } @@ -67,9 +66,11 @@ namespace osu.Game.Graphics.Containers.Markdown private class HeadingTextFlowContainer : OsuMarkdownTextFlowContainer { - public FontWeight Weight { get; set; } + public float FontSize; + public FontWeight FontWeight; - protected override SpriteText CreateSpriteText() => base.CreateSpriteText().With(t => t.Font = t.Font.With(weight: Weight)); + protected override SpriteText CreateSpriteText() + => base.CreateSpriteText().With(t => t.Font = t.Font.With(size: FontSize, weight: FontWeight)); } } } diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.cs new file mode 100644 index 0000000000..ce8a9c8f9f --- /dev/null +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownImage.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 Markdig.Syntax.Inlines; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; + +namespace osu.Game.Graphics.Containers.Markdown +{ + public class OsuMarkdownImage : MarkdownImage, IHasTooltip + { + public LocalisableString TooltipText { get; } + + public OsuMarkdownImage(LinkInline linkInline) + : base(linkInline.Url) + { + TooltipText = linkInline.Title; + } + } +} diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs index f91a0e40e3..82e556f653 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs @@ -26,6 +26,12 @@ namespace osu.Game.Graphics.Containers.Markdown title = linkInline.Title; } + public OsuMarkdownLinkText(AutolinkInline autolinkInline) + : base(autolinkInline) + { + text = autolinkInline.Url; + } + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs index f3308019ce..a7cd6b3905 100644 --- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs +++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs @@ -17,6 +17,11 @@ namespace osu.Game.Graphics.Containers.Markdown protected override void AddLinkText(string text, LinkInline linkInline) => AddDrawable(new OsuMarkdownLinkText(text, linkInline)); + protected override void AddAutoLink(AutolinkInline autolinkInline) + => AddDrawable(new OsuMarkdownLinkText(autolinkInline)); + + protected override void AddImage(LinkInline linkInline) => AddDrawable(new OsuMarkdownImage(linkInline)); + // TODO : Change font to monospace protected override void AddCodeInLine(CodeInline codeInline) => AddDrawable(new OsuMarkdownInlineCode { diff --git a/osu.Game/Graphics/Containers/OsuClickableContainer.cs b/osu.Game/Graphics/Containers/OsuClickableContainer.cs index 1f31e4cdda..bf397e4251 100644 --- a/osu.Game/Graphics/Containers/OsuClickableContainer.cs +++ b/osu.Game/Graphics/Containers/OsuClickableContainer.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.Containers @@ -17,14 +18,14 @@ namespace osu.Game.Graphics.Containers protected override Container Content => content; - protected virtual HoverClickSounds CreateHoverClickSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet); + protected virtual HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet); - public OsuClickableContainer(HoverSampleSet sampleSet = HoverSampleSet.Normal) + public OsuClickableContainer(HoverSampleSet sampleSet = HoverSampleSet.Default) { this.sampleSet = sampleSet; } - public virtual string TooltipText { get; set; } + public virtual LocalisableString TooltipText { get; set; } [BackgroundDependencyLoader] private void load() @@ -38,7 +39,7 @@ namespace osu.Game.Graphics.Containers InternalChildren = new Drawable[] { content, - CreateHoverClickSounds(sampleSet) + CreateHoverSounds(sampleSet) }; } } diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index c0518247a9..b9b098df80 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -107,10 +107,10 @@ namespace osu.Game.Graphics.Containers { } - private bool playedPopInSound; - protected override void UpdateState(ValueChangedEvent state) { + bool didChange = state.NewValue != state.OldValue; + switch (state.NewValue) { case Visibility.Visible: @@ -121,18 +121,15 @@ namespace osu.Game.Graphics.Containers return; } - samplePopIn?.Play(); - playedPopInSound = true; + if (didChange) + samplePopIn?.Play(); if (BlockScreenWideMouse && DimMainContent) game?.AddBlockingOverlay(this); break; case Visibility.Hidden: - if (playedPopInSound) - { + if (didChange) samplePopOut?.Play(); - playedPopInSound = false; - } if (BlockScreenWideMouse) game?.RemoveBlockingOverlay(this); break; diff --git a/osu.Game/Graphics/Containers/OsuHoverContainer.cs b/osu.Game/Graphics/Containers/OsuHoverContainer.cs index 67af79c763..ac66fd658a 100644 --- a/osu.Game/Graphics/Containers/OsuHoverContainer.cs +++ b/osu.Game/Graphics/Containers/OsuHoverContainer.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Input.Events; using osuTK.Graphics; using System.Collections.Generic; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.Containers { @@ -20,7 +21,8 @@ namespace osu.Game.Graphics.Containers protected virtual IEnumerable EffectTargets => new[] { Content }; - public OsuHoverContainer() + public OsuHoverContainer(HoverSampleSet sampleSet = HoverSampleSet.Default) + : base(sampleSet) { Enabled.ValueChanged += e => { diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index 2488fd14d0..d2b1e5e523 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -172,6 +172,8 @@ namespace osu.Game.Graphics.Containers private class ScalingBackgroundScreen : BackgroundScreenDefault { + protected override bool AllowStoryboardBackground => false; + public override void OnEntering(IScreen last) { this.FadeInFromZero(4000, Easing.OutQuint); diff --git a/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.cs b/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.cs new file mode 100644 index 0000000000..90b2d20e4d --- /dev/null +++ b/osu.Game/Graphics/Containers/SelectionCycleFillFlowContainer.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.Diagnostics; +using osu.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Graphics.Containers +{ + /// + /// A FillFlowContainer that provides functionality to cycle selection between children + /// The selection wraps around when overflowing past the first or last child. + /// + public class SelectionCycleFillFlowContainer : FillFlowContainer where T : Drawable, IStateful + { + public T Selected => (selectedIndex >= 0 && selectedIndex < Count) ? this[selectedIndex.Value] : null; + + private int? selectedIndex; + + public void SelectNext() + { + if (!selectedIndex.HasValue || selectedIndex == Count - 1) + setSelected(0); + else + setSelected(selectedIndex.Value + 1); + } + + public void SelectPrevious() + { + if (!selectedIndex.HasValue || selectedIndex == 0) + setSelected(Count - 1); + else + setSelected(selectedIndex.Value - 1); + } + + public void Deselect() => setSelected(null); + + public void Select(T item) + { + var newIndex = IndexOf(item); + + if (newIndex < 0) + setSelected(null); + else + setSelected(IndexOf(item)); + } + + public override void Add(T drawable) + { + base.Add(drawable); + + Debug.Assert(drawable != null); + + drawable.StateChanged += state => selectionChanged(drawable, state); + } + + public override bool Remove(T drawable) + => throw new NotSupportedException($"Cannot remove drawables from {nameof(SelectionCycleFillFlowContainer)}"); + + private void setSelected(int? value) + { + if (selectedIndex == value) + return; + + // Deselect the previously-selected button + if (selectedIndex.HasValue) + this[selectedIndex.Value].State = SelectionState.NotSelected; + + selectedIndex = value; + + // Select the newly-selected button + if (selectedIndex.HasValue) + this[selectedIndex.Value].State = SelectionState.Selected; + } + + private void selectionChanged(T drawable, SelectionState state) + { + if (state == SelectionState.NotSelected) + Deselect(); + else + Select(drawable); + } + } +} diff --git a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs index 57f39bb8c7..81dca99ddd 100644 --- a/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs +++ b/osu.Game/Graphics/Cursor/OsuTooltipContainer.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.Cursor @@ -32,7 +33,7 @@ namespace osu.Game.Graphics.Cursor public override bool SetContent(object content) { - if (!(content is string contentString)) + if (!(content is LocalisableString contentString)) return false; if (contentString == text.Text) return true; diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 15967c37c2..c0bc8fdb76 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -3,6 +3,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Game.Beatmaps; +using osu.Game.Overlays; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osuTK.Graphics; @@ -32,10 +33,10 @@ namespace osu.Game.Graphics return Pink; case DifficultyRating.Expert: - return useLighterColour ? PurpleLight : Purple; + return PurpleLight; case DifficultyRating.ExpertPlus: - return useLighterColour ? Gray9 : Gray0; + return useLighterColour ? Gray9 : Color4Extensions.FromHex("#121415"); } } @@ -198,8 +199,14 @@ namespace osu.Game.Graphics public readonly Color4 GrayE = Color4Extensions.FromHex(@"eee"); public readonly Color4 GrayF = Color4Extensions.FromHex(@"fff"); - // in latest editor design logic, need to figure out where these sit... + /// + /// Equivalent to 's . + /// public readonly Color4 Lime1 = Color4Extensions.FromHex(@"b2ff66"); + + /// + /// Equivalent to 's . + /// public readonly Color4 Orange1 = Color4Extensions.FromHex(@"ffd966"); // Content Background diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index 1047aa4255..2d75dad828 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -1,25 +1,26 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Bindables; -using osuTK; -using osuTK.Graphics; +using System; +using osu.Framework; +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.Graphics.Sprites; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.Backgrounds; -using osu.Game.Graphics.Sprites; -using osu.Framework.Extensions.Color4Extensions; -using osu.Framework.Graphics.Effects; -using osu.Game.Graphics.Containers; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public class DialogButton : OsuClickableContainer + public class DialogButton : OsuClickableContainer, IStateful { private const float idle_width = 0.8f; private const float hover_width = 0.9f; @@ -27,7 +28,22 @@ namespace osu.Game.Graphics.UserInterface private const float hover_duration = 500; private const float click_duration = 200; - public readonly BindableBool Selected = new BindableBool(); + public event Action StateChanged; + + private SelectionState state; + + public SelectionState State + { + get => state; + set + { + if (state == value) + return; + + state = value; + StateChanged?.Invoke(value); + } + } private readonly Container backgroundContainer; private readonly Container colourContainer; @@ -153,7 +169,7 @@ namespace osu.Game.Graphics.UserInterface updateGlow(); - Selected.ValueChanged += selectionChanged; + StateChanged += selectionChanged; } private Color4 buttonColour; @@ -221,7 +237,7 @@ namespace osu.Game.Graphics.UserInterface .OnComplete(_ => { clickAnimating = false; - Selected.TriggerChange(); + StateChanged?.Invoke(State); }); return base.OnClick(e); @@ -235,7 +251,7 @@ namespace osu.Game.Graphics.UserInterface protected override void OnMouseUp(MouseUpEvent e) { - if (Selected.Value) + if (State == SelectionState.Selected) colourContainer.ResizeWidthTo(hover_width, click_duration, Easing.In); base.OnMouseUp(e); } @@ -243,7 +259,7 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnHover(HoverEvent e) { base.OnHover(e); - Selected.Value = true; + State = SelectionState.Selected; return true; } @@ -251,15 +267,15 @@ namespace osu.Game.Graphics.UserInterface protected override void OnHoverLost(HoverLostEvent e) { base.OnHoverLost(e); - Selected.Value = false; + State = SelectionState.NotSelected; } - private void selectionChanged(ValueChangedEvent args) + private void selectionChanged(SelectionState newState) { if (clickAnimating) return; - if (args.NewValue) + if (newState == SelectionState.Selected) { spriteText.TransformSpacingTo(hoverSpacing, hover_duration, Easing.OutElastic); colourContainer.ResizeWidthTo(hover_width, hover_duration, Easing.OutElastic); diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index 8df2c1c2fd..fea84998cf 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -3,7 +3,6 @@ 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; @@ -23,9 +22,6 @@ namespace osu.Game.Graphics.UserInterface private const int text_size = 17; private const int transition_length = 80; - private Sample sampleClick; - private Sample sampleHover; - private TextContainer text; public DrawableOsuMenuItem(MenuItem item) @@ -36,12 +32,11 @@ namespace osu.Game.Graphics.UserInterface [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"); + AddInternal(new HoverClickSounds()); + updateTextColour(); Item.Action.BindDisabledChanged(_ => updateState(), true); @@ -84,7 +79,6 @@ namespace osu.Game.Graphics.UserInterface if (IsHovered && !Item.Action.Disabled) { - sampleHover.Play(); text.BoldText.FadeIn(transition_length, Easing.OutQuint); text.NormalText.FadeOut(transition_length, Easing.OutQuint); } @@ -95,12 +89,6 @@ namespace osu.Game.Graphics.UserInterface } } - 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(); diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index 5a1eb53fe1..6ad88eaaba 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Platform; using osuTK; using osuTK.Graphics; @@ -58,6 +59,6 @@ namespace osu.Game.Graphics.UserInterface return true; } - public string TooltipText => "view in browser"; + public LocalisableString TooltipText => "view in browser"; } } diff --git a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs index c1963ce62d..12819840e5 100644 --- a/osu.Game/Graphics/UserInterface/HoverClickSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverClickSounds.cs @@ -28,7 +28,7 @@ namespace osu.Game.Graphics.UserInterface /// 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) + public HoverClickSounds(HoverSampleSet sampleSet = HoverSampleSet.Default, MouseButton[] buttons = null) : base(sampleSet) { this.buttons = buttons ?? new[] { MouseButton.Left }; @@ -45,7 +45,8 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load(AudioManager audio) { - sampleClick = audio.Samples.Get($@"UI/generic-select{SampleSet.GetDescription()}"); + sampleClick = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-select") + ?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); } } } diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs new file mode 100644 index 0000000000..b88f81a143 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.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.ComponentModel; + +namespace osu.Game.Graphics.UserInterface +{ + public enum HoverSampleSet + { + [Description("default")] + Default, + + [Description("button")] + Button, + + [Description("toolbar")] + Toolbar, + + [Description("tabselect")] + TabSelect, + + [Description("scrolltotop")] + ScrollToTop + } +} diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs index f2e4c6d013..c0ef5cb3fc 100644 --- a/osu.Game/Graphics/UserInterface/HoverSounds.cs +++ b/osu.Game/Graphics/UserInterface/HoverSounds.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.ComponentModel; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -22,7 +21,7 @@ namespace osu.Game.Graphics.UserInterface protected readonly HoverSampleSet SampleSet; - public HoverSounds(HoverSampleSet sampleSet = HoverSampleSet.Normal) + public HoverSounds(HoverSampleSet sampleSet = HoverSampleSet.Default) { SampleSet = sampleSet; RelativeSizeAxes = Axes.Both; @@ -31,7 +30,8 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load(AudioManager audio, SessionStatics statics) { - sampleHover = audio.Samples.Get($@"UI/generic-hover{SampleSet.GetDescription()}"); + sampleHover = audio.Samples.Get($@"UI/{SampleSet.GetDescription()}-hover") + ?? audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-hover"); } public override void PlayHoverSample() @@ -40,22 +40,4 @@ namespace osu.Game.Graphics.UserInterface sampleHover.Play(); } } - - public enum HoverSampleSet - { - [Description("")] - Loud, - - [Description("-soft")] - Normal, - - [Description("-softer")] - Soft, - - [Description("-toolbar")] - Toolbar, - - [Description("-songselect")] - SongSelect - } } diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index cfcf034d1c..70a107ca04 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -44,6 +44,7 @@ namespace osu.Game.Graphics.UserInterface private readonly Box hover; public OsuAnimatedButton() + : base(HoverSampleSet.Button) { base.Content.Add(content = new Container { diff --git a/osu.Game/Graphics/UserInterface/OsuButton.cs b/osu.Game/Graphics/UserInterface/OsuButton.cs index a22c837080..cd9ca9f87f 100644 --- a/osu.Game/Graphics/UserInterface/OsuButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuButton.cs @@ -49,7 +49,7 @@ namespace osu.Game.Graphics.UserInterface protected Box Background; protected SpriteText SpriteText; - public OsuButton(HoverSampleSet? hoverSounds = HoverSampleSet.Loud) + public OsuButton(HoverSampleSet? hoverSounds = HoverSampleSet.Button) { Height = 40; diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index 15fb00ccb0..b97f12df02 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -4,6 +4,8 @@ using System.Linq; using osuTK.Graphics; 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; @@ -57,6 +59,9 @@ namespace osu.Game.Graphics.UserInterface { public override bool HandleNonPositionalInput => State == MenuState.Open; + private Sample sampleOpen; + private Sample sampleClose; + // todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring public OsuDropdownMenu() { @@ -69,9 +74,30 @@ namespace osu.Game.Graphics.UserInterface ItemsContainer.Padding = new MarginPadding(5); } + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); + sampleClose = audio.Samples.Get(@"UI/dropdown-close"); + } + + // todo: this shouldn't be required after https://github.com/ppy/osu-framework/issues/4519 is fixed. + private bool wasOpened; + // todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring - protected override void AnimateOpen() => this.FadeIn(300, Easing.OutQuint); - protected override void AnimateClose() => this.FadeOut(300, Easing.OutQuint); + protected override void AnimateOpen() + { + wasOpened = true; + this.FadeIn(300, Easing.OutQuint); + sampleOpen?.Play(); + } + + protected override void AnimateClose() + { + this.FadeOut(300, Easing.OutQuint); + if (wasOpened) + sampleClose?.Play(); + } // todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring protected override void UpdateSize(Vector2 newSize) @@ -155,7 +181,7 @@ namespace osu.Game.Graphics.UserInterface nonAccentSelectedColour = Color4.Black.Opacity(0.5f); updateColours(); - AddInternal(new HoverClickSounds(HoverSampleSet.Soft)); + AddInternal(new HoverSounds()); } protected override void UpdateForegroundColour() @@ -262,7 +288,7 @@ namespace osu.Game.Graphics.UserInterface }, }; - AddInternal(new HoverClickSounds()); + AddInternal(new HoverSounds()); } [BackgroundDependencyLoader] diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index ac6f5ceb1b..8e82f4a7c1 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Platform; namespace osu.Game.Graphics.UserInterface @@ -105,7 +106,7 @@ namespace osu.Game.Graphics.UserInterface private class CapsWarning : SpriteIcon, IHasTooltip { - public string TooltipText => @"caps lock is active"; + public LocalisableString TooltipText => "caps lock is active"; public CapsWarning() { diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index f58962f8e1..f85f9327fa 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -14,6 +14,8 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Framework.Utils; namespace osu.Game.Graphics.UserInterface { @@ -34,7 +36,7 @@ namespace osu.Game.Graphics.UserInterface private readonly Box rightBox; private readonly Container nubContainer; - public virtual string TooltipText { get; private set; } + public virtual LocalisableString TooltipText { get; private set; } /// /// Whether to format the tooltip as a percentage or the actual value. @@ -98,7 +100,7 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load(AudioManager audio, OsuColour colours) { - sample = audio.Samples.Get(@"UI/sliderbar-notch"); + sample = audio.Samples.Get(@"UI/notch-tick"); AccentColour = colours.Pink; } @@ -148,7 +150,7 @@ namespace osu.Game.Graphics.UserInterface private void playSample(T value) { - if (Clock == null || Clock.CurrentTime - lastSampleTime <= 50) + if (Clock == null || Clock.CurrentTime - lastSampleTime <= 30) return; if (value.Equals(lastSampleValue)) @@ -157,13 +159,15 @@ namespace osu.Game.Graphics.UserInterface lastSampleValue = value; lastSampleTime = Clock.CurrentTime; - var channel = sample.Play(); + var channel = sample.GetChannel(); - channel.Frequency.Value = 1 + NormalizedValue * 0.2f; - if (NormalizedValue == 0) - channel.Frequency.Value -= 0.4f; - else if (NormalizedValue == 1) - channel.Frequency.Value += 0.4f; + channel.Frequency.Value = 0.99f + RNG.NextDouble(0.02f) + NormalizedValue * 0.2f; + + // intentionally pitched down, even when hitting max. + if (NormalizedValue == 0 || NormalizedValue == 1) + channel.Frequency.Value -= 0.5f; + + channel.Play(); } private void updateTooltipText(T value) diff --git a/osu.Game/Graphics/UserInterface/OsuTabControl.cs b/osu.Game/Graphics/UserInterface/OsuTabControl.cs index dbcce9a84a..3572ea5c31 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControl.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControl.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics.Sprites; @@ -153,6 +154,27 @@ namespace osu.Game.Graphics.UserInterface AutoSizeAxes = Axes.X; RelativeSizeAxes = Axes.Y; + LocalisableString text; + + switch (value) + { + case IHasDescription hasDescription: + text = hasDescription.GetDescription(); + break; + + case Enum e: + text = e.GetLocalisableDescription(); + break; + + case LocalisableString l: + text = l; + break; + + default: + text = value.ToString(); + break; + } + Children = new Drawable[] { Text = new OsuSpriteText @@ -160,7 +182,7 @@ namespace osu.Game.Graphics.UserInterface Margin = new MarginPadding { Top = 5, Bottom = 5 }, Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, - Text = (value as IHasDescription)?.Description ?? (value as Enum)?.GetDescription() ?? value.ToString(), + Text = text, Font = OsuFont.GetFont(size: 14) }, Bar = new Box @@ -172,7 +194,7 @@ namespace osu.Game.Graphics.UserInterface Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, }, - new HoverClickSounds() + new HoverClickSounds(HoverSampleSet.TabSelect) }; } diff --git a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs index b66a4a58ce..c6121dcd17 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControlCheckbox.cs @@ -4,6 +4,8 @@ using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -43,6 +45,8 @@ namespace osu.Game.Graphics.UserInterface } private const float transition_length = 500; + private Sample sampleChecked; + private Sample sampleUnchecked; public OsuTabControlCheckbox() { @@ -77,8 +81,7 @@ namespace osu.Game.Graphics.UserInterface Colour = Color4.White, Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, - }, - new HoverClickSounds() + } }; Current.ValueChanged += selected => @@ -91,10 +94,13 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, AudioManager audio) { if (accentColour == null) AccentColour = colours.Blue; + + sampleChecked = audio.Samples.Get(@"UI/check-on"); + sampleUnchecked = audio.Samples.Get(@"UI/check-off"); } protected override bool OnHover(HoverEvent e) @@ -111,6 +117,16 @@ namespace osu.Game.Graphics.UserInterface base.OnHoverLost(e); } + protected override void OnUserChange(bool value) + { + base.OnUserChange(value); + + if (value) + sampleChecked?.Play(); + else + sampleUnchecked?.Play(); + } + private void updateFade() { box.FadeTo(Current.Value || IsHovered ? 1 : 0, transition_length, Easing.OutQuint); diff --git a/osu.Game/Graphics/UserInterface/PageTabControl.cs b/osu.Game/Graphics/UserInterface/PageTabControl.cs index d05a08108a..a218c7bf52 100644 --- a/osu.Game/Graphics/UserInterface/PageTabControl.cs +++ b/osu.Game/Graphics/UserInterface/PageTabControl.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface @@ -75,13 +76,13 @@ namespace osu.Game.Graphics.UserInterface Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, }, - new HoverClickSounds() + new HoverClickSounds(HoverSampleSet.TabSelect) }; Active.BindValueChanged(active => Text.Font = Text.Font.With(Typeface.Torus, weight: active.NewValue ? FontWeight.Bold : FontWeight.Medium), true); } - protected virtual string CreateText() => (Value as Enum)?.GetDescription() ?? Value.ToString(); + protected virtual LocalisableString CreateText() => (Value as Enum)?.GetLocalisableDescription() ?? Value.ToString(); protected override bool OnHover(HoverEvent e) { diff --git a/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs b/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs index e85525b2f8..d7bd7d7e01 100644 --- a/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs +++ b/osu.Game/Graphics/UserInterface/ScreenBreadcrumbControl.cs @@ -3,6 +3,7 @@ using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Screens; namespace osu.Game.Graphics.UserInterface @@ -19,8 +20,13 @@ namespace osu.Game.Graphics.UserInterface if (stack.CurrentScreen != null) onPushed(null, stack.CurrentScreen); + } - Current.ValueChanged += current => current.NewValue.MakeCurrent(); + protected override void SelectTab(TabItem tab) + { + // override base method to prevent current item from being changed on click. + // depend on screen push/exit to change current item instead. + tab.Value.MakeCurrent(); } private void onPushed(IScreen lastScreen, IScreen newScreen) diff --git a/osu.Game/Graphics/UserInterface/StarCounter.cs b/osu.Game/Graphics/UserInterface/StarCounter.cs index 894a21fcf3..32b788b5dc 100644 --- a/osu.Game/Graphics/UserInterface/StarCounter.cs +++ b/osu.Game/Graphics/UserInterface/StarCounter.cs @@ -113,7 +113,7 @@ namespace osu.Game.Graphics.UserInterface double delay = (current <= newValue ? Math.Max(i - current, 0) : Math.Max(current - 1 - i, 0)) * AnimationDelay; - using (star.BeginDelayedSequence(delay, true)) + using (star.BeginDelayedSequence(delay)) star.DisplayAt(getStarScale(i, newValue)); } } diff --git a/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs deleted file mode 100644 index a1cd074619..0000000000 --- a/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs +++ /dev/null @@ -1,297 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.IO; -using System.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 deleted file mode 100644 index e10b8f7033..0000000000 --- a/osu.Game/Graphics/UserInterfaceV2/FileSelector.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 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/LabelledDrawable.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs index ec68223a3d..5a697623c9 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs @@ -14,6 +14,27 @@ namespace osu.Game.Graphics.UserInterfaceV2 public abstract class LabelledDrawable : CompositeDrawable where T : Drawable { + private float? fixedLabelWidth; + + /// + /// The fixed width of the label of this . + /// If null, the label portion will auto-size to its content. + /// Can be used in layout scenarios where several labels must match in length for the components to be aligned properly. + /// + public float? FixedLabelWidth + { + get => fixedLabelWidth; + set + { + if (fixedLabelWidth == value) + return; + + fixedLabelWidth = value; + + updateLabelWidth(); + } + } + protected const float CONTENT_PADDING_VERTICAL = 10; protected const float CONTENT_PADDING_HORIZONTAL = 15; protected const float CORNER_RADIUS = 15; @@ -23,6 +44,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 /// protected readonly T Component; + private readonly GridContainer grid; private readonly OsuTextFlowContainer labelText; private readonly OsuTextFlowContainer descriptionText; @@ -56,7 +78,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 Spacing = new Vector2(0, 12), Children = new Drawable[] { - new GridContainer + grid = new GridContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -69,7 +91,13 @@ namespace osu.Game.Graphics.UserInterfaceV2 Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, AutoSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = 20 } + Padding = new MarginPadding + { + Right = 20, + // ensure that the label is always vertically padded even if the component itself isn't. + // this may become an issue if the label is taller than the component. + Vertical = padded ? 0 : CONTENT_PADDING_VERTICAL + } }, new Container { @@ -87,7 +115,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 }, }, 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)) { @@ -99,6 +126,24 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } }; + + updateLabelWidth(); + } + + private void updateLabelWidth() + { + if (fixedLabelWidth == null) + { + grid.ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }; + labelText.RelativeSizeAxes = Axes.None; + labelText.AutoSizeAxes = Axes.Both; + } + else + { + grid.ColumnDimensions = new[] { new Dimension(GridSizeMode.Absolute, fixedLabelWidth.Value) }; + labelText.AutoSizeAxes = Axes.Y; + labelText.RelativeSizeAxes = Axes.X; + } } [BackgroundDependencyLoader] diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs index 266eb11319..4da8d6a554 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs @@ -21,6 +21,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 public bool ReadOnly { + get => Component.ReadOnly; set => Component.ReadOnly = value; } @@ -45,14 +46,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 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 virtual OsuTextBox CreateTextBox() => new OsuTextBox(); public override bool AcceptsFocus => true; @@ -64,6 +58,12 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override OsuTextBox CreateComponent() => CreateTextBox().With(t => { + t.CommitOnFocusLost = true; + t.Anchor = Anchor.Centre; + t.Origin = Anchor.Centre; + t.RelativeSizeAxes = Axes.X; + t.CornerRadius = CORNER_RADIUS; + t.OnCommit += (sender, newText) => OnCommit?.Invoke(sender, newText); }); } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuColourPicker.cs b/osu.Game/Graphics/UserInterfaceV2/OsuColourPicker.cs new file mode 100644 index 0000000000..5394e5d0aa --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuColourPicker.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.Graphics.UserInterface; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class OsuColourPicker : ColourPicker + { + public OsuColourPicker() + { + CornerRadius = 10; + Masking = true; + } + + protected override HSVColourPicker CreateHSVColourPicker() => new OsuHSVColourPicker(); + protected override HexColourPicker CreateHexColourPicker() => new OsuHexColourPicker(); + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs new file mode 100644 index 0000000000..1ce4d97fdf --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelector.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class OsuDirectorySelector : DirectorySelector + { + public const float ITEM_HEIGHT = 20; + + public OsuDirectorySelector(string initialPath = null) + : base(initialPath) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Padding = new MarginPadding(10); + } + + protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + + protected override DirectorySelectorBreadcrumbDisplay CreateBreadcrumb() => new OsuDirectorySelectorBreadcrumbDisplay(); + + protected override DirectorySelectorDirectory CreateParentDirectoryItem(DirectoryInfo directory) => new OsuDirectorySelectorParentDirectory(directory); + + protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); + + protected override void NotifySelectionError() => this.FlashColour(Colour4.Red, 300); + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorBreadcrumbDisplay.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorBreadcrumbDisplay.cs new file mode 100644 index 0000000000..cb5ff242a1 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorBreadcrumbDisplay.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.IO; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.Sprites; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + internal class OsuDirectorySelectorBreadcrumbDisplay : DirectorySelectorBreadcrumbDisplay + { + protected override Drawable CreateCaption() => new OsuSpriteText + { + Text = "Current Directory: ", + Font = OsuFont.Default.With(size: OsuDirectorySelector.ITEM_HEIGHT), + }; + + protected override DirectorySelectorDirectory CreateRootDirectoryItem() => new OsuBreadcrumbDisplayComputer(); + + protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuBreadcrumbDisplayDirectory(directory, displayName); + + [BackgroundDependencyLoader] + private void load() + { + Height = 50; + } + + private class OsuBreadcrumbDisplayComputer : OsuBreadcrumbDisplayDirectory + { + protected override IconUsage? Icon => null; + + public OsuBreadcrumbDisplayComputer() + : base(null, "Computer") + { + } + } + + private class OsuBreadcrumbDisplayDirectory : OsuDirectorySelectorDirectory + { + public OsuBreadcrumbDisplayDirectory(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; + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs new file mode 100644 index 0000000000..8a420cdcfb --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.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.IO; +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.Graphics.UserInterface; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + internal class OsuDirectorySelectorDirectory : DirectorySelectorDirectory + { + public OsuDirectorySelectorDirectory(DirectoryInfo directory, string displayName = null) + : base(directory, displayName) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Flow.AutoSizeAxes = Axes.X; + Flow.Height = OsuDirectorySelector.ITEM_HEIGHT; + + AddInternal(new Background + { + Depth = 1 + }); + } + + protected override SpriteText CreateSpriteText() => new OsuSpriteText(); + + protected override IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar) + ? FontAwesome.Solid.Database + : FontAwesome.Regular.Folder; + + internal class Background : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + RelativeSizeAxes = Axes.Both; + + Masking = true; + CornerRadius = 5; + + InternalChild = new Box + { + Colour = colours.GreySeafoamDarker, + RelativeSizeAxes = Axes.Both, + }; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorParentDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorParentDirectory.cs new file mode 100644 index 0000000000..481d811adb --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorParentDirectory.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.IO; +using osu.Framework.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + internal class OsuDirectorySelectorParentDirectory : OsuDirectorySelectorDirectory + { + protected override IconUsage? Icon => FontAwesome.Solid.Folder; + + public OsuDirectorySelectorParentDirectory(DirectoryInfo directory) + : base(directory, "..") + { + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs new file mode 100644 index 0000000000..b9fb642cbe --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.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.IO; +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.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class OsuFileSelector : FileSelector + { + public OsuFileSelector(string initialPath = null, string[] validFileExtensions = null) + : base(initialPath, validFileExtensions) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Padding = new MarginPadding(10); + } + + protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer(); + + protected override DirectorySelectorBreadcrumbDisplay CreateBreadcrumb() => new OsuDirectorySelectorBreadcrumbDisplay(); + + protected override DirectorySelectorDirectory CreateParentDirectoryItem(DirectoryInfo directory) => new OsuDirectorySelectorParentDirectory(directory); + + protected override DirectorySelectorDirectory CreateDirectoryItem(DirectoryInfo directory, string displayName = null) => new OsuDirectorySelectorDirectory(directory, displayName); + + protected override DirectoryListingFile CreateFileItem(FileInfo file) => new OsuDirectoryListingFile(file); + + protected override void NotifySelectionError() => this.FlashColour(Colour4.Red, 300); + + protected class OsuDirectoryListingFile : DirectoryListingFile + { + public OsuDirectoryListingFile(FileInfo file) + : base(file) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Flow.AutoSizeAxes = Axes.X; + Flow.Height = OsuDirectorySelector.ITEM_HEIGHT; + + AddInternal(new OsuDirectorySelectorDirectory.Background + { + Depth = 1 + }); + } + + 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; + } + } + } + + protected override SpriteText CreateSpriteText() => new OsuSpriteText(); + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs b/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.cs new file mode 100644 index 0000000000..06056f239b --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuHSVColourPicker.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 JetBrains.Annotations; +using osu.Framework.Allocation; +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.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class OsuHSVColourPicker : HSVColourPicker + { + private const float spacing = 10; + private const float corner_radius = 10; + private const float control_border_thickness = 3; + + protected override HueSelector CreateHueSelector() => new OsuHueSelector(); + protected override SaturationValueSelector CreateSaturationValueSelector() => new OsuSaturationValueSelector(); + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] OverlayColourProvider colourProvider, OsuColour osuColour) + { + Background.Colour = colourProvider?.Dark5 ?? osuColour.GreySeafoamDark; + + Content.Padding = new MarginPadding(spacing); + Content.Spacing = new Vector2(0, spacing); + } + + private static EdgeEffectParameters createShadowParameters() => new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0, 1), + Radius = 3, + Colour = Colour4.Black.Opacity(0.3f) + }; + + private class OsuHueSelector : HueSelector + { + public OsuHueSelector() + { + SliderBar.CornerRadius = corner_radius; + SliderBar.Masking = true; + } + + protected override Drawable CreateSliderNub() => new SliderNub(this); + + private class SliderNub : CompositeDrawable + { + private readonly Bindable hue; + private readonly Box fill; + + public SliderNub(OsuHueSelector osuHueSelector) + { + hue = osuHueSelector.Hue.GetBoundCopy(); + + InternalChild = new CircularContainer + { + Height = 35, + Width = 10, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Masking = true, + BorderColour = Colour4.White, + BorderThickness = control_border_thickness, + EdgeEffect = createShadowParameters(), + Child = fill = new Box + { + RelativeSizeAxes = Axes.Both + } + }; + } + + protected override void LoadComplete() + { + hue.BindValueChanged(h => fill.Colour = Colour4.FromHSV(h.NewValue, 1, 1), true); + } + } + } + + private class OsuSaturationValueSelector : SaturationValueSelector + { + public OsuSaturationValueSelector() + { + SelectionArea.CornerRadius = corner_radius; + SelectionArea.Masking = true; + // purposefully use hard non-AA'd masking to avoid edge artifacts. + SelectionArea.MaskingSmoothness = 0; + } + + protected override Marker CreateMarker() => new OsuMarker(); + + private class OsuMarker : Marker + { + private readonly Box previewBox; + + public OsuMarker() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new CircularContainer + { + Size = new Vector2(20), + Masking = true, + BorderColour = Colour4.White, + BorderThickness = control_border_thickness, + EdgeEffect = createShadowParameters(), + Child = previewBox = new Box + { + RelativeSizeAxes = Axes.Both + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(colour => previewBox.Colour = colour.NewValue, true); + } + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.cs b/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.cs new file mode 100644 index 0000000000..331a1b67c9 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuHexColourPicker.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 JetBrains.Annotations; +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.UserInterface; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class OsuHexColourPicker : HexColourPicker + { + public OsuHexColourPicker() + { + Padding = new MarginPadding(20); + Spacing = 20; + } + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] OverlayColourProvider overlayColourProvider, OsuColour osuColour) + { + Background.Colour = overlayColourProvider?.Dark6 ?? osuColour.GreySeafoamDarker; + } + + protected override TextBox CreateHexCodeTextBox() => new OsuTextBox(); + protected override ColourPreview CreateColourPreview() => new OsuColourPreview(); + + private class OsuColourPreview : ColourPreview + { + private readonly Box preview; + + public OsuColourPreview() + { + InternalChild = new CircularContainer + { + RelativeSizeAxes = Axes.Both, + Masking = true, + Child = preview = new Box + { + RelativeSizeAxes = Axes.Both + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(colour => preview.Colour = colour.NewValue, true); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs new file mode 100644 index 0000000000..c07a5de1e4 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class OsuPopover : Popover + { + private const float fade_duration = 250; + private const double scale_duration = 500; + + public OsuPopover(bool withPadding = true) + { + Content.Padding = withPadding ? new MarginPadding(20) : new MarginPadding(); + + Body.Masking = true; + Body.CornerRadius = 10; + Body.Margin = new MarginPadding(10); + Body.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0, 2), + Radius = 5, + Colour = Colour4.Black.Opacity(0.3f) + }; + } + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] OverlayColourProvider colourProvider, OsuColour colours) + { + Background.Colour = Arrow.Colour = colourProvider?.Background4 ?? colours.GreySeafoamDarker; + } + + protected override Drawable CreateArrow() => Empty(); + + protected override void PopIn() + { + this.ScaleTo(1, scale_duration, Easing.OutElasticHalf); + this.FadeIn(fade_duration, Easing.OutQuint); + } + + protected override void PopOut() + { + this.ScaleTo(0.7f, scale_duration, Easing.OutQuint); + this.FadeOut(fade_duration, Easing.OutQuint); + } + } +} diff --git a/osu.Game/IO/Legacy/SerializationWriter.cs b/osu.Game/IO/Legacy/SerializationWriter.cs index bb8014fe54..9ebeaf616e 100644 --- a/osu.Game/IO/Legacy/SerializationWriter.cs +++ b/osu.Game/IO/Legacy/SerializationWriter.cs @@ -18,8 +18,8 @@ namespace osu.Game.IO.Legacy /// handle null strings and simplify use with ISerializable. public class SerializationWriter : BinaryWriter { - public SerializationWriter(Stream s) - : base(s, Encoding.UTF8) + public SerializationWriter(Stream s, bool leaveOpen = false) + : base(s, Encoding.UTF8, leaveOpen) { } diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index 7df5d820ee..802c71e363 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -33,12 +33,18 @@ namespace osu.Game.IO private readonly StorageConfigManager storageConfig; private readonly Storage defaultStorage; - public override string[] IgnoreDirectories => new[] { "cache" }; + public override string[] IgnoreDirectories => new[] + { + "cache", + "client.realm.management" + }; public override string[] IgnoreFiles => new[] { "framework.ini", - "storage.ini" + "storage.ini", + "client.realm.note", + "client.realm.lock", }; public OsuStorage(GameHost host, Storage defaultStorage) @@ -96,8 +102,15 @@ namespace osu.Game.IO protected override void ChangeTargetStorage(Storage newStorage) { + var lastStorage = UnderlyingStorage; base.ChangeTargetStorage(newStorage); - Logger.Storage = UnderlyingStorage.GetStorageForDirectory("logs"); + + if (lastStorage != null) + { + // for now we assume that if there was a previous storage, this is a migration operation. + // the logger shouldn't be set during initialisation as it can cause cross-talk in tests (due to being static). + Logger.Storage = UnderlyingStorage.GetStorageForDirectory("logs"); + } } public override void Migrate(Storage newStorage) diff --git a/osu.Game/Input/Bindings/DatabasedKeyBinding.cs b/osu.Game/Input/Bindings/DatabasedKeyBinding.cs index 8c0072c3da..ad3493d0fc 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBinding.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBinding.cs @@ -8,7 +8,7 @@ using osu.Game.Database; namespace osu.Game.Input.Bindings { [Table("KeyBinding")] - public class DatabasedKeyBinding : KeyBinding, IHasPrimaryKey + public class DatabasedKeyBinding : IKeyBinding, IHasPrimaryKey { public int ID { get; set; } @@ -17,17 +17,23 @@ namespace osu.Game.Input.Bindings public int? Variant { get; set; } [Column("Keys")] - public string KeysString - { - get => KeyCombination.ToString(); - private set => KeyCombination = value; - } + public string KeysString { get; set; } [Column("Action")] - public int IntAction + public int IntAction { get; set; } + + [NotMapped] + public KeyCombination KeyCombination { - get => (int)Action; - set => Action = value; + get => KeysString; + set => KeysString = value.ToString(); + } + + [NotMapped] + public object Action + { + get => IntAction; + set => IntAction = (int)value; } } } diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index 23b09e8fb1..10376c1866 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Input.Bindings; +using osu.Game.Database; using osu.Game.Rulesets; -using System.Linq; +using Realms; namespace osu.Game.Input.Bindings { @@ -21,7 +23,11 @@ namespace osu.Game.Input.Bindings private readonly int? variant; - private KeyBindingStore store; + private IDisposable realmSubscription; + private IQueryable realmKeyBindings; + + [Resolved] + private RealmContextFactory realmFactory { get; set; } public override IEnumerable DefaultKeyBindings => ruleset.CreateInstance().GetDefaultKeyBindings(variant ?? 0); @@ -42,24 +48,34 @@ namespace osu.Game.Input.Bindings throw new InvalidOperationException($"{nameof(variant)} can not be null when a non-null {nameof(ruleset)} is provided."); } - [BackgroundDependencyLoader] - private void load(KeyBindingStore keyBindings) - { - store = keyBindings; - } - protected override void LoadComplete() { + if (ruleset == null || ruleset.ID.HasValue) + { + var rulesetId = ruleset?.ID; + + realmKeyBindings = realmFactory.Context.All() + .Where(b => b.RulesetID == rulesetId && b.Variant == variant); + + realmSubscription = realmKeyBindings + .SubscribeForNotifications((sender, changes, error) => + { + // first subscription ignored as we are handling this in LoadComplete. + if (changes == null) + return; + + ReloadMappings(); + }); + } + base.LoadComplete(); - store.KeyBindingChanged += ReloadMappings; } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (store != null) - store.KeyBindingChanged -= ReloadMappings; + realmSubscription?.Dispose(); } protected override void ReloadMappings() @@ -67,17 +83,17 @@ namespace osu.Game.Input.Bindings var defaults = DefaultKeyBindings.ToList(); if (ruleset != null && !ruleset.ID.HasValue) - // if the provided ruleset is not stored to the database, we have no way to retrieve custom bindings. - // fallback to defaults instead. + // some tests instantiate a ruleset which is not present in the database. + // in these cases we still want key bindings to work, but matching to database instances would result in none being present, + // so let's populate the defaults directly. KeyBindings = defaults; else { - KeyBindings = store.Query(ruleset?.ID, variant) - .OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.IntAction)) - // this ordering is important to ensure that we read entries from the database in the order - // enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise - // have been eaten by the music controller due to query order. - .ToList(); + KeyBindings = realmKeyBindings.Detach() + // this ordering is important to ensure that we read entries from the database in the order + // enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise + // have been eaten by the music controller due to query order. + .OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.ActionInt)).ToList(); } } } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index c8227c0887..d3cc90ef99 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -87,6 +87,8 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Shift, InputKey.Tab }, GlobalAction.ToggleInGameInterface), new KeyBinding(InputKey.MouseMiddle, GlobalAction.PauseGameplay), new KeyBinding(InputKey.Space, GlobalAction.TogglePauseReplay), + new KeyBinding(InputKey.Left, GlobalAction.SeekReplayBackward), + new KeyBinding(InputKey.Right, GlobalAction.SeekReplayForward), new KeyBinding(InputKey.Control, GlobalAction.HoldForHUD), }; @@ -103,6 +105,9 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Alt, InputKey.Up }, GlobalAction.IncreaseVolume), new KeyBinding(new[] { InputKey.Alt, InputKey.Down }, GlobalAction.DecreaseVolume), + new KeyBinding(new[] { InputKey.Alt, InputKey.Left }, GlobalAction.PreviousVolumeMeter), + new KeyBinding(new[] { InputKey.Alt, InputKey.Right }, GlobalAction.NextVolumeMeter), + new KeyBinding(new[] { InputKey.Control, InputKey.F4 }, GlobalAction.ToggleMute), new KeyBinding(InputKey.TrackPrevious, GlobalAction.MusicPrev), @@ -263,5 +268,17 @@ namespace osu.Game.Input.Bindings [Description("Toggle skin editor")] ToggleSkinEditor, + + [Description("Previous volume meter")] + PreviousVolumeMeter, + + [Description("Next volume meter")] + NextVolumeMeter, + + [Description("Seek replay forward")] + SeekReplayForward, + + [Description("Seek replay backward")] + SeekReplayBackward, } } diff --git a/osu.Game/Input/Bindings/RealmKeyBinding.cs b/osu.Game/Input/Bindings/RealmKeyBinding.cs new file mode 100644 index 0000000000..334d2da427 --- /dev/null +++ b/osu.Game/Input/Bindings/RealmKeyBinding.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; +using osu.Framework.Input.Bindings; +using osu.Game.Database; +using Realms; + +namespace osu.Game.Input.Bindings +{ + [MapTo(nameof(KeyBinding))] + public class RealmKeyBinding : RealmObject, IHasGuidPrimaryKey, IKeyBinding + { + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + public int? RulesetID { get; set; } + + public int? Variant { get; set; } + + public KeyCombination KeyCombination + { + get => KeyCombinationString; + set => KeyCombinationString = value.ToString(); + } + + public object Action + { + get => ActionInt; + set => ActionInt = (int)value; + } + + [MapTo(nameof(Action))] + public int ActionInt { get; set; } + + [MapTo(nameof(KeyCombination))] + public string KeyCombinationString { get; set; } + } +} diff --git a/osu.Game/Input/KeyBindingStore.cs b/osu.Game/Input/KeyBindingStore.cs deleted file mode 100644 index 3ef9923487..0000000000 --- a/osu.Game/Input/KeyBindingStore.cs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using osu.Framework.Input.Bindings; -using osu.Framework.Platform; -using osu.Game.Database; -using osu.Game.Input.Bindings; -using osu.Game.Rulesets; - -namespace osu.Game.Input -{ - public class KeyBindingStore : DatabaseBackedStore - { - public event Action KeyBindingChanged; - - /// - /// Keys which should not be allowed for gameplay input purposes. - /// - private static readonly IEnumerable banned_keys = new[] - { - InputKey.MouseWheelDown, - InputKey.MouseWheelLeft, - InputKey.MouseWheelUp, - InputKey.MouseWheelRight - }; - - public KeyBindingStore(DatabaseContextFactory contextFactory, RulesetStore rulesets, Storage storage = null) - : base(contextFactory, storage) - { - using (ContextFactory.GetForWrite()) - { - foreach (var info in rulesets.AvailableRulesets) - { - var ruleset = info.CreateInstance(); - foreach (var variant in ruleset.AvailableVariants) - insertDefaults(ruleset.GetDefaultKeyBindings(variant), info.ID, variant); - } - } - } - - public void Register(KeyBindingContainer manager) => insertDefaults(manager.DefaultKeyBindings); - - /// - /// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action. - /// - /// The action to lookup. - /// A set of display strings for all the user's key configuration for the action. - public IEnumerable GetReadableKeyCombinationsFor(GlobalAction globalAction) - { - foreach (var action in Query().Where(b => (GlobalAction)b.Action == globalAction)) - { - string str = action.KeyCombination.ReadableString(); - - // even if found, the readable string may be empty for an unbound action. - if (str.Length > 0) - yield return str; - } - } - - private void insertDefaults(IEnumerable defaults, int? rulesetId = null, int? variant = null) - { - using (var usage = ContextFactory.GetForWrite()) - { - // compare counts in database vs defaults - foreach (var group in defaults.GroupBy(k => k.Action)) - { - int count = Query(rulesetId, variant).Count(k => (int)k.Action == (int)group.Key); - int aimCount = group.Count(); - - if (aimCount <= count) - continue; - - foreach (var insertable in group.Skip(count).Take(aimCount - count)) - { - // insert any defaults which are missing. - usage.Context.DatabasedKeyBinding.Add(new DatabasedKeyBinding - { - KeyCombination = insertable.KeyCombination, - Action = insertable.Action, - RulesetID = rulesetId, - Variant = variant - }); - - // required to ensure stable insert order (https://github.com/dotnet/efcore/issues/11686) - usage.Context.SaveChanges(); - } - } - } - } - - /// - /// Retrieve s for a specified ruleset/variant content. - /// - /// The ruleset's internal ID. - /// An optional variant. - public List Query(int? rulesetId = null, int? variant = null) => - ContextFactory.Get().DatabasedKeyBinding.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList(); - - public void Update(KeyBinding keyBinding) - { - using (ContextFactory.GetForWrite()) - { - var dbKeyBinding = (DatabasedKeyBinding)keyBinding; - - Debug.Assert(dbKeyBinding.RulesetID == null || CheckValidForGameplay(keyBinding.KeyCombination)); - - Refresh(ref dbKeyBinding); - - if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination)) - return; - - dbKeyBinding.KeyCombination = keyBinding.KeyCombination; - } - - KeyBindingChanged?.Invoke(); - } - - public static bool CheckValidForGameplay(KeyCombination combination) - { - foreach (var key in banned_keys) - { - if (combination.Keys.Contains(key)) - return false; - } - - return true; - } - } -} diff --git a/osu.Game/Input/RealmKeyBindingStore.cs b/osu.Game/Input/RealmKeyBindingStore.cs new file mode 100644 index 0000000000..9089169877 --- /dev/null +++ b/osu.Game/Input/RealmKeyBindingStore.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.Collections.Generic; +using System.Linq; +using osu.Framework.Input.Bindings; +using osu.Game.Database; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets; + +#nullable enable + +namespace osu.Game.Input +{ + public class RealmKeyBindingStore + { + private readonly RealmContextFactory realmFactory; + + public RealmKeyBindingStore(RealmContextFactory realmFactory) + { + this.realmFactory = realmFactory; + } + + /// + /// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action. + /// + /// The action to lookup. + /// A set of display strings for all the user's key configuration for the action. + public IReadOnlyList GetReadableKeyCombinationsFor(GlobalAction globalAction) + { + List combinations = new List(); + + using (var context = realmFactory.GetForRead()) + { + foreach (var action in context.Realm.All().Where(b => b.RulesetID == null && (GlobalAction)b.ActionInt == globalAction)) + { + string str = action.KeyCombination.ReadableString(); + + // even if found, the readable string may be empty for an unbound action. + if (str.Length > 0) + combinations.Add(str); + } + } + + return combinations; + } + + /// + /// Register a new type of , adding default bindings from . + /// + /// The container to populate defaults from. + public void Register(KeyBindingContainer container) => insertDefaults(container.DefaultKeyBindings); + + /// + /// Register a ruleset, adding default bindings for each of its variants. + /// + /// The ruleset to populate defaults from. + public void Register(RulesetInfo ruleset) + { + var instance = ruleset.CreateInstance(); + + foreach (var variant in instance.AvailableVariants) + insertDefaults(instance.GetDefaultKeyBindings(variant), ruleset.ID, variant); + } + + private void insertDefaults(IEnumerable defaults, int? rulesetId = null, int? variant = null) + { + using (var usage = realmFactory.GetForWrite()) + { + // compare counts in database vs defaults + foreach (var defaultsForAction in defaults.GroupBy(k => k.Action)) + { + int existingCount = usage.Realm.All().Count(k => k.RulesetID == rulesetId && k.Variant == variant && k.ActionInt == (int)defaultsForAction.Key); + + if (defaultsForAction.Count() <= existingCount) + continue; + + foreach (var k in defaultsForAction.Skip(existingCount)) + { + // insert any defaults which are missing. + usage.Realm.Add(new RealmKeyBinding + { + KeyCombinationString = k.KeyCombination.ToString(), + ActionInt = (int)k.Action, + RulesetID = rulesetId, + Variant = variant + }); + } + } + + usage.Commit(); + } + } + + /// + /// Keys which should not be allowed for gameplay input purposes. + /// + private static readonly IEnumerable banned_keys = new[] + { + InputKey.MouseWheelDown, + InputKey.MouseWheelLeft, + InputKey.MouseWheelUp, + InputKey.MouseWheelRight + }; + + public static bool CheckValidForGameplay(KeyCombination combination) + { + foreach (var key in banned_keys) + { + if (combination.Keys.Contains(key)) + return false; + } + + return true; + } + } +} diff --git a/osu.Game/Localisation/BindingSettingsStrings.cs b/osu.Game/Localisation/BindingSettingsStrings.cs new file mode 100644 index 0000000000..ad4a650a1f --- /dev/null +++ b/osu.Game/Localisation/BindingSettingsStrings.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.Localisation; + +namespace osu.Game.Localisation +{ + public static class BindingSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.BindingSettings"; + + /// + /// "Shortcut and gameplay bindings" + /// + public static LocalisableString ShortcutAndGameplayBindings => new TranslatableString(getKey(@"shortcut_and_gameplay_bindings"), @"Shortcut and gameplay bindings"); + + /// + /// "Configure" + /// + public static LocalisableString Configure => new TranslatableString(getKey(@"configure"), @"Configure"); + + /// + /// "change global shortcut keys and gameplay bindings" + /// + public static LocalisableString ChangeBindingsButton => new TranslatableString(getKey(@"change_bindings_button"), @"change global shortcut keys and gameplay bindings"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/ButtonSystem.ja.resx b/osu.Game/Localisation/ButtonSystem.ja.resx deleted file mode 100644 index 02f3e7ce2f..0000000000 --- a/osu.Game/Localisation/ButtonSystem.ja.resx +++ /dev/null @@ -1,38 +0,0 @@ - - - text/microsoft-resx - - - 1.3 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - ソロ - - - プレイリスト - - - 遊ぶ - - - マルチ - - - エディット - - - ブラウズ - - - 閉じる - - - 設定 - - diff --git a/osu.Game/Localisation/ButtonSystem.resx b/osu.Game/Localisation/ButtonSystem.resx deleted file mode 100644 index d72ffff8be..0000000000 --- a/osu.Game/Localisation/ButtonSystem.resx +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - solo - - - multi - - - playlists - - - play - - - edit - - - browse - - - settings - - - back - - - exit - - \ No newline at end of file diff --git a/osu.Game/Localisation/ButtonSystemStrings.cs b/osu.Game/Localisation/ButtonSystemStrings.cs index 8083f80782..ba4abf63a6 100644 --- a/osu.Game/Localisation/ButtonSystemStrings.cs +++ b/osu.Game/Localisation/ButtonSystemStrings.cs @@ -7,7 +7,7 @@ namespace osu.Game.Localisation { public static class ButtonSystemStrings { - private const string prefix = @"osu.Game.Localisation.ButtonSystem"; + private const string prefix = @"osu.Game.Resources.Localisation.ButtonSystem"; /// /// "solo" @@ -56,4 +56,4 @@ namespace osu.Game.Localisation private static string getKey(string key) => $@"{prefix}:{key}"; } -} \ No newline at end of file +} diff --git a/osu.Game/Localisation/Chat.resx b/osu.Game/Localisation/Chat.resx deleted file mode 100644 index 055e351463..0000000000 --- a/osu.Game/Localisation/Chat.resx +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - chat - - - join the real-time discussion - - \ No newline at end of file diff --git a/osu.Game/Localisation/ChatStrings.cs b/osu.Game/Localisation/ChatStrings.cs index daddb602ad..7bd284a94e 100644 --- a/osu.Game/Localisation/ChatStrings.cs +++ b/osu.Game/Localisation/ChatStrings.cs @@ -7,17 +7,17 @@ namespace osu.Game.Localisation { public static class ChatStrings { - private const string prefix = "osu.Game.Localisation.Chat"; + private const string prefix = @"osu.Game.Resources.Localisation.Chat"; /// /// "chat" /// - public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "chat"); + public static LocalisableString HeaderTitle => new TranslatableString(getKey(@"header_title"), @"chat"); /// /// "join the real-time discussion" /// - public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "join the real-time discussion"); + public static LocalisableString HeaderDescription => new TranslatableString(getKey(@"header_description"), @"join the real-time discussion"); private static string getKey(string key) => $"{prefix}:{key}"; } diff --git a/osu.Game/Localisation/Common.resx b/osu.Game/Localisation/Common.resx deleted file mode 100644 index 59de16a037..0000000000 --- a/osu.Game/Localisation/Common.resx +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Cancel - - \ No newline at end of file diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index f448158191..bf488d2590 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -7,13 +7,28 @@ namespace osu.Game.Localisation { public static class CommonStrings { - private const string prefix = "osu.Game.Localisation.Common"; + private const string prefix = @"osu.Game.Resources.Localisation.Common"; /// /// "Cancel" /// - public static LocalisableString Cancel => new TranslatableString(getKey("cancel"), "Cancel"); + public static LocalisableString Cancel => new TranslatableString(getKey(@"cancel"), @"Cancel"); - private static string getKey(string key) => $"{prefix}:{key}"; + /// + /// "Enabled" + /// + public static LocalisableString Enabled => new TranslatableString(getKey(@"enabled"), @"Enabled"); + + /// + /// "Width" + /// + public static LocalisableString Width => new TranslatableString(getKey(@"width"), @"Width"); + + /// + /// "Height" + /// + public static LocalisableString Height => new TranslatableString(getKey(@"height"), @"Height"); + + private static string getKey(string key) => $@"{prefix}:{key}"; } -} +} \ No newline at end of file diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs index edcf264c7f..dc1fac47a8 100644 --- a/osu.Game/Localisation/Language.cs +++ b/osu.Game/Localisation/Language.cs @@ -7,10 +7,109 @@ namespace osu.Game.Localisation { public enum Language { - [Description("English")] + [Description(@"English")] en, - [Description("日本語")] - ja + // TODO: Requires Arabic glyphs to be added to resources (and possibly also RTL support). + // [Description(@"اَلْعَرَبِيَّةُ")] + // ar, + + [Description(@"Беларуская мова")] + be, + + [Description(@"Български")] + bg, + + [Description(@"Česky")] + cs, + + [Description(@"Dansk")] + da, + + [Description(@"Deutsch")] + de, + + [Description(@"Ελληνικά")] + el, + + [Description(@"español")] + es, + + [Description(@"Suomi")] + fi, + + [Description(@"français")] + fr, + + [Description(@"Magyar")] + hu, + + [Description(@"Bahasa Indonesia")] + id, + + [Description(@"Italiano")] + it, + + [Description(@"日本語")] + ja, + + [Description(@"한국어")] + ko, + + [Description(@"Nederlands")] + nl, + + [Description(@"Norsk")] + no, + + [Description(@"polski")] + pl, + + [Description(@"Português")] + pt, + + [Description(@"Português (Brasil)")] + pt_br, + + [Description(@"Română")] + ro, + + [Description(@"Русский")] + ru, + + [Description(@"Slovenčina")] + sk, + + [Description(@"Svenska")] + sv, + + [Description(@"ไทย")] + th, + + // Tagalog has no associated localisations yet, and is not supported on Xamarin platforms or Windows versions <10. + // Can be revisited if localisations ever arrive. + //[Description(@"Tagalog")] + //tl, + + [Description(@"Türkçe")] + tr, + + [Description(@"Українська мова")] + uk, + + [Description(@"Tiếng Việt")] + vi, + + [Description(@"简体中文")] + zh, + + // Traditional Chinese (Hong Kong) is listed in web sources but has no associated localisations, + // and was wrongly falling back to Simplified Chinese. + // Can be revisited if localisations ever arrive. + // [Description(@"繁體中文(香港)")] + // zh_hk, + + [Description(@"繁體中文(台灣)")] + zh_tw } } diff --git a/osu.Game/Localisation/MouseSettingsStrings.cs b/osu.Game/Localisation/MouseSettingsStrings.cs new file mode 100644 index 0000000000..9b1f7fe4c5 --- /dev/null +++ b/osu.Game/Localisation/MouseSettingsStrings.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class MouseSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.MouseSettings"; + + /// + /// "Mouse" + /// + public static LocalisableString Mouse => new TranslatableString(getKey(@"mouse"), @"Mouse"); + + /// + /// "Not applicable in full screen mode" + /// + public static LocalisableString NotApplicableFullscreen => new TranslatableString(getKey(@"not_applicable_full_screen"), @"Not applicable in full screen mode"); + + /// + /// "High precision mouse" + /// + public static LocalisableString HighPrecisionMouse => new TranslatableString(getKey(@"high_precision_mouse"), @"High precision mouse"); + + /// + /// "Attempts to bypass any operation system mouse acceleration. On windows, this is equivalent to what used to be known as "Raw Input"." + /// + public static LocalisableString HighPrecisionMouseTooltip => new TranslatableString(getKey(@"high_precision_mouse_tooltip"), @"Attempts to bypass any operation system mouse acceleration. On windows, this is equivalent to what used to be known as ""Raw Input""."); + + /// + /// "Confine mouse cursor to window" + /// + public static LocalisableString ConfineMouseMode => new TranslatableString(getKey(@"confine_mouse_mode"), @"Confine mouse cursor to window"); + + /// + /// "Disable mouse wheel during gameplay" + /// + public static LocalisableString DisableMouseWheel => new TranslatableString(getKey(@"disable_mouse_wheel"), @"Disable mouse wheel during gameplay"); + + /// + /// "Disable mouse buttons during gameplay" + /// + public static LocalisableString DisableMouseButtons => new TranslatableString(getKey(@"disable_mouse_buttons"), @"Disable mouse buttons during gameplay"); + + /// + /// "Enable high precision mouse to adjust sensitivity" + /// + public static LocalisableString EnableHighPrecisionForSensitivityAdjust => new TranslatableString(getKey(@"enable_high_precision_for_sensitivity_adjust"), @"Enable high precision mouse to adjust sensitivity"); + + /// + /// "Cursor sensitivity" + /// + public static LocalisableString CursorSensitivity => new TranslatableString(getKey(@"cursor_sensitivity"), @"Cursor sensitivity"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/Notifications.resx b/osu.Game/Localisation/Notifications.resx deleted file mode 100644 index 08db240ba2..0000000000 --- a/osu.Game/Localisation/Notifications.resx +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - notifications - - - waiting for 'ya - - \ No newline at end of file diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 092eec3a6b..382e0d81f4 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -7,17 +7,17 @@ namespace osu.Game.Localisation { public static class NotificationsStrings { - private const string prefix = "osu.Game.Localisation.Notifications"; + private const string prefix = @"osu.Game.Resources.Localisation.Notifications"; /// /// "notifications" /// - public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "notifications"); + public static LocalisableString HeaderTitle => new TranslatableString(getKey(@"header_title"), @"notifications"); /// /// "waiting for 'ya" /// - public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "waiting for 'ya"); + public static LocalisableString HeaderDescription => new TranslatableString(getKey(@"header_description"), @"waiting for 'ya"); private static string getKey(string key) => $"{prefix}:{key}"; } diff --git a/osu.Game/Localisation/NowPlaying.resx b/osu.Game/Localisation/NowPlaying.resx deleted file mode 100644 index 40fda3e25b..0000000000 --- a/osu.Game/Localisation/NowPlaying.resx +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - now playing - - - manage the currently playing track - - \ No newline at end of file diff --git a/osu.Game/Localisation/NowPlayingStrings.cs b/osu.Game/Localisation/NowPlayingStrings.cs index d742a56895..f334637338 100644 --- a/osu.Game/Localisation/NowPlayingStrings.cs +++ b/osu.Game/Localisation/NowPlayingStrings.cs @@ -7,17 +7,17 @@ namespace osu.Game.Localisation { public static class NowPlayingStrings { - private const string prefix = "osu.Game.Localisation.NowPlaying"; + private const string prefix = @"osu.Game.Resources.Localisation.NowPlaying"; /// /// "now playing" /// - public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "now playing"); + public static LocalisableString HeaderTitle => new TranslatableString(getKey(@"header_title"), @"now playing"); /// /// "manage the currently playing track" /// - public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "manage the currently playing track"); + public static LocalisableString HeaderDescription => new TranslatableString(getKey(@"header_description"), @"manage the currently playing track"); private static string getKey(string key) => $"{prefix}:{key}"; } diff --git a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs index 7b21e1af42..a35ce7a9c8 100644 --- a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs +++ b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Resources; using System.Threading.Tasks; using osu.Framework.Localisation; @@ -34,7 +35,29 @@ namespace osu.Game.Localisation lock (resourceManagers) { if (!resourceManagers.TryGetValue(ns, out var manager)) - resourceManagers[ns] = manager = new ResourceManager(ns, GetType().Assembly); + { + var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); + + // Traverse backwards through periods in the namespace to find a matching assembly. + string assemblyName = ns; + + while (!string.IsNullOrEmpty(assemblyName)) + { + var matchingAssembly = loadedAssemblies.FirstOrDefault(asm => asm.GetName().Name == assemblyName); + + if (matchingAssembly != null) + { + resourceManagers[ns] = manager = new ResourceManager(ns, matchingAssembly); + break; + } + + int lastIndex = Math.Max(0, assemblyName.LastIndexOf('.')); + assemblyName = assemblyName.Substring(0, lastIndex); + } + } + + if (manager == null) + return null; try { diff --git a/osu.Game/Localisation/Settings.resx b/osu.Game/Localisation/Settings.resx deleted file mode 100644 index 85c224cedf..0000000000 --- a/osu.Game/Localisation/Settings.resx +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - settings - - - change the way osu! behaves - - \ No newline at end of file diff --git a/osu.Game/Localisation/SettingsStrings.cs b/osu.Game/Localisation/SettingsStrings.cs index cfbd392691..aa2e2740eb 100644 --- a/osu.Game/Localisation/SettingsStrings.cs +++ b/osu.Game/Localisation/SettingsStrings.cs @@ -7,17 +7,17 @@ namespace osu.Game.Localisation { public static class SettingsStrings { - private const string prefix = "osu.Game.Localisation.Settings"; + private const string prefix = @"osu.Game.Resources.Localisation.Settings"; /// /// "settings" /// - public static LocalisableString HeaderTitle => new TranslatableString(getKey("header_title"), "settings"); + public static LocalisableString HeaderTitle => new TranslatableString(getKey(@"header_title"), @"settings"); /// /// "change the way osu! behaves" /// - public static LocalisableString HeaderDescription => new TranslatableString(getKey("header_description"), "change the way osu! behaves"); + public static LocalisableString HeaderDescription => new TranslatableString(getKey(@"header_description"), @"change the way osu! behaves"); private static string getKey(string key) => $"{prefix}:{key}"; } diff --git a/osu.Game/Localisation/TabletSettingsStrings.cs b/osu.Game/Localisation/TabletSettingsStrings.cs new file mode 100644 index 0000000000..5bdca09e4a --- /dev/null +++ b/osu.Game/Localisation/TabletSettingsStrings.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class TabletSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.TabletSettings"; + + /// + /// "Tablet" + /// + public static LocalisableString Tablet => new TranslatableString(getKey(@"tablet"), @"Tablet"); + + /// + /// "No tablet detected!" + /// + public static LocalisableString NoTabletDetected => new TranslatableString(getKey(@"no_tablet_detected"), @"No tablet detected!"); + + /// + /// "Reset to full area" + /// + public static LocalisableString ResetToFullArea => new TranslatableString(getKey(@"reset_to_full_area"), @"Reset to full area"); + + /// + /// "Conform to current game aspect ratio" + /// + public static LocalisableString ConformToCurrentGameAspectRatio => new TranslatableString(getKey(@"conform_to_current_game_aspect_ratio"), @"Conform to current game aspect ratio"); + + /// + /// "X Offset" + /// + public static LocalisableString XOffset => new TranslatableString(getKey(@"x_offset"), @"X Offset"); + + /// + /// "Y Offset" + /// + public static LocalisableString YOffset => new TranslatableString(getKey(@"y_offset"), @"Y Offset"); + + /// + /// "Rotation" + /// + public static LocalisableString Rotation => new TranslatableString(getKey(@"rotation"), @"Rotation"); + + /// + /// "Aspect Ratio" + /// + public static LocalisableString AspectRatio => new TranslatableString(getKey(@"aspect_ratio"), @"Aspect Ratio"); + + /// + /// "Lock aspect ratio" + /// + public static LocalisableString LockAspectRatio => new TranslatableString(getKey(@"lock_aspect_ratio"), @"Lock aspect ratio"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Online/API/Requests/CreateChannelRequest.cs b/osu.Game/Online/API/Requests/CreateChannelRequest.cs index 42cb201969..041ad26267 100644 --- a/osu.Game/Online/API/Requests/CreateChannelRequest.cs +++ b/osu.Game/Online/API/Requests/CreateChannelRequest.cs @@ -11,11 +11,11 @@ namespace osu.Game.Online.API.Requests { public class CreateChannelRequest : APIRequest { - private readonly Channel channel; + public readonly Channel Channel; public CreateChannelRequest(Channel channel) { - this.channel = channel; + Channel = channel; } protected override WebRequest CreateWebRequest() @@ -24,7 +24,7 @@ namespace osu.Game.Online.API.Requests req.Method = HttpMethod.Post; req.AddParameter("type", $"{ChannelType.PM}"); - req.AddParameter("target_id", $"{channel.Users.First().Id}"); + req.AddParameter("target_id", $"{Channel.Users.First().Id}"); return req; } diff --git a/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs b/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs index 25e6b3f1af..20856c2768 100644 --- a/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs +++ b/osu.Game/Online/API/Requests/GetSpotlightRankingsRequest.cs @@ -13,7 +13,7 @@ namespace osu.Game.Online.API.Requests private readonly RankingsSortCriteria sort; public GetSpotlightRankingsRequest(RulesetInfo ruleset, int spotlight, RankingsSortCriteria sort) - : base(ruleset, 1) + : base(ruleset) { this.spotlight = spotlight; this.sort = sort; diff --git a/osu.Game/Online/API/Requests/PostMessageRequest.cs b/osu.Game/Online/API/Requests/PostMessageRequest.cs index 84ab873acf..5d508a4cdf 100644 --- a/osu.Game/Online/API/Requests/PostMessageRequest.cs +++ b/osu.Game/Online/API/Requests/PostMessageRequest.cs @@ -9,11 +9,11 @@ namespace osu.Game.Online.API.Requests { public class PostMessageRequest : APIRequest { - private readonly Message message; + public readonly Message Message; public PostMessageRequest(Message message) { - this.message = message; + Message = message; } protected override WebRequest CreateWebRequest() @@ -21,12 +21,12 @@ namespace osu.Game.Online.API.Requests var req = base.CreateWebRequest(); req.Method = HttpMethod.Post; - req.AddParameter(@"is_action", message.IsAction.ToString().ToLowerInvariant()); - req.AddParameter(@"message", message.Content); + req.AddParameter(@"is_action", Message.IsAction.ToString().ToLowerInvariant()); + req.AddParameter(@"message", Message.Content); return req; } - protected override string Target => $@"chat/channels/{message.ChannelId}/messages"; + protected override string Target => $@"chat/channels/{Message.ChannelId}/messages"; } } diff --git a/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs index 3d3c07a5ad..1b394185fd 100644 --- a/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/APILegacyScoreInfo.cs @@ -21,7 +21,12 @@ namespace osu.Game.Online.API.Requests.Responses { var ruleset = rulesets.GetRuleset(OnlineRulesetID); - var mods = Mods != null ? ruleset.CreateInstance().GetAllMods().Where(mod => Mods.Contains(mod.Acronym)).ToArray() : Array.Empty(); + var rulesetInstance = ruleset.CreateInstance(); + + var mods = Mods != null ? rulesetInstance.GetAllMods().Where(mod => Mods.Contains(mod.Acronym)).ToArray() : Array.Empty(); + + // all API scores provided by this class are considered to be legacy. + mods = mods.Append(rulesetInstance.GetAllMods().OfType().Single()).ToArray(); var scoreInfo = new ScoreInfo { @@ -38,7 +43,6 @@ namespace osu.Game.Online.API.Requests.Responses Rank = Rank, Ruleset = ruleset, Mods = mods, - IsLegacyScore = true }; if (Statistics != null) diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index a980f4c54b..3136a3960d 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Logging; +using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Chat.Tabs; @@ -33,6 +34,16 @@ namespace osu.Game.Online.Chat private readonly BindableList availableChannels = new BindableList(); private readonly BindableList joinedChannels = new BindableList(); + /// + /// Keeps a stack of recently closed channels + /// + private readonly List closedChannels = new List(); + + // For efficiency purposes, this constant bounds the number of closed channels we store. + // This number is somewhat arbitrary; future developers are free to modify it. + // Must be a positive number. + private const int closed_channels_max_size = 50; + /// /// The currently opened channel /// @@ -51,6 +62,9 @@ namespace osu.Game.Online.Chat [Resolved] private IAPIProvider api { get; set; } + [Resolved] + private UserLookupCache users { get; set; } + public readonly BindableBool HighPollRate = new BindableBool(); public ChannelManager() @@ -211,7 +225,7 @@ namespace osu.Game.Online.Chat switch (command) { case "np": - AddInternal(new NowPlayingCommand()); + AddInternal(new NowPlayingCommand(target)); break; case "me": @@ -221,7 +235,7 @@ namespace osu.Game.Online.Chat break; } - PostMessage(content, true); + PostMessage(content, true, target); break; case "join": @@ -420,6 +434,18 @@ namespace osu.Game.Online.Chat joinedChannels.Remove(channel); + // Prevent the closedChannel list from exceeding the max size + // by removing the oldest element + if (closedChannels.Count >= closed_channels_max_size) + { + closedChannels.RemoveAt(0); + } + + // For PM channels, we store the user ID; else, we store the channel ID + closedChannels.Add(channel.Type == ChannelType.PM + ? new ClosedChannel(ChannelType.PM, channel.Users.Single().Id) + : new ClosedChannel(channel.Type, channel.Id)); + if (channel.Joined.Value) { api.Queue(new LeaveChannelRequest(channel)); @@ -427,6 +453,46 @@ namespace osu.Game.Online.Chat } }); + /// + /// Opens the most recently closed channel that has not already been reopened, + /// Works similarly to reopening the last closed tab on a web browser. + /// + public void JoinLastClosedChannel() + { + // This loop could be eliminated if the join channel operation ensured that every channel joined + // is removed from the closedChannels list, but it'd require a linear scan of closed channels on every join. + // To keep the overhead of joining channels low, just lazily scan the list of closed channels locally. + while (closedChannels.Count > 0) + { + ClosedChannel lastClosedChannel = closedChannels.Last(); + closedChannels.RemoveAt(closedChannels.Count - 1); + + // If the user has already joined the channel, try the next one + if (joinedChannels.FirstOrDefault(lastClosedChannel.Matches) != null) + continue; + + Channel lastChannel = AvailableChannels.FirstOrDefault(lastClosedChannel.Matches); + + if (lastChannel != null) + { + // Channel exists as an available channel, directly join it + CurrentChannel.Value = JoinChannel(lastChannel); + } + else if (lastClosedChannel.Type == ChannelType.PM) + { + // Try to get user in order to open PM chat + users.GetUserAsync((int)lastClosedChannel.Id).ContinueWith(u => + { + if (u.Result == null) return; + + Schedule(() => CurrentChannel.Value = JoinChannel(new Channel(u.Result))); + }); + } + + return; + } + } + private long lastMessageId; private bool channelsInitialised; @@ -511,4 +577,28 @@ namespace osu.Game.Online.Chat { } } + + /// + /// Stores information about a closed channel + /// + public class ClosedChannel + { + public readonly ChannelType Type; + public readonly long Id; + + public ClosedChannel(ChannelType type, long id) + { + Type = type; + Id = id; + } + + public bool Matches(Channel channel) + { + if (channel.Type != Type) return false; + + return Type == ChannelType.PM + ? channel.Users.Single().Id == Id + : channel.Id == Id; + } + } } diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs index e7f47833a2..53ea1d6f99 100644 --- a/osu.Game/Online/Chat/DrawableLinkCompiler.cs +++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs @@ -28,7 +28,7 @@ namespace osu.Game.Online.Chat public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parts.Any(d => d.ReceivePositionalInputAt(screenSpacePos)); - protected override HoverClickSounds CreateHoverClickSounds(HoverSampleSet sampleSet) => new LinkHoverSounds(sampleSet, Parts); + protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new LinkHoverSounds(sampleSet, Parts); public DrawableLinkCompiler(IEnumerable parts) { diff --git a/osu.Game/Online/Chat/Message.cs b/osu.Game/Online/Chat/Message.cs index 30753b3920..4f33153e56 100644 --- a/osu.Game/Online/Chat/Message.cs +++ b/osu.Game/Online/Chat/Message.cs @@ -63,5 +63,7 @@ namespace osu.Game.Online.Chat // ReSharper disable once ImpureMethodCallOnReadonlyValueField public override int GetHashCode() => Id.GetHashCode(); + + public override string ToString() => $"[{ChannelId}] ({Id}) {Sender}: {Content}"; } } diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index df14d7eb1c..ae9199c428 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -154,6 +154,10 @@ namespace osu.Game.Online.Chat case "beatmapsets": case "d": { + if (mainArg == "discussions") + // handle discussion links externally for now + return new LinkDetails(LinkAction.External, url); + if (args.Length > 4 && int.TryParse(args[4], out var id)) // https://osu.ppy.sh/beatmapsets/1154158#osu/2768184 return new LinkDetails(LinkAction.OpenBeatmap, id.ToString()); @@ -316,6 +320,7 @@ namespace osu.Game.Online.Chat JoinMultiplayerMatch, Spectate, OpenUserProfile, + SearchBeatmapSet, OpenWiki, Custom, } diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs new file mode 100644 index 0000000000..6840c036ff --- /dev/null +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -0,0 +1,181 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.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.Sprites; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; +using osu.Game.Users; + +namespace osu.Game.Online.Chat +{ + /// + /// Component that handles creating and posting notifications for incoming messages. + /// + public class MessageNotifier : Component + { + [Resolved] + private NotificationOverlay notifications { get; set; } + + [Resolved] + private ChatOverlay chatOverlay { get; set; } + + [Resolved] + private ChannelManager channelManager { get; set; } + + private Bindable notifyOnUsername; + private Bindable notifyOnPrivateMessage; + + private readonly IBindable localUser = new Bindable(); + private readonly IBindableList joinedChannels = new BindableList(); + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config, IAPIProvider api) + { + notifyOnUsername = config.GetBindable(OsuSetting.NotifyOnUsernameMentioned); + notifyOnPrivateMessage = config.GetBindable(OsuSetting.NotifyOnPrivateMessage); + + localUser.BindTo(api.LocalUser); + joinedChannels.BindTo(channelManager.JoinedChannels); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + joinedChannels.BindCollectionChanged(channelsChanged, true); + } + + private void channelsChanged(object sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var channel in e.NewItems.Cast()) + channel.NewMessagesArrived += checkNewMessages; + + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var channel in e.OldItems.Cast()) + channel.NewMessagesArrived -= checkNewMessages; + + break; + } + } + + private void checkNewMessages(IEnumerable messages) + { + if (!messages.Any()) + return; + + var channel = channelManager.JoinedChannels.SingleOrDefault(c => c.Id == messages.First().ChannelId); + + if (channel == null) + return; + + // Only send notifications, if ChatOverlay and the target channel aren't visible. + if (chatOverlay.IsPresent && channelManager.CurrentChannel.Value == channel) + return; + + foreach (var message in messages.OrderByDescending(m => m.Id)) + { + // ignore messages that already have been read + if (message.Id <= channel.LastReadId) + return; + + if (message.Sender.Id == localUser.Value.Id) + continue; + + // check for private messages first to avoid both posting two notifications about the same message + if (checkForPMs(channel, message)) + continue; + + checkForMentions(channel, message); + } + } + + /// + /// Checks whether the user enabled private message notifications and whether specified is a direct message. + /// + /// The channel associated to the + /// The message to be checked + /// Whether a notification was fired. + private bool checkForPMs(Channel channel, Message message) + { + if (!notifyOnPrivateMessage.Value || channel.Type != ChannelType.PM) + return false; + + notifications.Post(new PrivateMessageNotification(message.Sender.Username, channel)); + return true; + } + + private void checkForMentions(Channel channel, Message message) + { + if (!notifyOnUsername.Value || !checkContainsUsername(message.Content, localUser.Value.Username)) return; + + notifications.Post(new MentionNotification(message.Sender.Username, channel)); + } + + /// + /// Checks if contains . + /// This will match against the case where underscores are used instead of spaces (which is how osu-stable handles usernames with spaces). + /// + private static bool checkContainsUsername(string message, string username) => message.Contains(username, StringComparison.OrdinalIgnoreCase) || message.Contains(username.Replace(' ', '_'), StringComparison.OrdinalIgnoreCase); + + public class PrivateMessageNotification : OpenChannelNotification + { + public PrivateMessageNotification(string username, Channel channel) + : base(channel) + { + Icon = FontAwesome.Solid.Envelope; + Text = $"You received a private message from '{username}'. Click to read it!"; + } + } + + public class MentionNotification : OpenChannelNotification + { + public MentionNotification(string username, Channel channel) + : base(channel) + { + Icon = FontAwesome.Solid.At; + Text = $"Your name was mentioned in chat by '{username}'. Click to find out why!"; + } + } + + public abstract class OpenChannelNotification : SimpleNotification + { + protected OpenChannelNotification(Channel channel) + { + this.channel = channel; + } + + private readonly Channel channel; + + public override bool IsImportant => false; + + [BackgroundDependencyLoader] + private void load(OsuColour colours, ChatOverlay chatOverlay, NotificationOverlay notificationOverlay, ChannelManager channelManager) + { + IconBackgound.Colour = colours.PurpleDark; + + Activated = delegate + { + notificationOverlay.Hide(); + chatOverlay.Show(); + channelManager.CurrentChannel.Value = channel; + + return true; + }; + } + } + } +} diff --git a/osu.Game/Online/Chat/NowPlayingCommand.cs b/osu.Game/Online/Chat/NowPlayingCommand.cs index 926709694b..7756591e03 100644 --- a/osu.Game/Online/Chat/NowPlayingCommand.cs +++ b/osu.Game/Online/Chat/NowPlayingCommand.cs @@ -21,6 +21,17 @@ namespace osu.Game.Online.Chat [Resolved] private Bindable currentBeatmap { get; set; } + private readonly Channel target; + + /// + /// Creates a new to post the currently-playing beatmap to a parenting . + /// + /// The target channel to post to. If null, the currently-selected channel will be posted to. + public NowPlayingCommand(Channel target = null) + { + this.target = target; + } + protected override void LoadComplete() { base.LoadComplete(); @@ -48,7 +59,7 @@ namespace osu.Game.Online.Chat var beatmapString = beatmap.OnlineBeatmapID.HasValue ? $"[{api.WebsiteRootUrl}/b/{beatmap.OnlineBeatmapID} {beatmap}]" : beatmap.ToString(); - channelManager.PostMessage($"is {verb} {beatmapString}", true); + channelManager.PostMessage($"is {verb} {beatmapString}", true, target); Expire(); } } diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index d18f189a70..4f8b27602b 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -44,9 +44,9 @@ namespace osu.Game.Online.Leaderboards protected override Container Content => content; - private IEnumerable scores; + private ICollection scores; - public IEnumerable Scores + public ICollection Scores { get => scores; set @@ -82,7 +82,7 @@ namespace osu.Game.Online.Leaderboards foreach (var s in scrollFlow.Children) { - using (s.BeginDelayedSequence(i++ * 50, true)) + using (s.BeginDelayedSequence(i++ * 50)) s.Show(); } @@ -126,7 +126,7 @@ namespace osu.Game.Online.Leaderboards return; scope = value; - UpdateScores(); + RefreshScores(); } } @@ -154,7 +154,7 @@ namespace osu.Game.Online.Leaderboards case PlaceholderState.NetworkFailure: replacePlaceholder(new ClickablePlaceholder(@"Couldn't fetch scores!", FontAwesome.Solid.Sync) { - Action = UpdateScores, + Action = RefreshScores }); break; @@ -254,8 +254,6 @@ namespace osu.Game.Online.Leaderboards apiState.BindValueChanged(onlineStateChanged, true); } - public void RefreshScores() => UpdateScores(); - private APIRequest getScoresRequest; protected abstract bool IsOnlineScope { get; } @@ -267,12 +265,14 @@ namespace osu.Game.Online.Leaderboards case APIState.Online: case APIState.Offline: if (IsOnlineScope) - UpdateScores(); + RefreshScores(); break; } }); + public void RefreshScores() => Scheduler.AddOnce(UpdateScores); + protected void UpdateScores() { // don't display any scores or placeholder until the first Scores_Set has been called. @@ -290,7 +290,7 @@ namespace osu.Game.Online.Leaderboards getScoresRequest = FetchScores(scores => Schedule(() => { - Scores = scores; + Scores = scores.ToArray(); PlaceholderState = Scores.Any() ? PlaceholderState.Successful : PlaceholderState.NoScores; })); diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 795540b65d..7108a23e44 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -247,7 +248,7 @@ namespace osu.Game.Online.Leaderboards this.FadeIn(200); content.MoveToY(0, 800, Easing.OutQuint); - using (BeginDelayedSequence(100, true)) + using (BeginDelayedSequence(100)) { avatar.FadeIn(300, Easing.OutQuint); nameLabel.FadeIn(350, Easing.OutQuint); @@ -255,12 +256,12 @@ namespace osu.Game.Online.Leaderboards avatar.MoveToX(0, 300, Easing.OutQuint); nameLabel.MoveToX(0, 350, Easing.OutQuint); - using (BeginDelayedSequence(250, true)) + using (BeginDelayedSequence(250)) { scoreLabel.FadeIn(200); scoreRank.FadeIn(200); - using (BeginDelayedSequence(50, true)) + using (BeginDelayedSequence(50)) { var drawables = new Drawable[] { flagBadgeContainer, modsContainer }.Concat(statisticsLabels).ToArray(); for (int i = 0; i < drawables.Length; i++) @@ -295,7 +296,7 @@ namespace osu.Game.Online.Leaderboards public override bool Contains(Vector2 screenSpacePos) => content.Contains(screenSpacePos); - public string TooltipText { get; } + public LocalisableString TooltipText { get; } public ScoreComponentLabel(LeaderboardScoreStatistic statistic) { @@ -365,7 +366,7 @@ namespace osu.Game.Online.Leaderboards }; } - public string TooltipText { get; } + public LocalisableString TooltipText { get; } } public class LeaderboardScoreStatistic diff --git a/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs index 4640640c5f..0a618c8f5c 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs @@ -15,6 +15,16 @@ namespace osu.Game.Online.Multiplayer /// /// The databased room ID. /// If the user is already in the requested (or another) room. + /// If the room required a password. Task JoinRoom(long roomId); + + /// + /// Request to join a multiplayer room with a provided password. + /// + /// The databased room ID. + /// The password for the join request. + /// If the user is already in the requested (or another) room. + /// If the room provided password was incorrect. + Task JoinRoomWithPassword(long roomId, string password); } } diff --git a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs new file mode 100644 index 0000000000..0441aea287 --- /dev/null +++ b/osu.Game/Online/Multiplayer/InvalidPasswordException.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; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.SignalR; + +namespace osu.Game.Online.Multiplayer +{ + [Serializable] + public class InvalidPasswordException : HubException + { + public InvalidPasswordException() + { + } + + protected InvalidPasswordException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 2e65f7cf1c..9972d7e88d 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -92,7 +92,7 @@ namespace osu.Game.Online.Multiplayer [Resolved] private UserLookupCache userLookupCache { get; set; } = null!; - private Room? apiRoom; + protected Room? APIRoom { get; private set; } [BackgroundDependencyLoader] private void load() @@ -115,7 +115,8 @@ namespace osu.Game.Online.Multiplayer /// Joins the for a given API . /// /// The API . - public async Task JoinRoom(Room room) + /// An optional password to use for the join operation. + public async Task JoinRoom(Room room, string? password = null) { var cancellationSource = joinCancellationSource = new CancellationTokenSource(); @@ -127,7 +128,7 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(room.RoomID.Value != null); // Join the server-side room. - var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false); + var joinedRoom = await JoinRoom(room.RoomID.Value.Value, password ?? room.Password.Value).ConfigureAwait(false); Debug.Assert(joinedRoom != null); // Populate users. @@ -138,7 +139,7 @@ namespace osu.Game.Online.Multiplayer await scheduleAsync(() => { Room = joinedRoom; - apiRoom = room; + APIRoom = room; foreach (var user in joinedRoom.Users) updateUserPlayingState(user.UserID, user.State); }, cancellationSource.Token).ConfigureAwait(false); @@ -152,8 +153,9 @@ namespace osu.Game.Online.Multiplayer /// Joins the with a given ID. /// /// The room ID. + /// An optional password to use when joining the room. /// The joined . - protected abstract Task JoinRoom(long roomId); + protected abstract Task JoinRoom(long roomId, string? password = null); public Task LeaveRoom() { @@ -166,7 +168,7 @@ namespace osu.Game.Online.Multiplayer // For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time. var scheduledReset = scheduleAsync(() => { - apiRoom = null; + APIRoom = null; Room = null; CurrentMatchPlayingUserIds.Clear(); @@ -189,8 +191,9 @@ namespace osu.Game.Online.Multiplayer /// A room must be joined for this to have any effect. /// /// The new room name, if any. + /// The new password, if any. /// The new room playlist item, if any. - public Task ChangeSettings(Optional name = default, Optional item = default) + public Task ChangeSettings(Optional name = default, Optional password = default, Optional item = default) { if (Room == null) throw new InvalidOperationException("Must be joined to a match to change settings."); @@ -212,6 +215,7 @@ namespace osu.Game.Online.Multiplayer return ChangeSettings(new MultiplayerRoomSettings { Name = name.GetOr(Room.Settings.Name), + Password = password.GetOr(Room.Settings.Password), BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID, BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, RulesetID = item.GetOr(existingPlaylistItem).RulesetID, @@ -301,22 +305,22 @@ namespace osu.Game.Online.Multiplayer if (Room == null) return; - Debug.Assert(apiRoom != null); + Debug.Assert(APIRoom != null); Room.State = state; switch (state) { case MultiplayerRoomState.Open: - apiRoom.Status.Value = new RoomStatusOpen(); + APIRoom.Status.Value = new RoomStatusOpen(); break; case MultiplayerRoomState.Playing: - apiRoom.Status.Value = new RoomStatusPlaying(); + APIRoom.Status.Value = new RoomStatusPlaying(); break; case MultiplayerRoomState.Closed: - apiRoom.Status.Value = new RoomStatusEnded(); + APIRoom.Status.Value = new RoomStatusEnded(); break; } @@ -377,12 +381,12 @@ namespace osu.Game.Online.Multiplayer if (Room == null) return; - Debug.Assert(apiRoom != null); + Debug.Assert(APIRoom != null); var user = Room.Users.FirstOrDefault(u => u.UserID == userId); Room.Host = user; - apiRoom.Host.Value = user?.User; + APIRoom.Host.Value = user?.User; RoomUpdated?.Invoke(); }, false); @@ -525,11 +529,12 @@ namespace osu.Game.Online.Multiplayer if (Room == null) return; - Debug.Assert(apiRoom != null); + Debug.Assert(APIRoom != null); // Update a few properties of the room instantaneously. Room.Settings = settings; - apiRoom.Name.Value = Room.Settings.Name; + APIRoom.Name.Value = Room.Settings.Name; + APIRoom.Password.Value = Room.Settings.Password; // The current item update is delayed until an online beatmap lookup (below) succeeds. // In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here. @@ -551,7 +556,7 @@ namespace osu.Game.Online.Multiplayer if (Room == null || !Room.Settings.Equals(settings)) return; - Debug.Assert(apiRoom != null); + Debug.Assert(APIRoom != null); var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID); beatmap.MD5Hash = settings.BeatmapChecksum; @@ -561,7 +566,7 @@ namespace osu.Game.Online.Multiplayer var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset)); // Try to retrieve the existing playlist item from the API room. - var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId); + var playlistItem = APIRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId); if (playlistItem != null) updateItem(playlistItem); @@ -569,7 +574,7 @@ namespace osu.Game.Online.Multiplayer { // An existing playlist item does not exist, so append a new one. updateItem(playlistItem = new PlaylistItem()); - apiRoom.Playlist.Add(playlistItem); + APIRoom.Playlist.Add(playlistItem); } CurrentMatchPlayingItem.Value = playlistItem; diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index 7d6c76bc2f..4e94c5982f 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using MessagePack; using osu.Game.Online.API; @@ -28,23 +27,25 @@ namespace osu.Game.Online.Multiplayer [Key(3)] public string Name { get; set; } = "Unnamed room"; - [NotNull] [Key(4)] public IEnumerable RequiredMods { get; set; } = Enumerable.Empty(); - [NotNull] [Key(5)] public IEnumerable AllowedMods { get; set; } = Enumerable.Empty(); [Key(6)] public long PlaylistItemId { get; set; } + [Key(7)] + public string Password { get; set; } = string.Empty; + public bool Equals(MultiplayerRoomSettings other) => BeatmapID == other.BeatmapID && BeatmapChecksum == other.BeatmapChecksum && RequiredMods.SequenceEqual(other.RequiredMods) && AllowedMods.SequenceEqual(other.AllowedMods) && RulesetID == other.RulesetID + && Password.Equals(other.Password, StringComparison.Ordinal) && Name.Equals(other.Name, StringComparison.Ordinal) && PlaylistItemId == other.PlaylistItemId; @@ -52,6 +53,7 @@ namespace osu.Game.Online.Multiplayer + $" Beatmap:{BeatmapID} ({BeatmapChecksum})" + $" RequiredMods:{string.Join(',', RequiredMods)}" + $" AllowedMods:{string.Join(',', AllowedMods)}" + + $" Password:{(string.IsNullOrEmpty(Password) ? "no" : "yes")}" + $" Ruleset:{RulesetID}" + $" Item:{PlaylistItemId}"; } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs index c654127b94..a49a8f083c 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using MessagePack; using Newtonsoft.Json; using osu.Game.Online.API; @@ -35,7 +34,6 @@ namespace osu.Game.Online.Multiplayer /// Any mods applicable only to the local user. /// [Key(3)] - [NotNull] public IEnumerable Mods { get; set; } = Enumerable.Empty(); [IgnoreMember] diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index cf1e18e059..726e26ebe1 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -62,12 +62,12 @@ namespace osu.Game.Online.Multiplayer } } - protected override Task JoinRoom(long roomId) + protected override Task JoinRoom(long roomId, string? password = null) { if (!IsConnected.Value) return Task.FromCanceled(new CancellationToken(true)); - return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId); + return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty); } protected override Task LeaveRoomInternal() diff --git a/osu.Game/Online/Rooms/JoinRoomRequest.cs b/osu.Game/Online/Rooms/JoinRoomRequest.cs index faa20a3e6c..b2d772cac7 100644 --- a/osu.Game/Online/Rooms/JoinRoomRequest.cs +++ b/osu.Game/Online/Rooms/JoinRoomRequest.cs @@ -9,11 +9,13 @@ namespace osu.Game.Online.Rooms { public class JoinRoomRequest : APIRequest { - private readonly Room room; + public readonly Room Room; + public readonly string Password; - public JoinRoomRequest(Room room) + public JoinRoomRequest(Room room, string password) { - this.room = room; + Room = room; + Password = password; } protected override WebRequest CreateWebRequest() @@ -23,6 +25,7 @@ namespace osu.Game.Online.Rooms return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}"; + // Todo: Password needs to be specified here rather than via AddParameter() because this is a PUT request. May be a framework bug. + protected override string Target => $"rooms/{Room.RoomID.Value}/users/{User.Id}?password={Password}"; } } diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index b28680ffef..4c506e26a8 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -10,10 +10,11 @@ using osu.Game.IO.Serialization.Converters; using osu.Game.Online.Rooms.GameTypes; using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Users; +using osu.Game.Utils; namespace osu.Game.Online.Rooms { - public class Room + public class Room : IDeepCloneable { [Cached] [JsonProperty("id")] @@ -48,10 +49,6 @@ namespace osu.Game.Online.Rooms set => Category.Value = value; } - [Cached] - [JsonIgnore] - public readonly Bindable Duration = new Bindable(); - [Cached] [JsonIgnore] public readonly Bindable MaxAttempts = new Bindable(); @@ -76,6 +73,9 @@ namespace osu.Game.Online.Rooms [JsonProperty("current_user_score")] public readonly Bindable UserScore = new Bindable(); + [JsonProperty("has_password")] + public readonly BindableBool HasPassword = new BindableBool(); + [Cached] [JsonProperty("recent_participants")] public readonly BindableList RecentParticipants = new BindableList(); @@ -84,6 +84,16 @@ namespace osu.Game.Online.Rooms [JsonProperty("participant_count")] public readonly Bindable ParticipantCount = new Bindable(); + #region Properties only used for room creation request + + [Cached(Name = nameof(Password))] + [JsonProperty("password")] + public readonly Bindable Password = new Bindable(); + + [Cached] + [JsonIgnore] + public readonly Bindable Duration = new Bindable(); + [JsonProperty("duration")] private int? duration { @@ -97,6 +107,8 @@ namespace osu.Game.Online.Rooms } } + #endregion + // Only supports retrieval for now [Cached] [JsonProperty("ends_at")] @@ -116,11 +128,16 @@ namespace osu.Game.Online.Rooms [JsonIgnore] public readonly Bindable Position = new Bindable(-1); + public Room() + { + Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue)); + } + /// /// 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. /// - public Room CreateCopy() + public Room DeepClone() { var copy = new Room(); @@ -144,6 +161,7 @@ namespace osu.Game.Online.Rooms ChannelId.Value = other.ChannelId.Value; Status.Value = other.Status.Value; Availability.Value = other.Availability.Value; + HasPassword.Value = other.HasPassword.Value; Type.Value = other.Type.Value; MaxParticipants.Value = other.MaxParticipants.Value; ParticipantCount.Value = other.ParticipantCount.Value; diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index c51624341e..8119df43ac 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -50,8 +50,10 @@ using osu.Game.Updater; using osu.Game.Utils; using LogLevel = osu.Framework.Logging.LogLevel; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.IO; using osu.Game.Localisation; +using osu.Game.Performance; using osu.Game.Skinning.Editor; namespace osu.Game @@ -221,7 +223,20 @@ namespace osu.Game // bind config int to database RulesetInfo configRuleset = LocalConfig.GetBindable(OsuSetting.Ruleset); - Ruleset.Value = RulesetStore.GetRuleset(configRuleset.Value) ?? RulesetStore.AvailableRulesets.First(); + + var preferredRuleset = RulesetStore.GetRuleset(configRuleset.Value); + + try + { + Ruleset.Value = preferredRuleset ?? RulesetStore.AvailableRulesets.First(); + } + catch (Exception e) + { + // on startup, a ruleset may be selected which has compatibility issues. + Logger.Error(e, $@"Failed to switch to preferred ruleset {preferredRuleset}."); + Ruleset.Value = RulesetStore.AvailableRulesets.First(); + } + Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ID ?? 0; // bind config int to database SkinInfo @@ -290,6 +305,10 @@ namespace osu.Game ShowChannel(link.Argument); break; + case LinkAction.SearchBeatmapSet: + SearchBeatmapSet(link.Argument); + break; + case LinkAction.OpenEditorTimestamp: case LinkAction.JoinMultiplayerMatch: case LinkAction.Spectate: @@ -360,6 +379,12 @@ namespace osu.Game /// The beatmap to show. public void ShowBeatmap(int beatmapId) => waitForReady(() => beatmapSetOverlay, _ => beatmapSetOverlay.FetchAndShowBeatmap(beatmapId)); + /// + /// Shows the beatmap listing overlay, with the given in the search box. + /// + /// The query to search for. + public void SearchBeatmapSet(string query) => waitForReady(() => beatmapListing, _ => beatmapListing.ShowWithSearch(query)); + /// /// Show a wiki's page as an overlay /// @@ -426,9 +451,12 @@ namespace osu.Game { // 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 = score.OnlineScoreID != null - ? ScoreManager.Query(s => s.OnlineScoreID == score.OnlineScoreID) - : ScoreManager.Query(s => s.Hash == score.Hash); + ScoreInfo databasedScoreInfo = null; + + if (score.OnlineScoreID != null) + databasedScoreInfo = ScoreManager.Query(s => s.OnlineScoreID == score.OnlineScoreID); + + databasedScoreInfo ??= ScoreManager.Query(s => s.Hash == score.Hash); if (databasedScoreInfo == null) { @@ -473,6 +501,10 @@ namespace osu.Game public override Task Import(params ImportTask[] imports) { // encapsulate task as we don't want to begin the import process until in a ready state. + + // ReSharper disable once AsyncVoidLambda + // TODO: This is bad because `new Task` doesn't have a Func override. + // Only used for android imports and a bit of a mess. Probably needs rethinking overall. var importTask = new Task(async () => await base.Import(imports).ConfigureAwait(false)); waitForReady(() => this, _ => importTask.Start()); @@ -484,6 +516,8 @@ namespace osu.Game protected virtual UpdateManager CreateUpdateManager() => new UpdateManager(); + protected virtual HighPerformanceSession CreateHighPerformanceSession() => new HighPerformanceSession(); + protected override Container CreateScalingContainer() => new ScalingContainer(ScalingMode.Everything); #region Beatmap progression @@ -491,16 +525,11 @@ namespace osu.Game private void beatmapChanged(ValueChangedEvent beatmap) { beatmap.OldValue?.CancelAsyncLoad(); - - updateModDefaults(); - beatmap.NewValue?.BeginAsyncLoad(); } private void modsChanged(ValueChangedEvent> mods) { - updateModDefaults(); - // a lease may be taken on the mods bindable, at which point we can't really ensure valid mods. if (SelectedMods.Disabled) return; @@ -512,19 +541,6 @@ namespace osu.Game } } - 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()) - mod.ReadFromDifficulty(adjustedDifficulty); - } - } - #endregion private PerformFromMenuRunner performFromMainMenuTask; @@ -577,8 +593,16 @@ namespace osu.Game foreach (var language in Enum.GetValues(typeof(Language)).OfType()) { - var cultureCode = language.ToString(); - Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode)); + var cultureCode = language.ToCultureCode(); + + try + { + Localisation.AddLanguage(cultureCode, new ResourceManagerLocalisationStore(cultureCode)); + } + catch (Exception ex) + { + Logger.Error(ex, $"Could not load localisations for language \"{cultureCode}\""); + } } // The next time this is updated is in UpdateAfterChildren, which occurs too late and results @@ -601,9 +625,9 @@ namespace osu.Game LocalConfig.LookupKeyBindings = l => { - var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l).ToArray(); + var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l); - if (combinations.Length == 0) + if (combinations.Count == 0) return "none"; return string.Join(" or ", combinations); @@ -651,9 +675,10 @@ namespace osu.Game Origin = Anchor.BottomLeft, Action = () => { - var currentScreen = ScreenStack.CurrentScreen as IOsuScreen; + if (!(ScreenStack.CurrentScreen is IOsuScreen currentScreen)) + return; - if (currentScreen?.AllowBackButton == true && !currentScreen.OnBackButton()) + if (!((Drawable)currentScreen).IsLoaded || (currentScreen.AllowBackButton && !currentScreen.OnBackButton())) ScreenStack.Exit(); } }, @@ -711,7 +736,6 @@ namespace osu.Game PostNotification = n => notifications.Post(n), }, Add, true); - loadComponentSingleFile(difficultyRecommender, Add); loadComponentSingleFile(stableImportManager, Add); loadComponentSingleFile(screenshotManager, Add); @@ -727,6 +751,7 @@ namespace osu.Game var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true); loadComponentSingleFile(channelManager = new ChannelManager(), AddInternal, true); loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true); + loadComponentSingleFile(new MessageNotifier(), AddInternal, 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); @@ -751,8 +776,11 @@ namespace osu.Game loadComponentSingleFile(new AccountCreationOverlay(), topMostOverlayContent.Add, true); loadComponentSingleFile(new DialogOverlay(), topMostOverlayContent.Add, true); + loadComponentSingleFile(CreateHighPerformanceSession(), Add); + chatOverlay.State.ValueChanged += state => channelManager.HighPollRate.Value = state.NewValue == Visibility.Visible; + Add(difficultyRecommender); Add(externalLinkOpener = new ExternalLinkOpener()); Add(new MusicKeyBindingHandler()); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 7935815f38..4b5fa4f62e 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -13,6 +13,7 @@ using osu.Framework.Development; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Game.Beatmaps; @@ -24,6 +25,7 @@ using osu.Framework.Graphics.Performance; using osu.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.Logging; +using osu.Framework.Threading; using osu.Game.Audio; using osu.Game.Database; using osu.Game.Input; @@ -58,7 +60,7 @@ namespace osu.Game /// /// The maximum volume at which audio tracks should playback. This can be set lower than 1 to create some head-room for sound effects. /// - internal const double GLOBAL_TRACK_VOLUME_ADJUST = 0.5; + internal const double GLOBAL_TRACK_VOLUME_ADJUST = 0.8; public bool UseDevelopmentServer { get; } @@ -79,7 +81,7 @@ namespace osu.Game return @"local " + (DebugUtils.IsDebugBuild ? @"debug" : @"release"); var version = AssemblyVersion; - return $@"{version.Major}.{version.Minor}.{version.Build}"; + return $@"{version.Major}.{version.Minor}.{version.Build}-lazer"; } } @@ -95,7 +97,7 @@ namespace osu.Game protected RulesetStore RulesetStore { get; private set; } - protected KeyBindingStore KeyBindingStore { get; private set; } + protected RealmKeyBindingStore KeyBindingStore { get; private set; } protected MenuCursorContainer MenuCursorContainer { get; private set; } @@ -144,6 +146,8 @@ namespace osu.Game private DatabaseContextFactory contextFactory; + private RealmContextFactory realmFactory; + protected override Container Content => content; private Container content; @@ -154,10 +158,12 @@ namespace osu.Game private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(GLOBAL_TRACK_VOLUME_ADJUST); + private IBindable updateThreadState; + public OsuGameBase() { UseDevelopmentServer = DebugUtils.IsDebugBuild; - Name = @"osu!lazer"; + Name = @"osu!"; } [BackgroundDependencyLoader] @@ -179,6 +185,13 @@ namespace osu.Game dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage)); + dependencies.Cache(realmFactory = new RealmContextFactory(Storage)); + + updateThreadState = Host.UpdateThread.State.GetBoundCopy(); + updateThreadState.BindValueChanged(updateThreadStateChanged); + + AddInternal(realmFactory); + dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); @@ -190,20 +203,29 @@ namespace osu.Game AddFont(Resources, @"Fonts/osuFont"); - AddFont(Resources, @"Fonts/Torus-Regular"); - AddFont(Resources, @"Fonts/Torus-Light"); - AddFont(Resources, @"Fonts/Torus-SemiBold"); - AddFont(Resources, @"Fonts/Torus-Bold"); + AddFont(Resources, @"Fonts/Torus/Torus-Regular"); + AddFont(Resources, @"Fonts/Torus/Torus-Light"); + AddFont(Resources, @"Fonts/Torus/Torus-SemiBold"); + AddFont(Resources, @"Fonts/Torus/Torus-Bold"); - 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"); + AddFont(Resources, @"Fonts/Inter/Inter-Regular"); + AddFont(Resources, @"Fonts/Inter/Inter-RegularItalic"); + AddFont(Resources, @"Fonts/Inter/Inter-Light"); + AddFont(Resources, @"Fonts/Inter/Inter-LightItalic"); + AddFont(Resources, @"Fonts/Inter/Inter-SemiBold"); + AddFont(Resources, @"Fonts/Inter/Inter-SemiBoldItalic"); + AddFont(Resources, @"Fonts/Inter/Inter-Bold"); + AddFont(Resources, @"Fonts/Inter/Inter-BoldItalic"); - AddFont(Resources, @"Fonts/Venera-Light"); - AddFont(Resources, @"Fonts/Venera-Bold"); - AddFont(Resources, @"Fonts/Venera-Black"); + AddFont(Resources, @"Fonts/Noto/Noto-Basic"); + AddFont(Resources, @"Fonts/Noto/Noto-Hangul"); + AddFont(Resources, @"Fonts/Noto/Noto-CJK-Basic"); + AddFont(Resources, @"Fonts/Noto/Noto-CJK-Compatibility"); + AddFont(Resources, @"Fonts/Noto/Noto-Thai"); + + AddFont(Resources, @"Fonts/Venera/Venera-Light"); + AddFont(Resources, @"Fonts/Venera/Venera-Bold"); + AddFont(Resources, @"Fonts/Venera/Venera-Black"); Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; @@ -244,7 +266,7 @@ namespace osu.Game dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Host, () => difficultyCache, LocalConfig)); dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, true)); - // this should likely be moved to ArchiveModelManager when another case appers where it is necessary + // this should likely be moved to ArchiveModelManager when another case appears where it is necessary // to have inter-dependent model managers. this could be obtained with an IHasForeign interface to // allow lookups to be done on the child (ScoreManager in this case) to perform the cascading delete. List getBeatmapScores(BeatmapSetInfo set) @@ -275,7 +297,8 @@ namespace osu.Game dependencies.Cache(scorePerformanceManager); AddInternal(scorePerformanceManager); - dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore)); + migrateDataToRealm(); + dependencies.Cache(settingsStore = new SettingsStore(contextFactory)); dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(settingsStore)); @@ -319,11 +342,20 @@ namespace osu.Game globalBindings = new GlobalActionContainer(this) }; - MenuCursorContainer.Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }; + MenuCursorContainer.Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both } + }; base.Content.Add(CreateScalingContainer().WithChildren(mainContent)); + KeyBindingStore = new RealmKeyBindingStore(realmFactory); KeyBindingStore.Register(globalBindings); + + foreach (var r in RulesetStore.AvailableRulesets) + KeyBindingStore.Register(r); + dependencies.Cache(globalBindings); PreviewTrackManager previewTrackManager; @@ -336,6 +368,23 @@ namespace osu.Game Ruleset.BindValueChanged(onRulesetChanged); } + private IDisposable blocking; + + private void updateThreadStateChanged(ValueChangedEvent state) + { + switch (state.NewValue) + { + case GameThreadState.Running: + blocking?.Dispose(); + blocking = null; + break; + + case GameThreadState.Paused: + blocking = realmFactory.BlockAllOperations(); + break; + } + } + protected override void LoadComplete() { base.LoadComplete(); @@ -378,8 +427,15 @@ namespace osu.Game public void Migrate(string path) { - contextFactory.FlushConnections(); - (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); + Logger.Log($@"Migrating osu! data from ""{Storage.GetFullPath(string.Empty)}"" to ""{path}""..."); + + using (realmFactory.BlockAllOperations()) + { + contextFactory.FlushConnections(); + (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); + } + + Logger.Log(@"Migration complete!"); } protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager(); @@ -390,6 +446,34 @@ namespace osu.Game protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage); + private void migrateDataToRealm() + { + using (var db = contextFactory.GetForWrite()) + using (var usage = realmFactory.GetForWrite()) + { + var existingBindings = db.Context.DatabasedKeyBinding; + + // only migrate data if the realm database is empty. + if (!usage.Realm.All().Any()) + { + foreach (var dkb in existingBindings) + { + usage.Realm.Add(new RealmKeyBinding + { + KeyCombinationString = dkb.KeyCombination.ToString(), + ActionInt = (int)dkb.Action, + RulesetID = dkb.RulesetID, + Variant = dkb.Variant + }); + } + } + + db.Context.RemoveRange(existingBindings); + + usage.Commit(); + } + } + private void onRulesetChanged(ValueChangedEvent r) { var dict = new Dictionary>(); diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index 1935a250b7..650d105911 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -10,11 +10,13 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; +using osu.Game.Resources.Localisation.Web; using osuTK; using osuTK.Graphics; @@ -23,9 +25,9 @@ namespace osu.Game.Overlays.BeatmapListing public class BeatmapListingFilterControl : CompositeDrawable { /// - /// Fired when a search finishes. Contains only new items in the case of pagination. + /// Fired when a search finishes. /// - public Action> SearchFinished; + public Action SearchFinished; /// /// Fired when search criteria change. @@ -120,6 +122,9 @@ namespace osu.Game.Overlays.BeatmapListing sortControlBackground.Colour = colourProvider.Background5; } + public void Search(string query) + => searchControl.Query.Value = query; + protected override void LoadComplete() { base.LoadComplete(); @@ -212,7 +217,25 @@ namespace osu.Game.Overlays.BeatmapListing lastResponse = response; getSetsRequest = null; - SearchFinished?.Invoke(sets); + // check if a non-supporter used supporter-only filters + if (!api.LocalUser.Value.IsSupporter) + { + List filters = new List(); + + if (searchControl.Played.Value != SearchPlayed.Any) + filters.Add(BeatmapsStrings.ListingSearchFiltersPlayed); + + if (searchControl.Ranks.Any()) + filters.Add(BeatmapsStrings.ListingSearchFiltersRank); + + if (filters.Any()) + { + SearchFinished?.Invoke(SearchResult.SupporterOnlyFilters(filters)); + return; + } + } + + SearchFinished?.Invoke(SearchResult.ResultsReturned(sets)); }; api.Queue(getSetsRequest); @@ -237,5 +260,53 @@ namespace osu.Game.Overlays.BeatmapListing base.Dispose(isDisposing); } + + /// + /// Indicates the type of result of a user-requested beatmap search. + /// + public enum SearchResultType + { + /// + /// Actual results have been returned from API. + /// + ResultsReturned, + + /// + /// The user is not a supporter, but used supporter-only search filters. + /// + SupporterOnlyFilters + } + + /// + /// Describes the result of a user-requested beatmap search. + /// + public struct SearchResult + { + public SearchResultType Type { get; private set; } + + /// + /// Contains the beatmap sets returned from API. + /// Valid for read if and only if is . + /// + public List Results { get; private set; } + + /// + /// Contains the names of supporter-only filters requested by the user. + /// Valid for read if and only if is . + /// + public List SupporterOnlyFiltersUsed { get; private set; } + + public static SearchResult ResultsReturned(List results) => new SearchResult + { + Type = SearchResultType.ResultsReturned, + Results = results + }; + + public static SearchResult SupporterOnlyFilters(List filters) => new SearchResult + { + Type = SearchResultType.SupporterOnlyFilters, + SupporterOnlyFiltersUsed = filters + }; + } } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs index 97ccb66599..0626f236b8 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -14,6 +14,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; using osuTK.Graphics; using osu.Game.Rulesets; using osu.Game.Scoring; @@ -126,15 +127,15 @@ namespace osu.Game.Overlays.BeatmapListing Padding = new MarginPadding { Horizontal = 10 }, Children = new Drawable[] { - generalFilter = new BeatmapSearchMultipleSelectionFilterRow(@"General"), + generalFilter = new BeatmapSearchMultipleSelectionFilterRow(BeatmapsStrings.ListingSearchFiltersGeneral), modeFilter = new BeatmapSearchRulesetFilterRow(), - categoryFilter = new BeatmapSearchFilterRow(@"Categories"), - genreFilter = new BeatmapSearchFilterRow(@"Genre"), - languageFilter = new BeatmapSearchFilterRow(@"Language"), - extraFilter = new BeatmapSearchMultipleSelectionFilterRow(@"Extra"), + categoryFilter = new BeatmapSearchFilterRow(BeatmapsStrings.ListingSearchFiltersStatus), + genreFilter = new BeatmapSearchFilterRow(BeatmapsStrings.ListingSearchFiltersGenre), + languageFilter = new BeatmapSearchFilterRow(BeatmapsStrings.ListingSearchFiltersLanguage), + extraFilter = new BeatmapSearchMultipleSelectionFilterRow(BeatmapsStrings.ListingSearchFiltersExtra), ranksFilter = new BeatmapSearchScoreFilterRow(), - playedFilter = new BeatmapSearchFilterRow(@"Played"), - explicitContentFilter = new BeatmapSearchFilterRow(@"Explicit Content"), + playedFilter = new BeatmapSearchFilterRow(BeatmapsStrings.ListingSearchFiltersPlayed), + explicitContentFilter = new BeatmapSearchFilterRow(BeatmapsStrings.ListingSearchFiltersNsfw), } } } @@ -172,7 +173,7 @@ namespace osu.Game.Overlays.BeatmapListing public BeatmapSearchTextBox() { - PlaceholderText = @"type in keywords..."; + PlaceholderText = BeatmapsStrings.ListingSearchPrompt; } protected override bool OnKeyDown(KeyDownEvent e) diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs index 01bcbd3244..4c831543fe 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs @@ -11,8 +11,8 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osuTK; -using Humanizer; using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Localisation; namespace osu.Game.Overlays.BeatmapListing { @@ -26,7 +26,7 @@ namespace osu.Game.Overlays.BeatmapListing set => current.Current = value; } - public BeatmapSearchFilterRow(string headerName) + public BeatmapSearchFilterRow(LocalisableString header) { Drawable filter; AutoSizeAxes = Axes.Y; @@ -53,7 +53,7 @@ namespace osu.Game.Overlays.BeatmapListing Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Font = OsuFont.GetFont(size: 13), - Text = headerName.Titleize() + Text = header }, filter = CreateFilter() } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs index 5dfa8e6109..e0632ace58 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchMultipleSelectionFilterRow.cs @@ -9,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osuTK; namespace osu.Game.Overlays.BeatmapListing @@ -19,8 +20,8 @@ namespace osu.Game.Overlays.BeatmapListing private MultipleSelectionFilter filter; - public BeatmapSearchMultipleSelectionFilterRow(string headerName) - : base(headerName) + public BeatmapSearchMultipleSelectionFilterRow(LocalisableString header) + : base(header) { Current.BindTo(filter.Current); } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs index a8dc088e52..e2c84c537c 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchRulesetFilterRow.cs @@ -3,6 +3,8 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; namespace osu.Game.Overlays.BeatmapListing @@ -10,7 +12,7 @@ namespace osu.Game.Overlays.BeatmapListing public class BeatmapSearchRulesetFilterRow : BeatmapSearchFilterRow { public BeatmapSearchRulesetFilterRow() - : base(@"Mode") + : base(BeatmapsStrings.ListingSearchFiltersMode) { } @@ -21,14 +23,21 @@ namespace osu.Game.Overlays.BeatmapListing [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - AddItem(new RulesetInfo - { - Name = @"Any" - }); + AddTabItem(new RulesetFilterTabItemAny()); foreach (var r in rulesets.AvailableRulesets) AddItem(r); } } + + private class RulesetFilterTabItemAny : FilterTabItem + { + protected override LocalisableString LabelFor(RulesetInfo info) => BeatmapsStrings.ModeAny; + + public RulesetFilterTabItemAny() + : base(new RulesetInfo()) + { + } + } } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs index 804962adfb..b39934b56f 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchScoreFilterRow.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; namespace osu.Game.Overlays.BeatmapListing @@ -11,7 +13,7 @@ namespace osu.Game.Overlays.BeatmapListing public class BeatmapSearchScoreFilterRow : BeatmapSearchMultipleSelectionFilterRow { public BeatmapSearchScoreFilterRow() - : base(@"Rank Achieved") + : base(BeatmapsStrings.ListingSearchFiltersRank) { } @@ -31,20 +33,7 @@ namespace osu.Game.Overlays.BeatmapListing { } - protected override string LabelFor(ScoreRank value) - { - switch (value) - { - case ScoreRank.XH: - return @"Silver SS"; - - case ScoreRank.SH: - return @"Silver S"; - - default: - return value.GetDescription(); - } - } + protected override LocalisableString LabelFor(ScoreRank value) => value.GetLocalisableDescription(); } } } diff --git a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs index f02b515755..46cb1e822f 100644 --- a/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs +++ b/osu.Game/Overlays/BeatmapListing/FilterTabItem.cs @@ -7,6 +7,7 @@ using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -66,7 +67,7 @@ namespace osu.Game.Overlays.BeatmapListing /// /// Returns the label text to be used for the supplied . /// - protected virtual string LabelFor(T value) => (value as Enum)?.GetDescription() ?? value.ToString(); + protected virtual LocalisableString LabelFor(T value) => (value as Enum)?.GetLocalisableDescription() ?? value.ToString(); private void updateState() { diff --git a/osu.Game/Overlays/BeatmapListing/SearchCategory.cs b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs index 84859bf5b5..8a9df76af3 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchCategory.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchCategory.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.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { + [LocalisableEnum(typeof(SearchCategoryEnumLocalisationMapper))] public enum SearchCategory { Any, @@ -23,4 +27,43 @@ namespace osu.Game.Overlays.BeatmapListing [Description("My Maps")] Mine, } + + public class SearchCategoryEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(SearchCategory value) + { + switch (value) + { + case SearchCategory.Any: + return BeatmapsStrings.StatusAny; + + case SearchCategory.Leaderboard: + return BeatmapsStrings.StatusLeaderboard; + + case SearchCategory.Ranked: + return BeatmapsStrings.StatusRanked; + + case SearchCategory.Qualified: + return BeatmapsStrings.StatusQualified; + + case SearchCategory.Loved: + return BeatmapsStrings.StatusLoved; + + case SearchCategory.Favourites: + return BeatmapsStrings.StatusFavourites; + + case SearchCategory.Pending: + return BeatmapsStrings.StatusPending; + + case SearchCategory.Graveyard: + return BeatmapsStrings.StatusGraveyard; + + case SearchCategory.Mine: + return BeatmapsStrings.StatusMine; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs b/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs index 3e57cdd48c..78e6a4e094 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs @@ -1,11 +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 osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.BeatmapListing { + [LocalisableEnum(typeof(SearchExplicitEnumLocalisationMapper))] public enum SearchExplicit { Hide, Show } + + public class SearchExplicitEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(SearchExplicit value) + { + switch (value) + { + case SearchExplicit.Hide: + return BeatmapsStrings.NsfwExclude; + + case SearchExplicit.Show: + return BeatmapsStrings.NsfwInclude; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchExtra.cs b/osu.Game/Overlays/BeatmapListing/SearchExtra.cs index af37e3264f..4b3fb6e833 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchExtra.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchExtra.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.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { + [LocalisableEnum(typeof(SearchExtraEnumLocalisationMapper))] public enum SearchExtra { [Description("Has Video")] @@ -13,4 +17,22 @@ namespace osu.Game.Overlays.BeatmapListing [Description("Has Storyboard")] Storyboard } + + public class SearchExtraEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(SearchExtra value) + { + switch (value) + { + case SearchExtra.Video: + return BeatmapsStrings.ExtraVideo; + + case SearchExtra.Storyboard: + return BeatmapsStrings.ExtraStoryboard; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs b/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs index 175942c626..b4c629f7fa 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchGeneral.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.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { + [LocalisableEnum(typeof(SearchGeneralEnumLocalisationMapper))] public enum SearchGeneral { [Description("Recommended difficulty")] @@ -16,4 +20,25 @@ namespace osu.Game.Overlays.BeatmapListing [Description("Subscribed mappers")] Follows } + + public class SearchGeneralEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(SearchGeneral value) + { + switch (value) + { + case SearchGeneral.Recommended: + return BeatmapsStrings.GeneralRecommended; + + case SearchGeneral.Converts: + return BeatmapsStrings.GeneralConverts; + + case SearchGeneral.Follows: + return BeatmapsStrings.GeneralFollows; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchGenre.cs b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs index de437fac3e..b2709ecd2e 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchGenre.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchGenre.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.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { + [LocalisableEnum(typeof(SearchGenreEnumLocalisationMapper))] public enum SearchGenre { Any = 0, @@ -26,4 +30,58 @@ namespace osu.Game.Overlays.BeatmapListing Folk = 13, Jazz = 14 } + + public class SearchGenreEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(SearchGenre value) + { + switch (value) + { + case SearchGenre.Any: + return BeatmapsStrings.GenreAny; + + case SearchGenre.Unspecified: + return BeatmapsStrings.GenreUnspecified; + + case SearchGenre.VideoGame: + return BeatmapsStrings.GenreVideoGame; + + case SearchGenre.Anime: + return BeatmapsStrings.GenreAnime; + + case SearchGenre.Rock: + return BeatmapsStrings.GenreRock; + + case SearchGenre.Pop: + return BeatmapsStrings.GenrePop; + + case SearchGenre.Other: + return BeatmapsStrings.GenreOther; + + case SearchGenre.Novelty: + return BeatmapsStrings.GenreNovelty; + + case SearchGenre.HipHop: + return BeatmapsStrings.GenreHipHop; + + case SearchGenre.Electronic: + return BeatmapsStrings.GenreElectronic; + + case SearchGenre.Metal: + return BeatmapsStrings.GenreMetal; + + case SearchGenre.Classical: + return BeatmapsStrings.GenreClassical; + + case SearchGenre.Folk: + return BeatmapsStrings.GenreFolk; + + case SearchGenre.Jazz: + return BeatmapsStrings.GenreJazz; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs index 015cee8ce3..fc176c305a 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchLanguage.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 osu.Framework.Localisation; using osu.Framework.Utils; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { + [LocalisableEnum(typeof(SearchLanguageEnumLocalisationMapper))] [HasOrderedElements] public enum SearchLanguage { @@ -53,4 +57,61 @@ namespace osu.Game.Overlays.BeatmapListing [Order(13)] Other } + + public class SearchLanguageEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(SearchLanguage value) + { + switch (value) + { + case SearchLanguage.Any: + return BeatmapsStrings.LanguageAny; + + case SearchLanguage.Unspecified: + return BeatmapsStrings.LanguageUnspecified; + + case SearchLanguage.English: + return BeatmapsStrings.LanguageEnglish; + + case SearchLanguage.Japanese: + return BeatmapsStrings.LanguageJapanese; + + case SearchLanguage.Chinese: + return BeatmapsStrings.LanguageChinese; + + case SearchLanguage.Instrumental: + return BeatmapsStrings.LanguageInstrumental; + + case SearchLanguage.Korean: + return BeatmapsStrings.LanguageKorean; + + case SearchLanguage.French: + return BeatmapsStrings.LanguageFrench; + + case SearchLanguage.German: + return BeatmapsStrings.LanguageGerman; + + case SearchLanguage.Swedish: + return BeatmapsStrings.LanguageSwedish; + + case SearchLanguage.Spanish: + return BeatmapsStrings.LanguageSpanish; + + case SearchLanguage.Italian: + return BeatmapsStrings.LanguageItalian; + + case SearchLanguage.Russian: + return BeatmapsStrings.LanguageRussian; + + case SearchLanguage.Polish: + return BeatmapsStrings.LanguagePolish; + + case SearchLanguage.Other: + return BeatmapsStrings.LanguageOther; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs b/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs index eb7fb46158..f24cf46c2d 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs @@ -1,12 +1,38 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.BeatmapListing { + [LocalisableEnum(typeof(SearchPlayedEnumLocalisationMapper))] public enum SearchPlayed { Any, Played, Unplayed } + + public class SearchPlayedEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(SearchPlayed value) + { + switch (value) + { + case SearchPlayed.Any: + return BeatmapsStrings.PlayedAny; + + case SearchPlayed.Played: + return BeatmapsStrings.PlayedPlayed; + + case SearchPlayed.Unplayed: + return BeatmapsStrings.PlayedUnplayed; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs index e409cbdda7..5ea885eecc 100644 --- a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs +++ b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs @@ -1,8 +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.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.BeatmapListing { + [LocalisableEnum(typeof(SortCriteriaLocalisationMapper))] public enum SortCriteria { Title, @@ -14,4 +19,40 @@ namespace osu.Game.Overlays.BeatmapListing Favourites, Relevance } + + public class SortCriteriaLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(SortCriteria value) + { + switch (value) + { + case SortCriteria.Title: + return BeatmapsStrings.ListingSearchSortingTitle; + + case SortCriteria.Artist: + return BeatmapsStrings.ListingSearchSortingArtist; + + case SortCriteria.Difficulty: + return BeatmapsStrings.ListingSearchSortingDifficulty; + + case SortCriteria.Ranked: + return BeatmapsStrings.ListingSearchSortingRanked; + + case SortCriteria.Rating: + return BeatmapsStrings.ListingSearchSortingRating; + + case SortCriteria.Plays: + return BeatmapsStrings.ListingSearchSortingPlays; + + case SortCriteria.Favourites: + return BeatmapsStrings.ListingSearchSortingFavourites; + + case SortCriteria.Relevance: + return BeatmapsStrings.ListingSearchSortingRelevance; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index 5df7a4650e..6861d17f26 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -7,6 +7,7 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Localisation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -15,9 +16,12 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.BeatmapListing.Panels; +using osu.Game.Resources.Localisation.Web; using osuTK; using osuTK.Graphics; @@ -32,6 +36,7 @@ namespace osu.Game.Overlays private Container panelTarget; private FillFlowContainer foundContent; private NotFoundDrawable notFoundContent; + private SupporterRequiredDrawable supporterRequiredContent; private BeatmapListingFilterControl filterControl; public BeatmapListingOverlay() @@ -75,6 +80,7 @@ namespace osu.Game.Overlays { foundContent = new FillFlowContainer(), notFoundContent = new NotFoundDrawable(), + supporterRequiredContent = new SupporterRequiredDrawable(), } } }, @@ -83,6 +89,12 @@ namespace osu.Game.Overlays }; } + public void ShowWithSearch(string query) + { + filterControl.Search(query); + Show(); + } + protected override BeatmapListingHeader CreateHeader() => new BeatmapListingHeader(); protected override Color4 BackgroundColour => ColourProvider.Background6; @@ -114,9 +126,16 @@ namespace osu.Game.Overlays private Task panelLoadDelegate; - private void onSearchFinished(List beatmaps) + private void onSearchFinished(BeatmapListingFilterControl.SearchResult searchResult) { - var newPanels = beatmaps.Select(b => new GridBeatmapPanel(b) + if (searchResult.Type == BeatmapListingFilterControl.SearchResultType.SupporterOnlyFilters) + { + supporterRequiredContent.UpdateText(searchResult.SupporterOnlyFiltersUsed); + addContentToPlaceholder(supporterRequiredContent); + return; + } + + var newPanels = searchResult.Results.Select(b => new GridBeatmapPanel(b) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, @@ -127,7 +146,7 @@ namespace osu.Game.Overlays //No matches case if (!newPanels.Any()) { - LoadComponentAsync(notFoundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + addContentToPlaceholder(notFoundContent); return; } @@ -169,9 +188,9 @@ namespace osu.Game.Overlays { var transform = lastContent.FadeOut(100, Easing.OutQuint); - if (lastContent == notFoundContent) + if (lastContent == notFoundContent || lastContent == supporterRequiredContent) { - // not found display may be used multiple times, so don't expire/dispose it. + // the placeholders may be used multiple times, so don't expire/dispose them. transform.Schedule(() => panelTarget.Remove(lastContent)); } else @@ -232,13 +251,74 @@ namespace osu.Game.Overlays { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = @"... nope, nothing found.", + Text = BeatmapsStrings.ListingSearchNotFoundQuote, } } }); } } + // TODO: localisation requires Text/LinkFlowContainer support for localising strings with links inside + // (https://github.com/ppy/osu-framework/issues/4530) + public class SupporterRequiredDrawable : CompositeDrawable + { + private LinkFlowContainer supporterRequiredText; + + public SupporterRequiredDrawable() + { + RelativeSizeAxes = Axes.X; + Height = 225; + Alpha = 0; + } + + [BackgroundDependencyLoader] + private void load(TextureStore textures) + { + AddInternal(new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + new Sprite + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + Texture = textures.Get(@"Online/supporter-required"), + }, + supporterRequiredText = new LinkFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Bottom = 10 }, + }, + } + }); + } + + public void UpdateText(List filters) + { + supporterRequiredText.Clear(); + + supporterRequiredText.AddText( + BeatmapsStrings.ListingSearchSupporterFilterQuoteDefault(string.Join(" and ", filters), "").ToString(), + t => + { + t.Font = OsuFont.GetFont(size: 16); + t.Colour = Colour4.White; + } + ); + + supporterRequiredText.AddLink(BeatmapsStrings.ListingSearchSupporterFilterQuoteLinkText.ToString(), @"/store/products/supporter-tag"); + } + } + private const double time_between_fetches = 500; private double lastFetchDisplayedTime; diff --git a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs index 1ffcf9722a..265d9bf125 100644 --- a/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs +++ b/osu.Game/Overlays/BeatmapSet/AuthorInfo.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -22,8 +23,8 @@ namespace osu.Game.Overlays.BeatmapSet { private const float height = 50; - private readonly UpdateableAvatar avatar; - private readonly FillFlowContainer fields; + private UpdateableAvatar avatar; + private FillFlowContainer fields; private BeatmapSetInfo beatmapSet; @@ -35,11 +36,46 @@ namespace osu.Game.Overlays.BeatmapSet if (value == beatmapSet) return; beatmapSet = value; - - updateDisplay(); + Scheduler.AddOnce(updateDisplay); } } + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + Height = height; + + Children = new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.Both, + CornerRadius = 4, + Masking = true, + Child = avatar = new UpdateableAvatar(showGuestOnNull: false) + { + Size = new Vector2(height), + }, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0.25f), + Type = EdgeEffectType.Shadow, + Radius = 4, + Offset = new Vector2(0f, 1f), + }, + }, + fields = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Left = height + 5 }, + }, + }; + + Scheduler.AddOnce(updateDisplay); + } + private void updateDisplay() { avatar.User = BeatmapSet?.Metadata.Author; @@ -69,45 +105,6 @@ namespace osu.Game.Overlays.BeatmapSet } } - public AuthorInfo() - { - RelativeSizeAxes = Axes.X; - Height = height; - - Children = new Drawable[] - { - new Container - { - AutoSizeAxes = Axes.Both, - CornerRadius = 4, - Masking = true, - Child = avatar = new UpdateableAvatar - { - ShowGuestOnNull = false, - Size = new Vector2(height), - }, - EdgeEffect = new EdgeEffectParameters - { - Colour = Color4.Black.Opacity(0.25f), - Type = EdgeEffectType.Shadow, - Radius = 4, - Offset = new Vector2(0f, 1f), - }, - }, - fields = new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Padding = new MarginPadding { Left = height + 5 }, - }, - }; - } - - private void load() - { - updateDisplay(); - } - private class Field : FillFlowContainer { public Field(string first, string second, FontUsage secondFont) diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index cf74c0d4d3..b81c60a5b9 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -95,7 +95,7 @@ namespace osu.Game.Overlays.BeatmapSet { private readonly OsuSpriteText value; - public string TooltipText { get; } + public LocalisableString TooltipText { get; } public LocalisableString Value { diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs index 7ad6906cea..bb87e7151b 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/FavouriteButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; @@ -28,7 +29,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private readonly IBindable localUser = new Bindable(); - public string TooltipText + public LocalisableString TooltipText { get { diff --git a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs index 6d27342049..cef623e59b 100644 --- a/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapSet/Buttons/HeaderDownloadButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -26,7 +27,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons private readonly bool noVideo; - public string TooltipText => button.Enabled.Value ? "download this beatmap" : "login to download"; + public LocalisableString TooltipText => button.Enabled.Value ? "download this beatmap" : "login to download"; private readonly IBindable localUser = new Bindable(); diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index bac658b76e..f9b8de9dba 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -8,15 +8,12 @@ using osu.Framework.Graphics.Containers; 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; namespace osu.Game.Overlays.BeatmapSet { public class Info : Container { - private const float transition_duration = 250; private const float metadata_width = 175; private const float spacing = 20; private const float base_height = 220; @@ -36,7 +33,7 @@ namespace osu.Game.Overlays.BeatmapSet public Info() { MetadataSection source, tags, genre, language; - OsuSpriteText unrankedPlaceholder; + OsuSpriteText notRankedPlaceholder; RelativeSizeAxes = Axes.X; Height = base_height; @@ -60,7 +57,7 @@ namespace osu.Game.Overlays.BeatmapSet Child = new Container { RelativeSizeAxes = Axes.Both, - Child = new MetadataSection("Description"), + Child = new MetadataSection(MetadataType.Description), }, }, new Container @@ -78,10 +75,10 @@ namespace osu.Game.Overlays.BeatmapSet 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"), + source = new MetadataSection(MetadataType.Source), + genre = new MetadataSection(MetadataType.Genre) { Width = 0.5f }, + language = new MetadataSection(MetadataType.Language) { Width = 0.5f }, + tags = new MetadataSection(MetadataType.Tags), }, }, }, @@ -102,12 +99,12 @@ namespace osu.Game.Overlays.BeatmapSet RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = 20, Horizontal = 15 }, }, - unrankedPlaceholder = new OsuSpriteText + notRankedPlaceholder = new OsuSpriteText { Anchor = Anchor.Centre, Origin = Anchor.Centre, Alpha = 0, - Text = "Unranked beatmap", + Text = "This beatmap is not ranked", Font = OsuFont.GetFont(size: 12) }, }, @@ -124,7 +121,7 @@ namespace osu.Game.Overlays.BeatmapSet 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; + notRankedPlaceholder.Alpha = setHasLeaderboard ? 0 : 1; Height = setHasLeaderboard ? 270 : base_height; }; } @@ -135,48 +132,5 @@ namespace osu.Game.Overlays.BeatmapSet successRateBackground.Colour = colourProvider.Background4; background.Colour = colourProvider.Background5; } - - private class MetadataSection : FillFlowContainer - { - private readonly TextFlowContainer textFlow; - - public string Text - { - set - { - if (string.IsNullOrEmpty(value)) - { - Hide(); - return; - } - - this.FadeIn(transition_duration); - textFlow.Clear(); - textFlow.AddText(value, s => s.Font = s.Font.With(size: 12)); - } - } - - public MetadataSection(string title) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Spacing = new Vector2(5f); - - InternalChildren = new Drawable[] - { - new OsuSpriteText - { - Text = title, - Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold), - Margin = new MarginPadding { Top = 15 }, - }, - textFlow = new OsuTextFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }, - }; - } - } } } diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs index 60fd520681..98662e5dea 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs @@ -52,7 +52,7 @@ namespace osu.Game.Overlays.BeatmapSet return; modsContainer.Add(new ModButton(new ModNoMod())); - modsContainer.AddRange(ruleset.NewValue.CreateInstance().GetAllMods().Where(m => m.Ranked).Select(m => new ModButton(m))); + modsContainer.AddRange(ruleset.NewValue.CreateInstance().GetAllMods().Where(m => m.UserPlayable).Select(m => new ModButton(m))); modsContainer.ForEach(button => button.OnSelectionChanged = selectionChanged); } diff --git a/osu.Game/Overlays/BeatmapSet/MetadataSection.cs b/osu.Game/Overlays/BeatmapSet/MetadataSection.cs new file mode 100644 index 0000000000..3648c55714 --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/MetadataSection.cs @@ -0,0 +1,115 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using 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 osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.BeatmapSet +{ + public class MetadataSection : Container + { + private readonly FillFlowContainer textContainer; + private readonly MetadataType type; + private TextFlowContainer textFlow; + + private const float transition_duration = 250; + + public MetadataSection(MetadataType type) + { + this.type = type; + + Alpha = 0; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = textContainer = new FillFlowContainer + { + Alpha = 0, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + + Margin = new MarginPadding { Top = 15 }, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new OsuSpriteText + { + Text = this.type.ToString(), + Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), + }, + }, + }, + }; + } + + public string Text + { + set + { + if (string.IsNullOrEmpty(value)) + { + this.FadeOut(transition_duration); + return; + } + + this.FadeIn(transition_duration); + + setTextAsync(value); + } + } + + private void setTextAsync(string text) + { + LoadComponentAsync(new LinkFlowContainer(s => s.Font = s.Font.With(size: 14)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Colour = Color4.White.Opacity(0.75f), + }, loaded => + { + textFlow?.Expire(); + + switch (type) + { + case MetadataType.Tags: + string[] tags = text.Split(" "); + + for (int i = 0; i <= tags.Length - 1; i++) + { + loaded.AddLink(tags[i], LinkAction.SearchBeatmapSet, tags[i]); + + if (i != tags.Length - 1) + loaded.AddText(" "); + } + + break; + + case MetadataType.Source: + loaded.AddLink(text, LinkAction.SearchBeatmapSet, text); + break; + + default: + loaded.AddText(text); + break; + } + + textContainer.Add(textFlow = loaded); + + // fade in if we haven't yet. + textContainer.FadeIn(transition_duration); + }); + } + } +} diff --git a/osu.Game/Overlays/BeatmapSet/MetadataType.cs b/osu.Game/Overlays/BeatmapSet/MetadataType.cs new file mode 100644 index 0000000000..1ab4c6887e --- /dev/null +++ b/osu.Game/Overlays/BeatmapSet/MetadataType.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. + +namespace osu.Game.Overlays.BeatmapSet +{ + public enum MetadataType + { + Tags, + Source, + Description, + Genre, + Language + } +} diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs index 9111a0cfc7..736366fb5c 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreUserSection.cs @@ -61,7 +61,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }, } }, - avatar = new UpdateableAvatar + avatar = new UpdateableAvatar(showGuestOnNull: false) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -75,7 +75,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores Offset = new Vector2(0, 2), Radius = 1, }, - ShowGuestOnNull = false, }, new FillFlowContainer { diff --git a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs index 443b3dcf01..0d383c374f 100644 --- a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs +++ b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs @@ -4,15 +4,16 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays { - public abstract class BreadcrumbControlOverlayHeader : TabControlOverlayHeader + public abstract class BreadcrumbControlOverlayHeader : TabControlOverlayHeader { - protected override OsuTabControl CreateTabControl() => new OverlayHeaderBreadcrumbControl(); + protected override OsuTabControl CreateTabControl() => new OverlayHeaderBreadcrumbControl(); - public class OverlayHeaderBreadcrumbControl : BreadcrumbControl + public class OverlayHeaderBreadcrumbControl : BreadcrumbControl { public OverlayHeaderBreadcrumbControl() { @@ -26,7 +27,7 @@ namespace osu.Game.Overlays AccentColour = colourProvider.Light2; } - protected override TabItem CreateTabItem(string value) => new ControlTabItem(value) + protected override TabItem CreateTabItem(LocalisableString? value) => new ControlTabItem(value) { AccentColour = AccentColour, }; @@ -35,7 +36,7 @@ namespace osu.Game.Overlays { protected override float ChevronSize => 8; - public ControlTabItem(string value) + public ControlTabItem(LocalisableString? value) : base(value) { RelativeSizeAxes = Axes.Y; diff --git a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs index f8e1ac0c84..cb144defbf 100644 --- a/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs +++ b/osu.Game/Overlays/Changelog/ChangelogUpdateStreamItem.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using Humanizer; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Online.API.Requests.Responses; using osuTK.Graphics; @@ -17,11 +18,11 @@ namespace osu.Game.Overlays.Changelog Width *= 2; } - protected override string MainText => Value.DisplayName; + protected override LocalisableString MainText => Value.DisplayName; - protected override string AdditionalText => Value.LatestBuild.DisplayVersion; + protected override LocalisableString AdditionalText => Value.LatestBuild.DisplayVersion; - protected override string InfoText => Value.LatestBuild.Users > 0 ? $"{"user".ToQuantity(Value.LatestBuild.Users, "N0")} online" : null; + protected override LocalisableString 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/ChangelogOverlay.cs b/osu.Game/Overlays/ChangelogOverlay.cs index e7d68853ad..a8f2e654d7 100644 --- a/osu.Game/Overlays/ChangelogOverlay.cs +++ b/osu.Game/Overlays/ChangelogOverlay.cs @@ -8,7 +8,6 @@ using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Input.Bindings; @@ -25,8 +24,6 @@ namespace osu.Game.Overlays public readonly Bindable Current = new Bindable(); - private Sample sampleBack; - private List builds; protected List Streams; @@ -41,8 +38,6 @@ namespace osu.Game.Overlays { Header.Build.BindTarget = Current; - sampleBack = audio.Samples.Get(@"UI/generic-select-soft"); - Current.BindValueChanged(e => { if (e.NewValue != null) @@ -108,7 +103,6 @@ namespace osu.Game.Overlays else { Current.Value = null; - sampleBack?.Play(); } return true; diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index f43420e35e..01cfe9a55b 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -240,12 +240,15 @@ namespace osu.Game.Overlays.Chat { get { + if (sender.Equals(User.SYSTEM_USER)) + return Array.Empty(); + List items = new List { new OsuMenuItem("View Profile", MenuItemType.Highlighted, Action) }; - if (sender.Id != api.LocalUser.Value.Id) + if (!sender.Equals(api.LocalUser.Value)) items.Add(new OsuMenuItem("Start Chat", MenuItemType.Standard, startChatAction)); return items.ToArray(); diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs index 5f9c00b36a..41e70bbfae 100644 --- a/osu.Game/Overlays/Chat/DrawableChannel.cs +++ b/osu.Game/Overlays/Chat/DrawableChannel.cs @@ -6,18 +6,18 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; -using osuTK.Graphics; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; 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.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.Sprites; +using osu.Game.Online.Chat; +using osuTK.Graphics; namespace osu.Game.Overlays.Chat { diff --git a/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs b/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs index 19c6f437b6..c0de093425 100644 --- a/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs +++ b/osu.Game/Overlays/Chat/Tabs/ChannelTabControl.cs @@ -81,9 +81,11 @@ namespace osu.Game.Overlays.Chat.Tabs RemoveItem(channel); if (SelectedTab == null) - SelectTab(selectorTab); + SelectChannelSelectorTab(); } + public void SelectChannelSelectorTab() => SelectTab(selectorTab); + protected override void SelectTab(TabItem tab) { if (tab is ChannelSelectorTabItem) diff --git a/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs b/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs index 00f46b0035..7c82420e08 100644 --- a/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs @@ -51,7 +51,7 @@ namespace osu.Game.Overlays.Chat.Tabs Child = new DelayedLoadWrapper(avatar = new ClickableAvatar(value.Users.First()) { RelativeSizeAxes = Axes.Both, - OpenOnClick = { Value = false }, + OpenOnClick = false, }) { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 285041800a..0aa6108815 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -24,13 +24,15 @@ using osu.Game.Overlays.Chat.Tabs; using osuTK.Input; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; using osu.Framework.Localisation; using osu.Game.Localisation; using osu.Game.Online; namespace osu.Game.Overlays { - public class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent + public class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent, IKeyBindingHandler { public string IconTexture => "Icons/Hexacons/messaging"; public LocalisableString Title => ChatStrings.HeaderTitle; @@ -370,6 +372,30 @@ namespace osu.Game.Overlays return base.OnKeyDown(e); } + public bool OnPressed(PlatformAction action) + { + switch (action.ActionType) + { + case PlatformActionType.TabNew: + ChannelTabControl.SelectChannelSelectorTab(); + return true; + + case PlatformActionType.TabRestore: + channelManager.JoinLastClosedChannel(); + return true; + + case PlatformActionType.DocumentClose: + channelManager.LeaveChannel(channelManager.CurrentChannel.Value); + return true; + } + + return false; + } + + public void OnReleased(PlatformAction action) + { + } + public override bool AcceptsFocus => true; protected override void OnFocus(FocusEvent e) diff --git a/osu.Game/Overlays/Comments/CommentsHeader.cs b/osu.Game/Overlays/Comments/CommentsHeader.cs index 0dd68bbd41..bf80655c3d 100644 --- a/osu.Game/Overlays/Comments/CommentsHeader.cs +++ b/osu.Game/Overlays/Comments/CommentsHeader.cs @@ -2,6 +2,8 @@ // 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.Graphics.Containers; using osu.Framework.Graphics; using osu.Framework.Bindables; @@ -66,6 +68,8 @@ namespace osu.Game.Overlays.Comments public readonly BindableBool Checked = new BindableBool(); private readonly SpriteIcon checkboxIcon; + private Sample sampleChecked; + private Sample sampleUnchecked; public ShowDeletedButton() { @@ -93,6 +97,13 @@ namespace osu.Game.Overlays.Comments }); } + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleChecked = audio.Samples.Get(@"UI/check-on"); + sampleUnchecked = audio.Samples.Get(@"UI/check-off"); + } + protected override void LoadComplete() { Checked.BindValueChanged(isChecked => checkboxIcon.Icon = isChecked.NewValue ? FontAwesome.Solid.CheckSquare : FontAwesome.Regular.Square, true); @@ -102,6 +113,12 @@ namespace osu.Game.Overlays.Comments protected override bool OnClick(ClickEvent e) { Checked.Value = !Checked.Value; + + if (Checked.Value) + sampleChecked?.Play(); + else + sampleUnchecked?.Play(); + return true; } } diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index 7c47ac655f..d94f8c4b8b 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -20,6 +20,7 @@ using System; using osu.Framework.Graphics.Shapes; using osu.Framework.Extensions.IEnumerableExtensions; using System.Collections.Specialized; +using osu.Framework.Localisation; using osu.Game.Overlays.Comments.Buttons; namespace osu.Game.Overlays.Comments @@ -395,7 +396,7 @@ namespace osu.Game.Overlays.Comments private class ParentUsername : FillFlowContainer, IHasTooltip { - public string TooltipText => getParentMessage(); + public LocalisableString TooltipText => getParentMessage(); private readonly Comment parentComment; @@ -427,7 +428,7 @@ namespace osu.Game.Overlays.Comments if (parentComment == null) return string.Empty; - return parentComment.HasMessage ? parentComment.Message : parentComment.IsDeleted ? @"deleted" : 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 index fdc8db35ab..65172aa57c 100644 --- a/osu.Game/Overlays/Comments/HeaderButton.cs +++ b/osu.Game/Overlays/Comments/HeaderButton.cs @@ -6,7 +6,6 @@ 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 { @@ -39,7 +38,6 @@ namespace osu.Game.Overlays.Comments Origin = Anchor.Centre, Margin = new MarginPadding { Horizontal = 10 } }, - new HoverClickSounds(), }); } diff --git a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs index 3314ed957a..056d4ad6f7 100644 --- a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs +++ b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.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 System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dashboard { @@ -13,13 +16,14 @@ namespace osu.Game.Overlays.Dashboard { public DashboardTitle() { - Title = "dashboard"; + Title = HomeStrings.UserTitle; Description = "view your friends and other information"; IconTexture = "Icons/Hexacons/social"; } } } + [LocalisableEnum(typeof(DashboardOverlayTabsEnumLocalisationMapper))] public enum DashboardOverlayTabs { Friends, @@ -27,4 +31,22 @@ namespace osu.Game.Overlays.Dashboard [Description("Currently Playing")] CurrentlyPlaying } + + public class DashboardOverlayTabsEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(DashboardOverlayTabs value) + { + switch (value) + { + case DashboardOverlayTabs.Friends: + return FriendsStrings.TitleCompact; + + case DashboardOverlayTabs.CurrentlyPlaying: + return @"Currently Playing"; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs index 7e902203f8..11dcb93e6f 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendsOnlineStatusItem.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using osu.Framework.Extensions; +using osu.Framework.Localisation; using osu.Game.Graphics; using osuTK.Graphics; @@ -14,9 +16,9 @@ namespace osu.Game.Overlays.Dashboard.Friends { } - protected override string MainText => Value.Status.ToString(); + protected override LocalisableString MainText => Value.Status.GetLocalisableDescription(); - protected override string AdditionalText => Value.Count.ToString(); + protected override LocalisableString AdditionalText => Value.Count.ToString(); protected override Color4 GetBarColour(OsuColour colours) { diff --git a/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs index 6f2f55a6ed..4b5a7ef066 100644 --- a/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs +++ b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs @@ -1,12 +1,38 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.Dashboard.Friends { + [LocalisableEnum(typeof(OnlineStatusEnumLocalisationMapper))] public enum OnlineStatus { All, Online, Offline } + + public class OnlineStatusEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(OnlineStatus value) + { + switch (value) + { + case OnlineStatus.All: + return SortStrings.All; + + case OnlineStatus.Online: + return UsersStrings.StatusOnline; + + case OnlineStatus.Offline: + return UsersStrings.StatusOffline; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs index 3a5f65212d..dc756e2957 100644 --- a/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs +++ b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.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 System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dashboard.Friends { @@ -9,6 +12,7 @@ namespace osu.Game.Overlays.Dashboard.Friends { } + [LocalisableEnum(typeof(UserSortCriteriaEnumLocalisationMappper))] public enum UserSortCriteria { [Description(@"Recently Active")] @@ -16,4 +20,25 @@ namespace osu.Game.Overlays.Dashboard.Friends Rank, Username } + + public class UserSortCriteriaEnumLocalisationMappper : EnumLocalisationMapper + { + public override LocalisableString Map(UserSortCriteria value) + { + switch (value) + { + case UserSortCriteria.LastVisit: + return SortStrings.LastVisit; + + case UserSortCriteria.Rank: + return SortStrings.Rank; + + case UserSortCriteria.Username: + return SortStrings.Username; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs index c905397e77..6ea4209cce 100644 --- a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Input.Bindings; using osu.Game.Overlays.Settings; @@ -28,7 +29,7 @@ namespace osu.Game.Overlays.KeyBinding private class DefaultBindingsSubsection : KeyBindingsSubsection { - protected override string Header => string.Empty; + protected override LocalisableString Header => string.Empty; public DefaultBindingsSubsection(GlobalActionContainer manager) : base(null) @@ -39,7 +40,7 @@ namespace osu.Game.Overlays.KeyBinding private class SongSelectKeyBindingSubsection : KeyBindingsSubsection { - protected override string Header => "Song Select"; + protected override LocalisableString Header => "Song Select"; public SongSelectKeyBindingSubsection(GlobalActionContainer manager) : base(null) @@ -50,7 +51,7 @@ namespace osu.Game.Overlays.KeyBinding private class InGameKeyBindingsSubsection : KeyBindingsSubsection { - protected override string Header => "In Game"; + protected override LocalisableString Header => "In Game"; public InGameKeyBindingsSubsection(GlobalActionContainer manager) : base(null) @@ -61,7 +62,7 @@ namespace osu.Game.Overlays.KeyBinding private class AudioControlKeyBindingsSubsection : KeyBindingsSubsection { - protected override string Header => "Audio"; + protected override LocalisableString Header => "Audio"; public AudioControlKeyBindingsSubsection(GlobalActionContainer manager) : base(null) @@ -72,7 +73,7 @@ namespace osu.Game.Overlays.KeyBinding private class EditorKeyBindingsSubsection : KeyBindingsSubsection { - protected override string Header => "Editor"; + protected override LocalisableString Header => "Editor"; public EditorKeyBindingsSubsection(GlobalActionContainer manager) : base(null) diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs index 0df3359c28..ef620df171 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -13,6 +14,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -27,7 +29,7 @@ namespace osu.Game.Overlays.KeyBinding public class KeyBindingRow : Container, IFilterable { private readonly object action; - private readonly IEnumerable bindings; + private readonly IEnumerable bindings; private const float transition_time = 150; @@ -62,7 +64,7 @@ namespace osu.Game.Overlays.KeyBinding public IEnumerable FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend(text.Text.ToString()); - public KeyBindingRow(object action, IEnumerable bindings) + public KeyBindingRow(object action, List bindings) { this.action = action; this.bindings = bindings; @@ -72,7 +74,7 @@ namespace osu.Game.Overlays.KeyBinding } [Resolved] - private KeyBindingStore store { get; set; } + private RealmContextFactory realmFactory { get; set; } [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -153,7 +155,8 @@ namespace osu.Game.Overlays.KeyBinding { var button = buttons[i++]; button.UpdateKeyCombination(d); - store.Update(button.KeyBinding); + + updateStoreFromButton(button); } isDefault.Value = true; @@ -314,7 +317,7 @@ namespace osu.Game.Overlays.KeyBinding { if (bindTarget != null) { - store.Update(bindTarget.KeyBinding); + updateStoreFromButton(bindTarget); updateIsDefaultValue(); @@ -361,6 +364,17 @@ namespace osu.Game.Overlays.KeyBinding if (bindTarget != null) bindTarget.IsBinding = true; } + private void updateStoreFromButton(KeyButton button) + { + using (var usage = realmFactory.GetForWrite()) + { + var binding = usage.Realm.Find(((IHasGuidPrimaryKey)button.KeyBinding).ID); + binding.KeyCombinationString = button.KeyBinding.KeyCombinationString; + + usage.Commit(); + } + } + private void updateIsDefaultValue() { isDefault.Value = bindings.Select(b => b.KeyCombination).SequenceEqual(Defaults); @@ -386,7 +400,7 @@ namespace osu.Game.Overlays.KeyBinding public class KeyButton : Container { - public readonly Framework.Input.Bindings.KeyBinding KeyBinding; + public readonly RealmKeyBinding KeyBinding; private readonly Box box; public readonly OsuSpriteText Text; @@ -408,8 +422,11 @@ namespace osu.Game.Overlays.KeyBinding } } - public KeyButton(Framework.Input.Bindings.KeyBinding keyBinding) + public KeyButton(RealmKeyBinding keyBinding) { + if (keyBinding.IsManaged) + throw new ArgumentException("Key binding should not be attached as we make temporary changes", nameof(keyBinding)); + KeyBinding = keyBinding; Margin = new MarginPadding(padding); @@ -478,7 +495,7 @@ namespace osu.Game.Overlays.KeyBinding public void UpdateKeyCombination(KeyCombination newCombination) { - if ((KeyBinding as DatabasedKeyBinding)?.RulesetID != null && !KeyBindingStore.CheckValidForGameplay(newCombination)) + if (KeyBinding.RulesetID != null && !RealmKeyBindingStore.CheckValidForGameplay(newCombination)) return; KeyBinding.KeyCombination = newCombination; diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs b/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs index 5e1f9d8f75..1fdc1b6574 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs @@ -6,8 +6,9 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Game.Database; using osu.Game.Graphics.UserInterface; -using osu.Game.Input; +using osu.Game.Input.Bindings; using osu.Game.Overlays.Settings; using osu.Game.Rulesets; using osuTK; @@ -31,16 +32,21 @@ namespace osu.Game.Overlays.KeyBinding } [BackgroundDependencyLoader] - private void load(KeyBindingStore store) + private void load(RealmContextFactory realmFactory) { - var bindings = store.Query(Ruleset?.ID, variant); + var rulesetId = Ruleset?.ID; + + List bindings; + + using (var usage = realmFactory.GetForRead()) + bindings = usage.Realm.All().Where(b => b.RulesetID == rulesetId && b.Variant == variant).Detach(); foreach (var defaultGroup in Defaults.GroupBy(d => d.Action)) { int intKey = (int)defaultGroup.Key; // one row per valid action. - Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => ((int)b.Action).Equals(intKey))) + Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => b.ActionInt.Equals(intKey)).ToList()) { AllowMainMouseButtons = Ruleset != null, Defaults = defaultGroup.Select(d => d.KeyCombination) diff --git a/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs b/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs index 861d59c8f4..7618a42282 100644 --- a/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs +++ b/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.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 osu.Framework.Localisation; using osu.Game.Rulesets; namespace osu.Game.Overlays.KeyBinding { public class VariantBindingsSubsection : KeyBindingsSubsection { - protected override string Header { get; } + protected override LocalisableString Header { get; } public VariantBindingsSubsection(RulesetInfo ruleset, int variant) : base(variant) diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index 0feae16b68..e15625a4b3 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -213,7 +213,7 @@ namespace osu.Game.Overlays innerSpin.Spin(20000, RotationDirection.Clockwise); outerSpin.Spin(40000, RotationDirection.Clockwise); - using (BeginDelayedSequence(200, true)) + using (BeginDelayedSequence(200)) { disc.FadeIn(initial_duration) .ScaleTo(1f, initial_duration * 2, Easing.OutElastic); @@ -221,7 +221,7 @@ namespace osu.Game.Overlays particleContainer.FadeIn(initial_duration); outerSpin.FadeTo(0.1f, initial_duration * 2); - using (BeginDelayedSequence(initial_duration + 200, true)) + using (BeginDelayedSequence(initial_duration + 200)) { backgroundStrip.FadeIn(step_duration); leftStrip.ResizeWidthTo(1f, step_duration, Easing.OutQuint); diff --git a/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs b/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs index 78cd9bdae5..db76581108 100644 --- a/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs @@ -12,7 +12,7 @@ namespace osu.Game.Overlays.Mods base.OnModSelected(mod); foreach (var section in ModSectionsContainer.Children) - section.DeselectTypes(mod.IncompatibleMods, true); + section.DeselectTypes(mod.IncompatibleMods, true, mod); } } } diff --git a/osu.Game/Overlays/Mods/ModButton.cs b/osu.Game/Overlays/Mods/ModButton.cs index 5e3733cd5e..572ff0d1aa 100644 --- a/osu.Game/Overlays/Mods/ModButton.cs +++ b/osu.Game/Overlays/Mods/ModButton.cs @@ -14,6 +14,7 @@ using System; using System.Linq; using osu.Framework.Graphics.Cursor; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; @@ -34,7 +35,7 @@ namespace osu.Game.Overlays.Mods /// public Action SelectionChanged; - public string TooltipText => (SelectedMod?.Description ?? Mods.FirstOrDefault()?.Description) ?? string.Empty; + public LocalisableString TooltipText => (SelectedMod?.Description ?? Mods.FirstOrDefault()?.Description) ?? string.Empty; private const Easing mod_switch_easing = Easing.InOutSine; private const double mod_switch_duration = 120; @@ -90,7 +91,7 @@ namespace osu.Game.Overlays.Mods backgroundIcon.Mod = newSelection; - using (BeginDelayedSequence(mod_switch_duration, true)) + using (BeginDelayedSequence(mod_switch_duration)) { foregroundIcon .RotateTo(-rotate_angle * direction) @@ -302,7 +303,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.TopCentre, Font = OsuFont.GetFont(size: 18) }, - new HoverClickSounds(buttons: new[] { MouseButton.Left, MouseButton.Right }) + new HoverSounds() }; Mod = mod; diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs index aa8a5efd39..6e289dc8aa 100644 --- a/osu.Game/Overlays/Mods/ModSection.cs +++ b/osu.Game/Overlays/Mods/ModSection.cs @@ -159,12 +159,16 @@ namespace osu.Game.Overlays.Mods /// /// The types of s which should be deselected. /// Whether the deselection should happen immediately. Should only be used when required to ensure correct selection flow. - public void DeselectTypes(IEnumerable modTypes, bool immediate = false) + /// If this deselection is triggered by a user selection, this should contain the newly selected type. This type will never be deselected, even if it matches one provided in . + public void DeselectTypes(IEnumerable modTypes, bool immediate = false, Mod newSelection = null) { foreach (var button in Buttons) { if (button.SelectedMod == null) continue; + if (button.SelectedMod == newSelection) + continue; + foreach (var type in modTypes) { if (type.IsInstanceOfType(button.SelectedMod)) diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index e31e307d4d..793bb79318 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -37,9 +37,6 @@ namespace osu.Game.Overlays.Mods protected readonly TriangleButton CustomiseButton; protected readonly TriangleButton CloseButton; - protected readonly Drawable MultiplierSection; - protected readonly OsuSpriteText MultiplierLabel; - protected readonly FillFlowContainer FooterContainer; protected override bool BlockNonPositionalInput => false; @@ -131,7 +128,7 @@ namespace osu.Game.Overlays.Mods RowDimensions = new[] { new Dimension(GridSizeMode.Absolute, 90), - new Dimension(GridSizeMode.Distributed), + new Dimension(), new Dimension(GridSizeMode.AutoSize), }, Content = new[] @@ -324,30 +321,6 @@ namespace osu.Game.Overlays.Mods Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, }, - MultiplierSection = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(footer_button_spacing / 2, 0), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Children = new Drawable[] - { - 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. - }, - }, - }, } } }, @@ -361,11 +334,8 @@ namespace osu.Game.Overlays.Mods } [BackgroundDependencyLoader(true)] - private void load(OsuColour colours, AudioManager audio, OsuGameBase osu) + private void load(AudioManager audio, OsuGameBase osu) { - LowMultiplierColour = colours.Red; - HighMultiplierColour = colours.Green; - availableMods = osu.AvailableMods.GetBoundCopy(); sampleOn = audio.Samples.Get(@"UI/check-on"); @@ -459,7 +429,7 @@ namespace osu.Game.Overlays.Mods if (!Stacked) modEnumeration = ModUtils.FlattenMods(modEnumeration); - section.Mods = modEnumeration.Select(getValidModOrNull).Where(m => m != null); + section.Mods = modEnumeration.Select(getValidModOrNull).Where(m => m != null).Select(m => m.DeepClone()); } updateSelectedButtons(); @@ -495,26 +465,6 @@ namespace osu.Game.Overlays.Mods foreach (var section in ModSectionsContainer.Children) section.UpdateSelectedButtons(selectedMods); - - updateMultiplier(); - } - - private void updateMultiplier() - { - var multiplier = 1.0; - - foreach (var mod in SelectedMods.Value) - { - multiplier *= mod.ScoreMultiplier; - } - - MultiplierLabel.Text = $"{multiplier:N2}x"; - if (multiplier > 1.0) - MultiplierLabel.FadeColour(HighMultiplierColour, 200); - else if (multiplier < 1.0) - MultiplierLabel.FadeColour(LowMultiplierColour, 200); - else - MultiplierLabel.FadeColour(Color4.White, 200); } private void modButtonPressed(Mod selectedMod) diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs index 94bfd62c32..56c54425bd 100644 --- a/osu.Game/Overlays/News/NewsHeader.cs +++ b/osu.Game/Overlays/News/NewsHeader.cs @@ -13,7 +13,7 @@ namespace osu.Game.Overlays.News public Action ShowFrontPage; - private readonly Bindable article = new Bindable(null); + private readonly Bindable article = new Bindable(); public NewsHeader() { diff --git a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs index 9e397e78c8..35cd6eb03b 100644 --- a/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs +++ b/osu.Game/Overlays/News/Sidebar/NewsSidebar.cs @@ -6,87 +6,36 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Game.Online.API.Requests.Responses; -using osu.Framework.Graphics.Shapes; using osuTK; using System.Linq; -using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.News.Sidebar { - public class NewsSidebar : CompositeDrawable + public class NewsSidebar : OverlaySidebar { [Cached] public readonly Bindable Metadata = new Bindable(); private FillFlowContainer monthsFlow; - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + protected override Drawable CreateContent() => new FillFlowContainer { - RelativeSizeAxes = Axes.Y; - Width = 250; - InternalChildren = new Drawable[] + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 20), + Children = new Drawable[] { - new Box + new YearsPanel(), + monthsFlow = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background4 - }, - new Box - { - RelativeSizeAxes = Axes.Y, - Width = OsuScrollContainer.SCROLL_BAR_HEIGHT, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Colour = colourProvider.Background3, - Alpha = 0.5f - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = -3 }, // Compensate for scrollbar margin - Child = new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Child = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Right = 3 }, // Addeded 3px back - Child = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding - { - Vertical = 20, - Left = 50, - Right = 30 - }, - Child = new FillFlowContainer - { - Direction = FillDirection.Vertical, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0, 20), - Children = new Drawable[] - { - new YearsPanel(), - monthsFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.Y, - RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 10) - } - } - } - } - } - } + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10) } - }; - } + } + }; protected override void LoadComplete() { diff --git a/osu.Game/Overlays/OnlineOverlay.cs b/osu.Game/Overlays/OnlineOverlay.cs index de33e4a1bc..a610511398 100644 --- a/osu.Game/Overlays/OnlineOverlay.cs +++ b/osu.Game/Overlays/OnlineOverlay.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.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -13,7 +14,9 @@ namespace osu.Game.Overlays { protected override Container Content => content; + [Cached] protected readonly OverlayScrollContainer ScrollFlow; + protected readonly LoadingLayer Loading; private readonly Container content; diff --git a/osu.Game/Overlays/OverlayColourProvider.cs b/osu.Game/Overlays/OverlayColourProvider.cs index abd1e43f25..008e7696e1 100644 --- a/osu.Game/Overlays/OverlayColourProvider.cs +++ b/osu.Game/Overlays/OverlayColourProvider.cs @@ -14,6 +14,7 @@ namespace osu.Game.Overlays public static OverlayColourProvider Red { get; } = new OverlayColourProvider(OverlayColourScheme.Red); public static OverlayColourProvider Pink { get; } = new OverlayColourProvider(OverlayColourScheme.Pink); public static OverlayColourProvider Orange { get; } = new OverlayColourProvider(OverlayColourScheme.Orange); + public static OverlayColourProvider Lime { get; } = new OverlayColourProvider(OverlayColourScheme.Lime); public static OverlayColourProvider Green { get; } = new OverlayColourProvider(OverlayColourScheme.Green); public static OverlayColourProvider Purple { get; } = new OverlayColourProvider(OverlayColourScheme.Purple); public static OverlayColourProvider Blue { get; } = new OverlayColourProvider(OverlayColourScheme.Blue); @@ -51,7 +52,7 @@ namespace osu.Game.Overlays 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 + // See https://github.com/ppy/osu-web/blob/5a536d217a21582aad999db50a981003d3ad5659/app/helpers.php#L1620-L1628 private static float getBaseHue(OverlayColourScheme colourScheme) { switch (colourScheme) @@ -66,10 +67,13 @@ namespace osu.Game.Overlays return 333 / 360f; case OverlayColourScheme.Orange: - return 46 / 360f; + return 45 / 360f; + + case OverlayColourScheme.Lime: + return 90 / 360f; case OverlayColourScheme.Green: - return 115 / 360f; + return 125 / 360f; case OverlayColourScheme.Purple: return 255 / 360f; @@ -85,6 +89,7 @@ namespace osu.Game.Overlays Red, Pink, Orange, + Lime, Green, Purple, Blue diff --git a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs index 87b9d89d4d..c2268ff43c 100644 --- a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs +++ b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs @@ -11,6 +11,10 @@ using osu.Game.Graphics.UserInterface; using osu.Framework.Allocation; using osuTK.Graphics; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; +using System; +using osu.Game.Resources.Localisation.Web; +using osu.Framework.Extensions; namespace osu.Game.Overlays { @@ -56,7 +60,7 @@ namespace osu.Game.Overlays [Resolved] private OverlayColourProvider colourProvider { get; set; } - public string TooltipText => $@"{Value} view"; + public LocalisableString TooltipText => Value.GetLocalisableDescription(); private readonly SpriteIcon icon; @@ -97,10 +101,32 @@ namespace osu.Game.Overlays } } + [LocalisableEnum(typeof(OverlayPanelDisplayStyleEnumLocalisationMapper))] public enum OverlayPanelDisplayStyle { Card, List, Brick } + + public class OverlayPanelDisplayStyleEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(OverlayPanelDisplayStyle value) + { + switch (value) + { + case OverlayPanelDisplayStyle.Card: + return UsersStrings.ViewModeCard; + + case OverlayPanelDisplayStyle.List: + return UsersStrings.ViewModeList; + + case OverlayPanelDisplayStyle.Brick: + return UsersStrings.ViewModeBrick; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs index 0004719b87..ca5fc90027 100644 --- a/osu.Game/Overlays/OverlayScrollContainer.cs +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -11,6 +11,8 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; using osuTK; using osuTK.Graphics; @@ -84,6 +86,7 @@ namespace osu.Game.Overlays private readonly Box background; public ScrollToTopButton() + : base(HoverSampleSet.ScrollToTop) { Size = new Vector2(50); Alpha = 0; @@ -116,7 +119,7 @@ namespace osu.Game.Overlays } }); - TooltipText = "Scroll to top"; + TooltipText = CommonStrings.ButtonsBackToTop; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/OverlaySidebar.cs b/osu.Game/Overlays/OverlaySidebar.cs new file mode 100644 index 0000000000..468b5b6eb3 --- /dev/null +++ b/osu.Game/Overlays/OverlaySidebar.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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays +{ + public abstract class OverlaySidebar : CompositeDrawable + { + private readonly Box sidebarBackground; + private readonly Box scrollbarBackground; + + protected OverlaySidebar() + { + RelativeSizeAxes = Axes.Y; + Width = 250; + InternalChildren = new Drawable[] + { + sidebarBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + scrollbarBackground = new Box + { + RelativeSizeAxes = Axes.Y, + Width = OsuScrollContainer.SCROLL_BAR_HEIGHT, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Alpha = 0.5f + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = -3 }, // Compensate for scrollbar margin + Child = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 3 }, // Addeded 3px back + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding + { + Vertical = 20, + Left = 50, + Right = 30 + }, + Child = CreateContent() + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + sidebarBackground.Colour = colourProvider.Background4; + scrollbarBackground.Colour = colourProvider.Background3; + } + + [NotNull] + protected virtual Drawable CreateContent() => Empty(); + } +} diff --git a/osu.Game/Overlays/OverlaySortTabControl.cs b/osu.Game/Overlays/OverlaySortTabControl.cs index 0ebabd424f..d4dde0db3f 100644 --- a/osu.Game/Overlays/OverlaySortTabControl.cs +++ b/osu.Game/Overlays/OverlaySortTabControl.cs @@ -18,6 +18,7 @@ using JetBrains.Annotations; using System; using osu.Framework.Extensions; using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays { @@ -54,7 +55,7 @@ namespace osu.Game.Overlays Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), - Text = @"Sort by" + Text = SortStrings.Default }, CreateControl().With(c => { @@ -143,10 +144,12 @@ namespace osu.Game.Overlays Anchor = Anchor.Centre, Origin = Anchor.Centre, Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), - Text = (value as Enum)?.GetDescription() ?? value.ToString() + Text = (value as Enum)?.GetLocalisableDescription() ?? value.ToString() } } }); + + AddInternal(new HoverClickSounds()); } protected override void LoadComplete() diff --git a/osu.Game/Overlays/OverlayStreamItem.cs b/osu.Game/Overlays/OverlayStreamItem.cs index 7f8559e7de..56502ff70f 100644 --- a/osu.Game/Overlays/OverlayStreamItem.cs +++ b/osu.Game/Overlays/OverlayStreamItem.cs @@ -12,6 +12,7 @@ using osu.Framework.Allocation; using osu.Game.Graphics.Sprites; using osu.Game.Graphics; using osuTK.Graphics; +using osu.Framework.Localisation; namespace osu.Game.Overlays { @@ -39,9 +40,9 @@ namespace osu.Game.Overlays protected OverlayStreamItem(T value) : base(value) { - Height = 60; - Width = 100; - Padding = new MarginPadding(5); + Height = 50; + Width = 90; + Margin = new MarginPadding(5); } [BackgroundDependencyLoader] @@ -88,11 +89,11 @@ namespace osu.Game.Overlays SelectedItem.BindValueChanged(_ => updateState(), true); } - protected abstract string MainText { get; } + protected abstract LocalisableString MainText { get; } - protected abstract string AdditionalText { get; } + protected abstract LocalisableString AdditionalText { get; } - protected virtual string InfoText => string.Empty; + protected virtual LocalisableString InfoText => string.Empty; protected abstract Color4 GetBarColour(OsuColour colours); diff --git a/osu.Game/Overlays/OverlayTabControl.cs b/osu.Game/Overlays/OverlayTabControl.cs index a1cbf2c1e7..578cd703c7 100644 --- a/osu.Game/Overlays/OverlayTabControl.cs +++ b/osu.Game/Overlays/OverlayTabControl.cs @@ -99,7 +99,7 @@ namespace osu.Game.Overlays ExpandedSize = 5f, CollapsedSize = 0 }, - new HoverClickSounds() + new HoverClickSounds(HoverSampleSet.TabSelect) }; } diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs index 62ebee7677..4195b0b2f1 100644 --- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Game.Overlays.Profile.Header.Components; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK; @@ -119,12 +120,12 @@ namespace osu.Game.Overlays.Profile.Header { hiddenDetailGlobal = new OverlinedInfoContainer { - Title = "Global Ranking", + Title = UsersStrings.ShowRankGlobalSimple, LineColour = colourProvider.Highlight1 }, hiddenDetailCountry = new OverlinedInfoContainer { - Title = "Country Ranking", + Title = UsersStrings.ShowRankCountrySimple, LineColour = colourProvider.Highlight1 }, } diff --git a/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs b/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs index 7eed4d3b6b..74f3ed846b 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DrawableBadge.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; using osu.Game.Users; using osuTK; @@ -42,6 +43,6 @@ namespace osu.Game.Overlays.Profile.Header.Components InternalChild.FadeInFromZero(200); } - public string TooltipText => badge.Description; + public LocalisableString TooltipText => badge.Description; } } diff --git a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs index 29e13e4f51..b4a5d5e31b 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs @@ -2,10 +2,15 @@ // 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.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Overlays.Profile.Header.Components @@ -14,21 +19,32 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly BindableBool DetailsVisible = new BindableBool(); - public override string TooltipText => DetailsVisible.Value ? "collapse" : "expand"; + public override LocalisableString TooltipText => DetailsVisible.Value ? CommonStrings.ButtonsCollapse : CommonStrings.ButtonsExpand; private SpriteIcon icon; + private Sample sampleOpen; + private Sample sampleClose; + + protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); public ExpandDetailsButton() { - Action = () => DetailsVisible.Toggle(); + Action = () => + { + DetailsVisible.Toggle(); + (DetailsVisible.Value ? sampleOpen : sampleClose)?.Play(); + }; } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider, AudioManager audio) { IdleColour = colourProvider.Background2; HoverColour = colourProvider.Background2.Lighten(0.2f); + sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); + sampleClose = audio.Samples.Get(@"UI/dropdown-close"); + Child = icon = new SpriteIcon { Anchor = Anchor.Centre, diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs index bd8aa7b3bd..8f66120055 100644 --- a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs @@ -4,6 +4,8 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -12,7 +14,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override string TooltipText => "followers"; + public override LocalisableString TooltipText => FriendsStrings.ButtonsDisabled; protected override IconUsage Icon => FontAwesome.Solid.User; diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs index 29471375b5..1deed1a748 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs @@ -8,8 +8,10 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -18,13 +20,13 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public string TooltipText { get; } + public LocalisableString TooltipText { get; private set; } private OsuSpriteText levelText; public LevelBadge() { - TooltipText = "level"; + TooltipText = UsersStrings.ShowStatsLevel("0"); } [BackgroundDependencyLoader] @@ -52,6 +54,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private void updateLevel(User user) { levelText.Text = user?.Statistics?.Level.Current.ToString() ?? "0"; + TooltipText = UsersStrings.ShowStatsLevel(user?.Statistics?.Level.Current.ToString()); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs index c97df3bc4d..ed89d78a10 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs @@ -6,9 +6,11 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK.Graphics; @@ -18,14 +20,14 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public string TooltipText { get; } + public LocalisableString TooltipText { get; } private Bar levelProgressBar; private OsuSpriteText levelProgressText; public LevelProgressBar() { - TooltipText = "progress to next level"; + TooltipText = UsersStrings.ShowStatsLevelProgress; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs index b4d7c9a05c..5cdf3a5ef9 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs @@ -4,6 +4,8 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -12,7 +14,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override string TooltipText => "mapping subscribers"; + public override LocalisableString TooltipText => FollowsStrings.MappingFollowers; protected override IconUsage Icon => FontAwesome.Solid.Bell; diff --git a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs index 228765ee1a..07f1f1c3ed 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs @@ -5,8 +5,10 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Online.API; using osu.Game.Online.Chat; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK; @@ -16,7 +18,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override string TooltipText => "send message"; + public override LocalisableString TooltipText => UsersStrings.CardSendMessage; [Resolved(CanBeNull = true)] private ChannelManager channelManager { get; set; } diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs b/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs index 9f56a34aa6..8f1bbc4097 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs +++ b/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK.Graphics; @@ -16,7 +17,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private readonly OsuSpriteText title; private readonly OsuSpriteText content; - public string Title + public LocalisableString Title { set => title.Text = value; } diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs index be96840217..1a40944632 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs +++ b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs @@ -6,6 +6,8 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -14,7 +16,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public string TooltipText { get; set; } + public LocalisableString TooltipText { get; set; } private OverlinedInfoContainer info; @@ -30,7 +32,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { InternalChild = info = new OverlinedInfoContainer { - Title = "Total Play Time", + Title = UsersStrings.ShowStatsPlayTime, LineColour = colourProvider.Highlight1, }; diff --git a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs index e4c0fe3a5a..14eeb4e5f0 100644 --- a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs +++ b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK; @@ -57,7 +58,7 @@ namespace osu.Game.Overlays.Profile.Header.Components ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Distributed) + new Dimension() }, Content = new[] { @@ -68,7 +69,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = @"formerly known as", + Text = UsersStrings.ShowPreviousUsernames, Font = OsuFont.GetFont(size: 10, italics: true) } }, diff --git a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs index ad91e491ef..6bf356c0ff 100644 --- a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs +++ b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs @@ -9,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -27,7 +28,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "No recent plays", + Text = UsersStrings.ShowExtraUnranked, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular) }); } @@ -74,7 +75,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private class RankGraphTooltip : UserGraphTooltip { public RankGraphTooltip() - : base("Global Ranking") + : base(UsersStrings.ShowRankGlobalSimple) { } diff --git a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs index d581e2750c..77f0378762 100644 --- a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs +++ b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs @@ -8,7 +8,9 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Header.Components { @@ -18,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private readonly FillFlowContainer iconContainer; private readonly CircularContainer content; - public string TooltipText => "osu!supporter"; + public LocalisableString TooltipText => UsersStrings.ShowIsSupporter; public int SupportLevel { diff --git a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs index 574aef02fd..6214e504b0 100644 --- a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs @@ -11,6 +11,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.Leaderboards; using osu.Game.Overlays.Profile.Header.Components; +using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; using osu.Game.Users; using osuTK; @@ -100,7 +101,7 @@ namespace osu.Game.Overlays.Profile.Header }, medalInfo = new OverlinedInfoContainer { - Title = "Medals", + Title = UsersStrings.ShowStatsMedals, LineColour = colours.GreenLight, }, ppInfo = new OverlinedInfoContainer @@ -151,12 +152,12 @@ namespace osu.Game.Overlays.Profile.Header { detailGlobalRank = new OverlinedInfoContainer(true, 110) { - Title = "Global Ranking", + Title = UsersStrings.ShowRankGlobalSimple, LineColour = colourProvider.Highlight1, }, detailCountryRank = new OverlinedInfoContainer(false, 110) { - Title = "Country Ranking", + Title = UsersStrings.ShowRankCountrySimple, LineColour = colourProvider.Highlight1, }, } diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index e0642d650c..b64dba62e3 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -7,11 +7,13 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Overlays.Profile.Header.Components; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osu.Game.Users.Drawables; using osuTK; @@ -58,13 +60,11 @@ namespace osu.Game.Overlays.Profile.Header Origin = Anchor.CentreLeft, Children = new Drawable[] { - avatar = new UpdateableAvatar + avatar = new UpdateableAvatar(openOnClick: false, showGuestOnNull: false) { Size = new Vector2(avatar_size), Masking = true, CornerRadius = avatar_size * 0.25f, - OpenOnClick = { Value = false }, - ShowGuestOnNull = false, }, new Container { @@ -181,19 +181,19 @@ namespace osu.Game.Overlays.Profile.Header if (user?.Statistics != null) { - userStats.Add(new UserStatsLine("Ranked Score", user.Statistics.RankedScore.ToString("#,##0"))); - 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"))); - userStats.Add(new UserStatsLine("Maximum Combo", user.Statistics.MaxCombo.ToString("#,##0"))); - userStats.Add(new UserStatsLine("Replays Watched by Others", user.Statistics.ReplaysWatched.ToString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsRankedScore, user.Statistics.RankedScore.ToString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsHitAccuracy, user.Statistics.DisplayAccuracy)); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsPlayCount, user.Statistics.PlayCount.ToString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsTotalScore, user.Statistics.TotalScore.ToString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsTotalHits, user.Statistics.TotalHits.ToString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsMaximumCombo, user.Statistics.MaxCombo.ToString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsReplaysWatchedByOthers, user.Statistics.ReplaysWatched.ToString("#,##0"))); } } private class UserStatsLine : Container { - public UserStatsLine(string left, string right) + public UserStatsLine(LocalisableString left, string right) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index c947ef0781..815f9fdafc 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -7,12 +7,14 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Overlays.Profile.Header; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile { - public class ProfileHeader : TabControlOverlayHeader + public class ProfileHeader : TabControlOverlayHeader { private UserCoverBackground coverContainer; @@ -27,8 +29,8 @@ namespace osu.Game.Overlays.Profile User.ValueChanged += e => updateDisplay(e.NewValue); - TabControl.AddItem("info"); - TabControl.AddItem("modding"); + TabControl.AddItem(LayoutStrings.HeaderUsersShow); + TabControl.AddItem(LayoutStrings.HeaderUsersModding); centreHeaderContainer.DetailsVisible.BindValueChanged(visible => detailHeaderContainer.Expanded = visible.NewValue, true); } @@ -96,7 +98,7 @@ namespace osu.Game.Overlays.Profile { public ProfileHeaderTitle() { - Title = "player info"; + Title = PageTitleStrings.MainUsersControllerDefault; IconTexture = "Icons/Hexacons/profile"; } } diff --git a/osu.Game/Overlays/Profile/ProfileSection.cs b/osu.Game/Overlays/Profile/ProfileSection.cs index 21f7921da6..1a5f562fff 100644 --- a/osu.Game/Overlays/Profile/ProfileSection.cs +++ b/osu.Game/Overlays/Profile/ProfileSection.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Sprites; @@ -17,7 +18,7 @@ namespace osu.Game.Overlays.Profile { public abstract class ProfileSection : Container { - public abstract string Title { get; } + public abstract LocalisableString Title { get; } public abstract string Identifier { get; } diff --git a/osu.Game/Overlays/Profile/Sections/AboutSection.cs b/osu.Game/Overlays/Profile/Sections/AboutSection.cs index 1bc01cfd9e..d0d9362fd2 100644 --- a/osu.Game/Overlays/Profile/Sections/AboutSection.cs +++ b/osu.Game/Overlays/Profile/Sections/AboutSection.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 osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.Profile.Sections { public class AboutSection : ProfileSection { - public override string Title => "me!"; + public override LocalisableString Title => UsersStrings.ShowExtraMeTitle; - public override string Identifier => "me"; + public override string Identifier => @"me"; } } diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index fe9c710bcc..ec64371a5d 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -19,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps private const float panel_padding = 10f; private readonly BeatmapSetType type; - public PaginatedBeatmapContainer(BeatmapSetType type, Bindable user, string headerText) + public PaginatedBeatmapContainer(BeatmapSetType type, Bindable user, LocalisableString headerText) : base(user, headerText) { this.type = type; diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs index c283de42f3..843ab531be 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs @@ -1,26 +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.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Profile.Sections.Beatmaps; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { public class BeatmapsSection : ProfileSection { - public override string Title => "Beatmaps"; + public override LocalisableString Title => UsersStrings.ShowExtraBeatmapsTitle; - public override string Identifier => "beatmaps"; + public override string Identifier => @"beatmaps"; public BeatmapsSection() { Children = new[] { - 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.Favourite, User, UsersStrings.ShowExtraBeatmapsFavouriteTitle), + new PaginatedBeatmapContainer(BeatmapSetType.RankedAndApproved, User, UsersStrings.ShowExtraBeatmapsRankedTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Loved, User, UsersStrings.ShowExtraBeatmapsLovedTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Unranked, User, UsersStrings.ShowExtraBeatmapsPendingTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, UsersStrings.ShowExtraBeatmapsGraveyardTitle) }; } } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs index a48036dcbb..986b3d9874 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Users; using static osu.Game.Users.User; @@ -18,9 +19,9 @@ namespace osu.Game.Overlays.Profile.Sections.Historical /// /// Text describing the value being plotted on the graph, which will be displayed as a prefix to the value in the history graph tooltip. /// - protected abstract string GraphCounterName { get; } + protected abstract LocalisableString GraphCounterName { get; } - protected ChartProfileSubsection(Bindable user, string headerText) + protected ChartProfileSubsection(Bindable user, LocalisableString headerText) : base(user, headerText) { } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs index 6d6ff32aac..a419bef233 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.Sprites; using osuTK; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections.Historical { @@ -143,7 +144,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private class PlayCountText : CompositeDrawable, IHasTooltip { - public string TooltipText => "times played"; + public LocalisableString TooltipText => UsersStrings.ShowExtraHistoricalMostPlayedCount; public PlayCountText(int playCount) { diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs index eeb14e5e4f..d0979526da 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs @@ -9,6 +9,7 @@ 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.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Sections.Historical @@ -16,7 +17,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical public class PaginatedMostPlayedBeatmapContainer : PaginatedProfileSubsection { public PaginatedMostPlayedBeatmapContainer(Bindable user) - : base(user, "Most Played Beatmaps") + : base(user, UsersStrings.ShowExtraHistoricalMostPlayedTitle) { ItemsPerPage = 5; } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs index dfd29db693..83c005970e 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using static osu.Game.Users.User; @@ -9,10 +11,10 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { public class PlayHistorySubsection : ChartProfileSubsection { - protected override string GraphCounterName => "Plays"; + protected override LocalisableString GraphCounterName => UsersStrings.ShowExtraHistoricalMonthlyPlaycountsCountLabel; public PlayHistorySubsection(Bindable user) - : base(user, "Play History") + : base(user, UsersStrings.ShowExtraHistoricalMonthlyPlaycountsTitle) { } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs index eb5deb2802..e6fd09301e 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs @@ -12,6 +12,7 @@ using osu.Framework.Allocation; using osu.Game.Graphics; using osu.Framework.Graphics.Shapes; using osuTK; +using osu.Framework.Localisation; using static osu.Game.Users.User; namespace osu.Game.Overlays.Profile.Sections.Historical @@ -42,7 +43,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private readonly Container rowLinesContainer; private readonly Container columnLinesContainer; - public ProfileLineChart(string graphCounterName) + public ProfileLineChart(LocalisableString graphCounterName) { RelativeSizeAxes = Axes.X; Height = 250; diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs index 1c28306f17..76d5f73bd7 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using static osu.Game.Users.User; @@ -9,10 +11,10 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { public class ReplaysSubsection : ChartProfileSubsection { - protected override string GraphCounterName => "Replays Watched"; + protected override LocalisableString GraphCounterName => UsersStrings.ShowExtraHistoricalReplaysWatchedCountsCountLabel; public ReplaysSubsection(Bindable user) - : base(user, "Replays Watched History") + : base(user, UsersStrings.ShowExtraHistoricalReplaysWatchedCountsTitle) { } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs index 52831b4243..d626c63fed 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs @@ -5,13 +5,14 @@ using System; using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; +using osu.Framework.Localisation; using static osu.Game.Users.User; namespace osu.Game.Overlays.Profile.Sections.Historical { public class UserHistoryGraph : UserGraph { - private readonly string tooltipCounterName; + private readonly LocalisableString tooltipCounterName; [CanBeNull] public UserHistoryCount[] Values @@ -19,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical set => Data = value?.Select(v => new KeyValuePair(v.Date, v.Count)).ToArray(); } - public UserHistoryGraph(string tooltipCounterName) + public UserHistoryGraph(LocalisableString tooltipCounterName) { this.tooltipCounterName = tooltipCounterName; } @@ -40,9 +41,9 @@ namespace osu.Game.Overlays.Profile.Sections.Historical protected class HistoryGraphTooltip : UserGraphTooltip { - private readonly string tooltipCounterName; + private readonly LocalisableString tooltipCounterName; - public HistoryGraphTooltip(string tooltipCounterName) + public HistoryGraphTooltip(LocalisableString tooltipCounterName) : base(tooltipCounterName) { this.tooltipCounterName = tooltipCounterName; @@ -61,7 +62,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private class TooltipDisplayContent { - public string Name; + public LocalisableString Name; public string Count; public string Date; } diff --git a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs index 4fbb7fc7d7..203844b6b5 100644 --- a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs +++ b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs @@ -2,17 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Profile.Sections.Historical; using osu.Game.Overlays.Profile.Sections.Ranks; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { public class HistoricalSection : ProfileSection { - public override string Title => "Historical"; + public override LocalisableString Title => UsersStrings.ShowExtraHistoricalTitle; - public override string Identifier => "historical"; + public override string Identifier => @"historical"; public HistoricalSection() { @@ -20,7 +22,7 @@ namespace osu.Game.Overlays.Profile.Sections { new PlayHistorySubsection(User), new PaginatedMostPlayedBeatmapContainer(User), - new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)"), + new PaginatedScoreContainer(ScoreType.Recent, User, UsersStrings.ShowExtraHistoricalRecentPlaysTitle), new ReplaysSubsection(User) }; } diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs index cdb24b784c..37de669b3b 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs @@ -12,6 +12,8 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Users; using osu.Framework.Allocation; +using osu.Game.Resources.Localisation.Web; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections.Kudosu { @@ -37,7 +39,7 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu private class CountTotal : CountSection { public CountTotal() - : base("Total Kudosu Earned") + : base(UsersStrings.ShowExtraKudosuTotal) { DescriptionText.AddText("Based on how much of a contribution the user has made to beatmap moderation. See "); DescriptionText.AddLink("this page", "https://osu.ppy.sh/wiki/Kudosu"); @@ -56,7 +58,7 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu set => valueText.Text = value.ToString("N0"); } - public CountSection(string header) + public CountSection(LocalisableString header) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs index 008d89d881..76cd7ed722 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs @@ -8,13 +8,14 @@ using osu.Framework.Bindables; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API; using System.Collections.Generic; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections.Kudosu { public class PaginatedKudosuHistoryContainer : PaginatedProfileSubsection { public PaginatedKudosuHistoryContainer(Bindable user) - : base(user, missingText: "This user hasn't received any kudosu!") + : base(user, missingText: UsersStrings.ShowExtraKudosuEntryEmpty) { ItemsPerPage = 5; } diff --git a/osu.Game/Overlays/Profile/Sections/KudosuSection.cs b/osu.Game/Overlays/Profile/Sections/KudosuSection.cs index a9e9952257..5b749c78a8 100644 --- a/osu.Game/Overlays/Profile/Sections/KudosuSection.cs +++ b/osu.Game/Overlays/Profile/Sections/KudosuSection.cs @@ -3,14 +3,16 @@ using osu.Framework.Graphics; using osu.Game.Overlays.Profile.Sections.Kudosu; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { public class KudosuSection : ProfileSection { - public override string Title => "Kudosu!"; + public override LocalisableString Title => UsersStrings.ShowExtraKudosuTitle; - public override string Identifier => "kudosu"; + public override string Identifier => @"kudosu"; public KudosuSection() { diff --git a/osu.Game/Overlays/Profile/Sections/MedalsSection.cs b/osu.Game/Overlays/Profile/Sections/MedalsSection.cs index 575a2f2c19..cacdd44b61 100644 --- a/osu.Game/Overlays/Profile/Sections/MedalsSection.cs +++ b/osu.Game/Overlays/Profile/Sections/MedalsSection.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 osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.Profile.Sections { public class MedalsSection : ProfileSection { - public override string Title => "Medals"; + public override LocalisableString Title => UsersStrings.ShowExtraMedalsTitle; - public override string Identifier => "medals"; + public override string Identifier => @"medals"; } } diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index e237b43b2e..d60243cd0a 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -15,6 +15,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Graphics.Sprites; using osu.Game.Graphics; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections { @@ -36,9 +37,9 @@ namespace osu.Game.Overlays.Profile.Sections private ShowMoreButton moreButton; private OsuSpriteText missing; - private readonly string missingText; + private readonly LocalisableString? missingText; - protected PaginatedProfileSubsection(Bindable user, string headerText = "", string missingText = "") + protected PaginatedProfileSubsection(Bindable user, LocalisableString? headerText = null, LocalisableString? missingText = null) : base(user, headerText, CounterVisibilityState.AlwaysVisible) { this.missingText = missingText; @@ -68,7 +69,7 @@ namespace osu.Game.Overlays.Profile.Sections missing = new OsuSpriteText { Font = OsuFont.GetFont(size: 15), - Text = missingText, + Text = missingText ?? string.Empty, Alpha = 0, } } @@ -114,7 +115,7 @@ namespace osu.Game.Overlays.Profile.Sections moreButton.Hide(); moreButton.IsLoading = false; - if (!string.IsNullOrEmpty(missingText)) + if (missingText.HasValue) missing.Show(); return; diff --git a/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs index 3e331f85e9..5a17f0d8bb 100644 --- a/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Users; using JetBrains.Annotations; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections { @@ -14,14 +15,14 @@ namespace osu.Game.Overlays.Profile.Sections { protected readonly Bindable User = new Bindable(); - private readonly string headerText; + private readonly LocalisableString headerText; private readonly CounterVisibilityState counterVisibilityState; private ProfileSubsectionHeader header; - protected ProfileSubsection(Bindable user, string headerText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) + protected ProfileSubsection(Bindable user, LocalisableString? headerText = null, CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) { - this.headerText = headerText; + this.headerText = headerText ?? string.Empty; this.counterVisibilityState = counterVisibilityState; User.BindTo(user); } @@ -37,7 +38,7 @@ namespace osu.Game.Overlays.Profile.Sections { header = new ProfileSubsectionHeader(headerText, counterVisibilityState) { - Alpha = string.IsNullOrEmpty(headerText) ? 0 : 1 + Alpha = string.IsNullOrEmpty(headerText.ToString()) ? 0 : 1 }, CreateContent() }; diff --git a/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs b/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs index 5858cebe89..408cb00770 100644 --- a/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs +++ b/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes; using osuTK; using osu.Game.Graphics.Sprites; using osu.Game.Graphics; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections { @@ -24,12 +25,12 @@ namespace osu.Game.Overlays.Profile.Sections set => current.Current = value; } - private readonly string text; + private readonly LocalisableString text; private readonly CounterVisibilityState counterState; private CounterPill counterPill; - public ProfileSubsectionHeader(string text, CounterVisibilityState counterState) + public ProfileSubsectionHeader(LocalisableString text, CounterVisibilityState counterState) { this.text = text; this.counterState = counterState; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs index 3afa79e59e..63305d004c 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; using osuTK; @@ -51,7 +52,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks new OsuSpriteText { Font = OsuFont.GetFont(size: 12), - Text = $@"weighted {weight:0%}" + Text = UsersStrings.ShowExtraTopRanksPpWeight(weight.ToString("0%")) } } }; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index 720cd4a3db..7c04b331c2 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -11,6 +11,7 @@ using osu.Game.Online.API.Requests.Responses; using System.Collections.Generic; using osu.Game.Online.API; using osu.Framework.Allocation; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections.Ranks { @@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { private readonly ScoreType type; - public PaginatedScoreContainer(ScoreType type, Bindable user, string headerText) + public PaginatedScoreContainer(ScoreType type, Bindable user, LocalisableString headerText) : base(user, headerText) { this.type = type; diff --git a/osu.Game/Overlays/Profile/Sections/RanksSection.cs b/osu.Game/Overlays/Profile/Sections/RanksSection.cs index 33f7c2f71a..00a68d5bf9 100644 --- a/osu.Game/Overlays/Profile/Sections/RanksSection.cs +++ b/osu.Game/Overlays/Profile/Sections/RanksSection.cs @@ -3,21 +3,23 @@ using osu.Game.Overlays.Profile.Sections.Ranks; using osu.Game.Online.API.Requests; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { public class RanksSection : ProfileSection { - public override string Title => "Ranks"; + public override LocalisableString Title => UsersStrings.ShowExtraTopRanksTitle; - public override string Identifier => "top_ranks"; + public override string Identifier => @"top_ranks"; public RanksSection() { Children = new[] { - new PaginatedScoreContainer(ScoreType.Best, User, "Best Performance"), - new PaginatedScoreContainer(ScoreType.Firsts, User, "First Place Ranks") + new PaginatedScoreContainer(ScoreType.Best, User, UsersStrings.ShowExtraTopRanksBestTitle), + new PaginatedScoreContainer(ScoreType.Firsts, User, UsersStrings.ShowExtraTopRanksFirstTitle) }; } } diff --git a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs index d7101a8147..db2e6bc1e0 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs @@ -10,13 +10,14 @@ using osu.Game.Online.API; using System.Collections.Generic; using osuTK; using osu.Framework.Allocation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections.Recent { public class PaginatedRecentActivityContainer : PaginatedProfileSubsection { public PaginatedRecentActivityContainer(Bindable user) - : base(user, missingText: "This user hasn't done anything notable recently!") + : base(user, missingText: EventsStrings.Empty) { ItemsPerPage = 10; } diff --git a/osu.Game/Overlays/Profile/Sections/RecentSection.cs b/osu.Game/Overlays/Profile/Sections/RecentSection.cs index 1e6cfcc9fd..33d435aa1b 100644 --- a/osu.Game/Overlays/Profile/Sections/RecentSection.cs +++ b/osu.Game/Overlays/Profile/Sections/RecentSection.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 osu.Framework.Localisation; using osu.Game.Overlays.Profile.Sections.Recent; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { public class RecentSection : ProfileSection { - public override string Title => "Recent"; + public override LocalisableString Title => UsersStrings.ShowExtraRecentActivityTitle; - public override string Identifier => "recent_activity"; + public override string Identifier => @"recent_activity"; public RecentSection() { diff --git a/osu.Game/Overlays/Profile/UserGraph.cs b/osu.Game/Overlays/Profile/UserGraph.cs index cdfd722d68..b7a08b6c5e 100644 --- a/osu.Game/Overlays/Profile/UserGraph.cs +++ b/osu.Game/Overlays/Profile/UserGraph.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -211,7 +212,7 @@ namespace osu.Game.Overlays.Profile protected readonly OsuSpriteText Counter, BottomText; private readonly Box background; - protected UserGraphTooltip(string tooltipCounterName) + protected UserGraphTooltip(LocalisableString tooltipCounterName) { AutoSizeAxes = Axes.Both; Masking = true; diff --git a/osu.Game/Overlays/Rankings/CountryFilter.cs b/osu.Game/Overlays/Rankings/CountryFilter.cs index 4bdefb06ef..9950f36141 100644 --- a/osu.Game/Overlays/Rankings/CountryFilter.cs +++ b/osu.Game/Overlays/Rankings/CountryFilter.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 osu.Framework.Allocation; @@ -17,7 +17,7 @@ namespace osu.Game.Overlays.Rankings public class CountryFilter : CompositeDrawable, IHasCurrentValue { private const int duration = 200; - private const int height = 50; + private const int height = 70; private readonly BindableWithCurrent current = new BindableWithCurrent(); diff --git a/osu.Game/Overlays/Rankings/CountryPill.cs b/osu.Game/Overlays/Rankings/CountryPill.cs index 1b19bbd95e..edd7b596d2 100644 --- a/osu.Game/Overlays/Rankings/CountryPill.cs +++ b/osu.Game/Overlays/Rankings/CountryPill.cs @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Rankings InternalChild = content = new CircularContainer { - Height = 25, + Height = 30, AutoSizeDuration = duration, AutoSizeEasing = Easing.OutQuint, Masking = true, @@ -58,9 +58,9 @@ namespace osu.Game.Overlays.Rankings Origin = Anchor.Centre, RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, - Margin = new MarginPadding { Horizontal = 10 }, + Margin = new MarginPadding { Horizontal = 15 }, Direction = FillDirection.Horizontal, - Spacing = new Vector2(8, 0), + Spacing = new Vector2(15, 0), Children = new Drawable[] { new FillFlowContainer @@ -70,14 +70,14 @@ namespace osu.Game.Overlays.Rankings Anchor = Anchor.Centre, Origin = Anchor.Centre, Direction = FillDirection.Horizontal, - Spacing = new Vector2(3, 0), + Spacing = new Vector2(5, 0), Children = new Drawable[] { flag = new UpdateableFlag { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(22, 15) + Size = new Vector2(30, 20) }, countryName = new OsuSpriteText { @@ -148,7 +148,7 @@ namespace osu.Game.Overlays.Rankings AutoSizeAxes = Axes.Both; Add(icon = new SpriteIcon { - Size = new Vector2(8), + Size = new Vector2(10), Icon = FontAwesome.Solid.Times }); } diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs index 422373d099..89dd4eafdd 100644 --- a/osu.Game/Overlays/Rankings/SpotlightSelector.cs +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; using osu.Framework.Graphics.UserInterface; using osu.Game.Online.API.Requests; +using osu.Framework.Extensions.Color4Extensions; namespace osu.Game.Overlays.Rankings { @@ -46,6 +47,7 @@ namespace osu.Game.Overlays.Rankings { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + InternalChildren = new Drawable[] { background = new Box @@ -139,7 +141,7 @@ namespace osu.Game.Overlays.Rankings { AutoSizeAxes = Axes.Both; Direction = FillDirection.Vertical; - Margin = new MarginPadding { Vertical = 10 }; + Padding = new MarginPadding { Vertical = 15 }; Children = new Drawable[] { new OsuSpriteText @@ -150,11 +152,11 @@ namespace osu.Game.Overlays.Rankings new Container { AutoSizeAxes = Axes.X, - Height = 20, + Height = 25, Child = valueText = new OsuSpriteText { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, Font = OsuFont.GetFont(size: 20, weight: FontWeight.Light), } } @@ -174,11 +176,34 @@ namespace osu.Game.Overlays.Rankings protected override DropdownMenu CreateMenu() => menu = base.CreateMenu().With(m => m.MaxHeight = 400); + protected override DropdownHeader CreateHeader() => new SpotlightsDropdownHeader(); + [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { + // osu-web adds a 0.6 opacity container on top of the 0.5 base one when hovering, 0.8 on a single container here matches the resulting colour + AccentColour = colourProvider.Background6.Opacity(0.8f); menu.BackgroundColour = colourProvider.Background5; - AccentColour = colourProvider.Background6; + Padding = new MarginPadding { Vertical = 20 }; + } + + private class SpotlightsDropdownHeader : OsuDropdownHeader + { + public SpotlightsDropdownHeader() + { + AutoSizeAxes = Axes.Y; + Text.Font = OsuFont.GetFont(size: 15); + Text.Padding = new MarginPadding { Vertical = 1.5f }; // osu-web line-height difference compensation + Foreground.Padding = new MarginPadding { Horizontal = 10, Vertical = 15 }; + Margin = Icon.Margin = new MarginPadding(0); + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + BackgroundColour = colourProvider.Background6.Opacity(0.5f); + BackgroundColourHover = colourProvider.Background5; + } } } } diff --git a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs index 0b9a48ce0e..c5e413c7fa 100644 --- a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs @@ -1,15 +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 osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using System; using osu.Game.Users; -using osu.Game.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using System.Collections.Generic; using osu.Framework.Allocation; -using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Rankings.Tables { @@ -62,35 +61,20 @@ namespace osu.Game.Overlays.Rankings.Tables } }; - private class CountryName : OsuHoverContainer + private class CountryName : LinkFlowContainer { - protected override IEnumerable EffectTargets => new[] { text }; - [Resolved(canBeNull: true)] private RankingsOverlay rankings { get; set; } - private readonly OsuSpriteText text; - private readonly Country country; - public CountryName(Country country) + : base(t => t.Font = OsuFont.GetFont(size: 12)) { - this.country = country; + AutoSizeAxes = Axes.X; + RelativeSizeAxes = Axes.Y; + TextAnchor = Anchor.CentreLeft; - 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); + if (!string.IsNullOrEmpty(country.FullName)) + AddLink(country.FullName, () => rankings?.ShowCountry(country)); } } } diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index 943897581e..585b5c22aa 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.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 osu.Framework.Graphics; @@ -20,7 +20,8 @@ namespace osu.Game.Overlays.Rankings.Tables { protected const int TEXT_SIZE = 12; private const float horizontal_inset = 20; - private const float row_height = 25; + private const float row_height = 32; + private const float row_spacing = 3; private const int items_per_page = 50; private readonly int page; @@ -35,7 +36,7 @@ namespace osu.Game.Overlays.Rankings.Tables AutoSizeAxes = Axes.Y; Padding = new MarginPadding { Horizontal = horizontal_inset }; - RowSize = new Dimension(GridSizeMode.Absolute, row_height); + RowSize = new Dimension(GridSizeMode.Absolute, row_height + row_spacing); } [BackgroundDependencyLoader] @@ -47,10 +48,11 @@ namespace osu.Game.Overlays.Rankings.Tables { RelativeSizeAxes = Axes.Both, Depth = 1f, - Margin = new MarginPadding { Top = row_height } + Margin = new MarginPadding { Top = row_height + row_spacing }, + Spacing = new Vector2(0, row_spacing), }); - rankings.ForEach(_ => backgroundFlow.Add(new TableRowBackground())); + rankings.ForEach(_ => backgroundFlow.Add(new TableRowBackground { Height = row_height })); Columns = mainHeaders.Concat(CreateAdditionalHeaders()).ToArray(); Content = rankings.Select((s, i) => createContent((page - 1) * items_per_page + i, s)).ToArray().ToRectangular(); @@ -61,20 +63,26 @@ namespace osu.Game.Overlays.Rankings.Tables 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) + new TableColumn(string.Empty, Anchor.CentreLeft, new Dimension()), // 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 virtual string HighlightedColumn => @"Performance"; + + protected override Drawable CreateHeader(int index, TableColumn column) + { + var title = column?.Header ?? string.Empty; + return new HeaderText(title, title == HighlightedColumn); + } protected abstract Country GetCountry(TModel item); protected abstract Drawable CreateFlagContent(TModel item); - private OsuSpriteText createIndexDrawable(int index) => new OsuSpriteText + private OsuSpriteText createIndexDrawable(int index) => new RowText { Text = $"#{index + 1}", Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.SemiBold) @@ -84,37 +92,36 @@ namespace osu.Game.Overlays.Rankings.Tables { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, - Spacing = new Vector2(7, 0), + Spacing = new Vector2(10, 0), + Margin = new MarginPadding { Bottom = row_spacing }, Children = new[] { new UpdateableFlag(GetCountry(item)) { - Size = new Vector2(20, 13), + Size = new Vector2(30, 20), ShowPlaceholderOnNull = false, }, CreateFlagContent(item) } }; - protected virtual string HighlightedColumn() => @"Performance"; - - private class HeaderText : OsuSpriteText + protected class HeaderText : OsuSpriteText { - private readonly string highlighted; + private readonly bool isHighlighted; - public HeaderText(string text, string highlighted) + public HeaderText(string text, bool isHighlighted) { - this.highlighted = highlighted; + this.isHighlighted = isHighlighted; Text = text; Font = OsuFont.GetFont(size: 12); - Margin = new MarginPadding { Horizontal = 10 }; + Margin = new MarginPadding { Vertical = 5, Horizontal = 10 }; } [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - if (Text != highlighted) + if (!isHighlighted) Colour = colourProvider.Foreground1; } } @@ -124,7 +131,7 @@ namespace osu.Game.Overlays.Rankings.Tables public RowText() { Font = OsuFont.GetFont(size: TEXT_SIZE); - Margin = new MarginPadding { Horizontal = 10 }; + Margin = new MarginPadding { Horizontal = 10, Bottom = row_spacing }; } } diff --git a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs index 370ee506c2..9fae8e1897 100644 --- a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs @@ -33,6 +33,6 @@ namespace osu.Game.Overlays.Rankings.Tables } }; - protected override string HighlightedColumn() => @"Ranked Score"; + protected override string HighlightedColumn => @"Ranked Score"; } } diff --git a/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs b/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs index fe87a8b3d4..b49fec65db 100644 --- a/osu.Game/Overlays/Rankings/Tables/TableRowBackground.cs +++ b/osu.Game/Overlays/Rankings/Tables/TableRowBackground.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 osu.Framework.Allocation; @@ -22,10 +22,10 @@ namespace osu.Game.Overlays.Rankings.Tables public TableRowBackground() { RelativeSizeAxes = Axes.X; - Height = 25; - CornerRadius = 3; + CornerRadius = 4; Masking = true; + MaskingSmoothness = 0.5f; InternalChild = background = new Box { diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs index cad7364103..a6969f483f 100644 --- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -19,22 +19,32 @@ namespace osu.Game.Overlays.Rankings.Tables { } + protected virtual IEnumerable GradeColumns => new List { "SS", "S", "A" }; + 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(GradeColumns.Select(grade => new TableColumn(grade, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)))) + .ToArray(); + + protected override Drawable CreateHeader(int index, TableColumn column) { - 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(); + var title = column?.Header ?? string.Empty; + return new UserTableHeaderText(title, HighlightedColumn == title, GradeColumns.Contains(title)); + } 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 }; + var username = new LinkFlowContainer(t => t.Font = OsuFont.GetFont(size: TEXT_SIZE, italics: true)) + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + TextAnchor = Anchor.CentreLeft + }; username.AddUserLink(item.User); return username; } @@ -53,5 +63,19 @@ namespace osu.Game.Overlays.Rankings.Tables protected abstract TableColumn[] CreateUniqueHeaders(); protected abstract Drawable[] CreateUniqueContent(UserStatistics item); + + private class UserTableHeaderText : HeaderText + { + public UserTableHeaderText(string text, bool isHighlighted, bool isGrade) + : base(text, isHighlighted) + { + Margin = new MarginPadding + { + // Grade columns have extra horizontal padding for readibility + Horizontal = isGrade ? 20 : 10, + Vertical = 5 + }; + } + } } } diff --git a/osu.Game/Overlays/RestoreDefaultValueButton.cs b/osu.Game/Overlays/RestoreDefaultValueButton.cs index 213ad2ba68..fd3ee16fe6 100644 --- a/osu.Game/Overlays/RestoreDefaultValueButton.cs +++ b/osu.Game/Overlays/RestoreDefaultValueButton.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; @@ -19,15 +20,26 @@ namespace osu.Game.Overlays { public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; - private readonly BindableWithCurrent current = new BindableWithCurrent(); - // this is done to ensure a click on this button doesn't trigger focus on a parent element which contains the button. public override bool AcceptsFocus => true; + // this is intentionally not using BindableWithCurrent, as it can use the wrong IsDefault implementation when passed a BindableNumber. + // using GetBoundCopy() ensures that the received bindable is of the exact same type as the source bindable and uses the proper IsDefault implementation. + private Bindable current; + public Bindable Current { - get => current.Current; - set => current.Current = value; + get => current; + set + { + current?.UnbindAll(); + current = value.GetBoundCopy(); + + current.ValueChanged += _ => UpdateState(); + current.DefaultChanged += _ => UpdateState(); + current.DisabledChanged += _ => UpdateState(); + UpdateState(); + } } private Color4 buttonColour; @@ -61,22 +73,18 @@ namespace osu.Game.Overlays Action += () => { - if (!current.Disabled) current.SetDefault(); + if (!current.Disabled) + current.SetDefault(); }; } protected override void LoadComplete() { base.LoadComplete(); - - Current.ValueChanged += _ => UpdateState(); - Current.DisabledChanged += _ => UpdateState(); - Current.DefaultChanged += _ => UpdateState(); - UpdateState(); } - public string TooltipText => "revert to default"; + public LocalisableString TooltipText => "revert to default"; protected override bool OnHover(HoverEvent e) { diff --git a/osu.Game/Overlays/Settings/OutlinedTextBox.cs b/osu.Game/Overlays/Settings/OutlinedTextBox.cs new file mode 100644 index 0000000000..93eaf74b77 --- /dev/null +++ b/osu.Game/Overlays/Settings/OutlinedTextBox.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.Extensions.Color4Extensions; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Settings +{ + public class OutlinedTextBox : OsuTextBox + { + private const float border_thickness = 3; + + private Color4 borderColourFocused; + private Color4 borderColourUnfocused; + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + borderColourUnfocused = colour.Gray4.Opacity(0.5f); + borderColourFocused = BorderColour; + + updateBorder(); + } + + protected override void OnFocus(FocusEvent e) + { + base.OnFocus(e); + + updateBorder(); + } + + protected override void OnFocusLost(FocusLostEvent e) + { + base.OnFocusLost(e); + + updateBorder(); + } + + private void updateBorder() + { + BorderThickness = border_thickness; + BorderColour = HasFocus ? borderColourFocused : borderColourUnfocused; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index b31e7dc45b..d64f176468 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -13,7 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { public class AudioDevicesSettings : SettingsSubsection { - protected override string Header => "Devices"; + protected override LocalisableString Header => "Devices"; [Resolved] private AudioManager audio { get; set; } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs index c9a81b955b..7f2e377c83 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -10,7 +11,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { public class OffsetSettings : SettingsSubsection { - protected override string Header => "Offset Adjustment"; + protected override LocalisableString Header => "Offset Adjustment"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -32,7 +33,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio private class OffsetSlider : OsuSliderBar { - public override string TooltipText => Current.Value.ToString(@"0ms"); + public override LocalisableString TooltipText => Current.Value.ToString(@"0ms"); } } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs index c172a76ab9..8f88b03471 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs @@ -4,13 +4,14 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Audio { public class VolumeSettings : SettingsSubsection { - protected override string Header => "Volume"; + protected override LocalisableString Header => "Volume"; [BackgroundDependencyLoader] private void load(AudioManager audio, OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs index 4a9c9bd8a2..2b868cab85 100644 --- a/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Screens.Import; @@ -11,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Debug { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => "General"; [BackgroundDependencyLoader(true)] private void load(FrameworkDebugConfigManager config, FrameworkConfigManager frameworkConfig, OsuGame game) diff --git a/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs index db64c9a8ac..bf7fb351c0 100644 --- a/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs @@ -4,13 +4,14 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Framework.Platform; namespace osu.Game.Overlays.Settings.Sections.Debug { public class MemorySettings : SettingsSubsection { - protected override string Header => "Memory"; + protected override LocalisableString Header => "Memory"; [BackgroundDependencyLoader] private void load(FrameworkDebugConfigManager config, GameHost host) diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 0b5ec4f338..353292606f 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -4,6 +4,7 @@ using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Scoring; @@ -11,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => "General"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs index 2b2fb9cef7..ec9ddde2da 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs @@ -4,13 +4,14 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Gameplay { public class ModsSettings : SettingsSubsection { - protected override string Header => "Mods"; + protected override LocalisableString Header => "Mods"; public override IEnumerable FilterTerms => base.FilterTerms.Concat(new[] { "mod" }); diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index c2767f61b4..c6c752e2fd 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -1,11 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Extensions; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.General @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.General private SettingsDropdown languageSelection; private Bindable frameworkLocale; - protected override string Header => "Language"; + protected override LocalisableString Header => "Language"; [BackgroundDependencyLoader] private void load(FrameworkConfigManager frameworkConfig) @@ -35,11 +36,11 @@ namespace osu.Game.Overlays.Settings.Sections.General }, }; - if (!Enum.TryParse(frameworkLocale.Value, out var locale)) + if (!LanguageExtensions.TryParseCultureCode(frameworkLocale.Value, out var locale)) locale = Language.en; languageSelection.Current.Value = locale; - languageSelection.Current.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToString()); + languageSelection.Current.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToCultureCode()); } } } diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index c213313559..dd20e1d7ef 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Configuration; @@ -19,7 +20,7 @@ namespace osu.Game.Overlays.Settings.Sections.General [Resolved(CanBeNull = true)] private UpdateManager updateManager { get; set; } - protected override string Header => "Updates"; + protected override LocalisableString Header => "Updates"; private SettingsButton checkForUpdatesButton; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs index 30caa45995..f889cfca0f 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs @@ -3,13 +3,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Graphics { public class DetailSettings : SettingsSubsection { - protected override string Header => "Detail Settings"; + protected override LocalisableString Header => "Detail Settings"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 937bcc8abf..91208cb78a 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -22,7 +22,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { public class LayoutSettings : SettingsSubsection { - protected override string Header => "Layout"; + protected override LocalisableString Header => "Layout"; private FillFlowContainer> scalingSettings; @@ -233,7 +233,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics private class UIScaleSlider : OsuSliderBar { - public override string TooltipText => base.TooltipText + "x"; + public override LocalisableString TooltipText => base.TooltipText + "x"; } private class ResolutionSettingsDropdown : SettingsDropdown diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index 70225ff6b8..2210c7911e 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.Localisation; using osu.Framework.Platform; using osu.Game.Configuration; @@ -11,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { public class RendererSettings : SettingsSubsection { - protected override string Header => "Renderer"; + protected override LocalisableString Header => "Renderer"; private SettingsEnumDropdown frameLimiterDropdown; diff --git a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs index 79c73863cf..3227decc46 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs @@ -2,12 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { public class BindingSettings : SettingsSubsection { - protected override string Header => "Shortcut and gameplay bindings"; + protected override LocalisableString Header => BindingSettingsStrings.ShortcutAndGameplayBindings; public BindingSettings(KeyBindingPanel keyConfig) { @@ -15,8 +17,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input { new SettingsButton { - Text = "Configure", - TooltipText = "change global shortcut keys and gameplay bindings", + Text = BindingSettingsStrings.Configure, + TooltipText = BindingSettingsStrings.ChangeBindingsButton, Action = keyConfig.ToggleVisibility }, }; diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index fb908a7669..753096a207 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -6,9 +6,11 @@ using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Input; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { @@ -16,7 +18,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input { private readonly MouseHandler mouseHandler; - protected override string Header => "Mouse"; + protected override LocalisableString Header => MouseSettingsStrings.Mouse; private Bindable handlerSensitivity; @@ -45,27 +47,29 @@ namespace osu.Game.Overlays.Settings.Sections.Input { new SettingsCheckbox { - LabelText = "High precision mouse", - Current = relativeMode + LabelText = MouseSettingsStrings.HighPrecisionMouse, + TooltipText = MouseSettingsStrings.HighPrecisionMouseTooltip, + Current = relativeMode, + Keywords = new[] { @"raw", @"input", @"relative", @"cursor" } }, new SensitivitySetting { - LabelText = "Cursor sensitivity", + LabelText = MouseSettingsStrings.CursorSensitivity, Current = localSensitivity }, confineMouseModeSetting = new SettingsEnumDropdown { - LabelText = "Confine mouse cursor to window", + LabelText = MouseSettingsStrings.ConfineMouseMode, Current = osuConfig.GetBindable(OsuSetting.ConfineMouseMode) }, new SettingsCheckbox { - LabelText = "Disable mouse wheel during gameplay", + LabelText = MouseSettingsStrings.DisableMouseWheel, Current = osuConfig.GetBindable(OsuSetting.MouseDisableWheel) }, new SettingsCheckbox { - LabelText = "Disable mouse buttons during gameplay", + LabelText = MouseSettingsStrings.DisableMouseButtons, Current = osuConfig.GetBindable(OsuSetting.MouseDisableButtons) }, }; @@ -95,7 +99,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (isFullscreen) { confineMouseModeSetting.Current.Disabled = true; - confineMouseModeSetting.TooltipText = "Not applicable in full screen mode"; + confineMouseModeSetting.TooltipText = MouseSettingsStrings.NotApplicableFullscreen; } else { @@ -116,7 +120,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private class SensitivitySlider : OsuSliderBar { - public override string TooltipText => Current.Disabled ? "enable high precision mouse to adjust sensitivity" : $"{base.TooltipText}x"; + public override LocalisableString TooltipText => Current.Disabled ? MouseSettingsStrings.EnableHighPrecisionForSensitivityAdjust : $"{base.TooltipText}x"; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs index 3e8da9f7d0..26610628d5 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs @@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input RelativeSizeAxes = Axes.X, Height = height, Width = 0.25f, - Text = $"{presetRotation}º", + Text = $@"{presetRotation}º", Action = () => tabletHandler.Rotation.Value = presetRotation, }); } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index d770c18878..c7342c251d 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -6,10 +6,12 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Graphics.Sprites; using osuTK; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { @@ -52,7 +54,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private OsuSpriteText noTabletMessage; - protected override string Header => "Tablet"; + protected override LocalisableString Header => TabletSettingsStrings.Tablet; public TabletSettings(ITabletHandler tabletHandler) { @@ -66,14 +68,14 @@ namespace osu.Game.Overlays.Settings.Sections.Input { new SettingsCheckbox { - LabelText = "Enabled", + LabelText = CommonStrings.Enabled, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Current = tabletHandler.Enabled }, noTabletMessage = new OsuSpriteText { - Text = "No tablet detected!", + Text = TabletSettingsStrings.NoTabletDetected, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS } @@ -94,7 +96,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, new DangerousSettingsButton { - Text = "Reset to full area", + Text = TabletSettingsStrings.ResetToFullArea, Action = () => { aspectLock.Value = false; @@ -105,7 +107,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, new SettingsButton { - Text = "Conform to current game aspect ratio", + Text = TabletSettingsStrings.ConformToCurrentGameAspectRatio, Action = () => { forceAspectRatio((float)host.Window.ClientSize.Width / host.Window.ClientSize.Height); @@ -114,43 +116,43 @@ namespace osu.Game.Overlays.Settings.Sections.Input new SettingsSlider { TransferValueOnCommit = true, - LabelText = "X Offset", + LabelText = TabletSettingsStrings.XOffset, Current = offsetX }, new SettingsSlider { TransferValueOnCommit = true, - LabelText = "Y Offset", + LabelText = TabletSettingsStrings.YOffset, Current = offsetY }, new SettingsSlider { TransferValueOnCommit = true, - LabelText = "Rotation", + LabelText = TabletSettingsStrings.Rotation, Current = rotation }, new RotationPresetButtons(tabletHandler), new SettingsSlider { TransferValueOnCommit = true, - LabelText = "Aspect Ratio", + LabelText = TabletSettingsStrings.AspectRatio, Current = aspectRatio }, new SettingsCheckbox { - LabelText = "Lock aspect ratio", + LabelText = TabletSettingsStrings.LockAspectRatio, Current = aspectLock }, new SettingsSlider { TransferValueOnCommit = true, - LabelText = "Width", + LabelText = CommonStrings.Width, Current = sizeX }, new SettingsSlider { TransferValueOnCommit = true, - LabelText = "Height", + LabelText = CommonStrings.Height, Current = sizeY }, } diff --git a/osu.Game/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index 6e99891794..366f39388a 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -9,6 +9,7 @@ using osu.Framework.Input.Handlers.Joystick; using osu.Framework.Input.Handlers.Midi; using osu.Framework.Input.Handlers.Mouse; using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Overlays.Settings.Sections.Input; @@ -100,7 +101,7 @@ namespace osu.Game.Overlays.Settings.Sections }; } - protected override string Header => handler.Description; + protected override LocalisableString Header => handler.Description; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs index 349a112477..5392ba5d93 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/DirectorySelectScreen.cs @@ -21,7 +21,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { private TriangleButton selectionButton; - private DirectorySelector directorySelector; + private OsuDirectorySelector directorySelector; /// /// Text to display in the header to inform the user of what they are selecting. @@ -91,7 +91,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance }, new Drawable[] { - directorySelector = new DirectorySelector + directorySelector = new OsuDirectorySelector { RelativeSizeAxes = Axes.Both, } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index a38ca81e23..b9a408b1f8 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Database; @@ -17,7 +18,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => "General"; private TriangleButton importBeatmapsButton; private TriangleButton importScoresButton; diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs new file mode 100644 index 0000000000..3a2de2ee36 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; + +namespace osu.Game.Overlays.Settings.Sections.Online +{ + public class AlertsAndPrivacySettings : SettingsSubsection + { + protected override LocalisableString Header => "Alerts and Privacy"; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = "Show a notification when someone mentions your name", + Current = config.GetBindable(OsuSetting.NotifyOnUsernameMentioned) + }, + new SettingsCheckbox + { + LabelText = "Show a notification when you receive a private message", + Current = config.GetBindable(OsuSetting.NotifyOnPrivateMessage) + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs index d2867962c0..f2012f0d9c 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs @@ -3,13 +3,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Online { public class IntegrationSettings : SettingsSubsection { - protected override string Header => "Integrations"; + protected override LocalisableString Header => "Integrations"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs index 59bcbe4d89..89e7b096f3 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs @@ -3,13 +3,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Online { public class WebSettings : SettingsSubsection { - protected override string Header => "Web"; + protected override LocalisableString Header => "Web"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs index 7aa4eff29a..680d11f7da 100644 --- a/osu.Game/Overlays/Settings/Sections/OnlineSection.cs +++ b/osu.Game/Overlays/Settings/Sections/OnlineSection.cs @@ -21,6 +21,7 @@ namespace osu.Game.Overlays.Settings.Sections Children = new Drawable[] { new WebSettings(), + new AlertsAndPrivacySettings(), new IntegrationSettings() }; } diff --git a/osu.Game/Overlays/Settings/Sections/SizeSlider.cs b/osu.Game/Overlays/Settings/Sections/SizeSlider.cs index 101d8f43f7..8aeb440be1 100644 --- a/osu.Game/Overlays/Settings/Sections/SizeSlider.cs +++ b/osu.Game/Overlays/Settings/Sections/SizeSlider.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.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings.Sections @@ -10,6 +11,6 @@ namespace osu.Game.Overlays.Settings.Sections /// internal class SizeSlider : OsuSliderBar { - public override string TooltipText => Current.Value.ToString(@"0.##x"); + public override LocalisableString TooltipText => Current.Value.ToString(@"0.##x"); } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs index 19adfc5dd9..4b26645ef3 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -10,7 +11,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => "General"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -44,7 +45,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface private class TimeSlider : OsuSliderBar { - public override string TooltipText => Current.Value.ToString("N0") + "ms"; + public override LocalisableString TooltipText => Current.Value.ToString(@"N0") + "ms"; } } } diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 5f703ed5a4..81bbcbb54a 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Users; @@ -12,7 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { public class MainMenuSettings : SettingsSubsection { - protected override string Header => "Main Menu"; + protected override LocalisableString Header => "Main Menu"; private IBindable user; diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index c73a783d37..587155eb0d 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface private Bindable minStars; private Bindable maxStars; - protected override string Header => "Song Select"; + protected override LocalisableString Header => "Song Select"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -62,12 +63,12 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface private class MaximumStarsSlider : StarsSlider { - public override string TooltipText => Current.IsDefault ? "no limit" : base.TooltipText; + public override LocalisableString TooltipText => Current.IsDefault ? "no limit" : base.TooltipText; } private class StarsSlider : OsuSliderBar { - public override string TooltipText => Current.Value.ToString(@"0.## stars"); + public override LocalisableString TooltipText => Current.Value.ToString(@"0.## stars"); } } } diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index 088d69c031..87b1aa0e46 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Settings @@ -17,14 +18,15 @@ namespace osu.Game.Overlays.Settings Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }; } - public string TooltipText { get; set; } + public LocalisableString TooltipText { get; set; } public override IEnumerable FilterTerms { get { - if (TooltipText != null) - return base.FilterTerms.Append(TooltipText); + if (TooltipText != default) + // TODO: this won't work as intended once the tooltip text is translated. + return base.FilterTerms.Append(TooltipText.ToString()); return base.FilterTerms; } diff --git a/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs b/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs index c77d14632b..9987a0c607 100644 --- a/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs @@ -19,6 +19,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/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 807916e7f6..c60ad020f0 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -35,7 +35,7 @@ namespace osu.Game.Overlays.Settings public bool ShowsDefaultIndicator = true; - public string TooltipText { get; set; } + public LocalisableString TooltipText { get; set; } [Resolved] private OsuColour colours { get; set; } @@ -101,10 +101,10 @@ namespace osu.Game.Overlays.Settings public event Action SettingChanged; + private readonly RestoreDefaultValueButton restoreDefaultButton; + protected SettingsItem() { - RestoreDefaultValueButton restoreDefaultButton; - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Padding = new MarginPadding { Right = SettingsPanel.CONTENT_MARGINS }; @@ -126,14 +126,19 @@ namespace osu.Game.Overlays.Settings // 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 (controlWithCurrent == null) + throw new ArgumentException(@$"Control created via {nameof(CreateControl)} must implement {nameof(IHasCurrentValue)}"); - if (ShowsDefaultIndicator) - restoreDefaultButton.Current = controlWithCurrent.Current; - } + controlWithCurrent.Current.ValueChanged += _ => SettingChanged?.Invoke(); + controlWithCurrent.Current.DisabledChanged += _ => updateDisabled(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (ShowsDefaultIndicator) + restoreDefaultButton.Current = controlWithCurrent.Current; } private void updateDisabled() @@ -142,4 +147,4 @@ namespace osu.Game.Overlays.Settings labelText.Alpha = controlWithCurrent.Current.Disabled ? 0.3f : 1; } } -} \ No newline at end of file +} diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index cb7e63ae6f..2fbe522479 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -1,17 +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.Framework.Graphics; -using osu.Game.Graphics.UserInterface; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; namespace osu.Game.Overlays.Settings { - public class SettingsNumberBox : SettingsItem + public class SettingsNumberBox : SettingsItem { - protected override Drawable CreateControl() => new OsuNumberBox + protected override Drawable CreateControl() => new NumberControl { - Margin = new MarginPadding { Top = 5 }, RelativeSizeAxes = Axes.X, + Margin = new MarginPadding { Top = 5 } }; + + private sealed class NumberControl : CompositeDrawable, IHasCurrentValue + { + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + public NumberControl() + { + AutoSizeAxes = Axes.Y; + + OutlinedNumberBox numberBox; + + InternalChildren = new[] + { + numberBox = new OutlinedNumberBox + { + Margin = new MarginPadding { Top = 5 }, + RelativeSizeAxes = Axes.X, + CommitOnFocusLost = true + } + }; + + numberBox.Current.BindValueChanged(e => + { + int? value = null; + + if (int.TryParse(e.NewValue, out var intVal)) + value = intVal; + + current.Value = value; + }); + + Current.BindValueChanged(e => + { + numberBox.Current.Value = e.NewValue?.ToString(); + }); + } + } + + private class OutlinedNumberBox : OutlinedTextBox + { + protected override bool CanAddCharacter(char character) => char.IsNumber(character); + } } } diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index 6abf6283b9..df32424b67 100644 --- a/osu.Game/Overlays/Settings/SettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/SettingsSubsection.cs @@ -8,6 +8,7 @@ using osu.Game.Graphics.Sprites; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Graphics; @@ -20,10 +21,10 @@ namespace osu.Game.Overlays.Settings protected readonly FillFlowContainer FlowContent; - protected abstract string Header { get; } + protected abstract LocalisableString Header { get; } public IEnumerable FilterableChildren => Children.OfType(); - public virtual IEnumerable FilterTerms => new[] { Header }; + public virtual IEnumerable FilterTerms => new[] { Header.ToString() }; public bool MatchingFilter { @@ -54,7 +55,7 @@ namespace osu.Game.Overlays.Settings { new OsuSpriteText { - Text = Header.ToUpperInvariant(), + Text = Header.ToString().ToUpper(), // TODO: Add localisation support after https://github.com/ppy/osu-framework/pull/4603 is merged. Margin = new MarginPadding { Vertical = 30, Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, Font = OsuFont.GetFont(weight: FontWeight.Bold), }, diff --git a/osu.Game/Overlays/Settings/SettingsTextBox.cs b/osu.Game/Overlays/Settings/SettingsTextBox.cs index 5e700a1d6b..d28dbf1068 100644 --- a/osu.Game/Overlays/Settings/SettingsTextBox.cs +++ b/osu.Game/Overlays/Settings/SettingsTextBox.cs @@ -2,17 +2,16 @@ // 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.Settings { public class SettingsTextBox : SettingsItem { - protected override Drawable CreateControl() => new OsuTextBox + protected override Drawable CreateControl() => new OutlinedTextBox { Margin = new MarginPadding { Top = 5 }, RelativeSizeAxes = Axes.X, - CommitOnFocusLost = true, + CommitOnFocusLost = true }; } } diff --git a/osu.Game/Overlays/TabControlOverlayHeader.cs b/osu.Game/Overlays/TabControlOverlayHeader.cs index 7798dfa576..e6f7e250a7 100644 --- a/osu.Game/Overlays/TabControlOverlayHeader.cs +++ b/osu.Game/Overlays/TabControlOverlayHeader.cs @@ -106,7 +106,19 @@ namespace osu.Game.Overlays public OverlayHeaderTabItem(T value) : base(value) { - Text.Text = ((Value as Enum)?.GetDescription() ?? Value.ToString()).ToLower(); + if (!(Value is Enum enumValue)) + Text.Text = Value.ToString().ToLower(); + else + { + var localisableDescription = enumValue.GetLocalisableDescription(); + var nonLocalisableDescription = enumValue.GetDescription(); + + // If localisable == non-localisable, then we must have a basic string, so .ToLower() is used. + Text.Text = localisableDescription.Equals(nonLocalisableDescription) + ? nonLocalisableDescription.ToLower() + : localisableDescription; + } + 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/ToolbarButton.cs b/osu.Game/Overlays/Toolbar/ToolbarButton.cs index 1933422dd9..4a33f9e296 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarButton.cs @@ -1,8 +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.Linq; using osu.Framework.Allocation; -using osu.Framework.Caching; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; @@ -13,13 +13,13 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Game.Database; using osu.Framework.Localisation; 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; @@ -76,7 +76,7 @@ namespace osu.Game.Overlays.Toolbar protected FillFlowContainer Flow; [Resolved] - private KeyBindingStore keyBindings { get; set; } + private RealmContextFactory realmFactory { get; set; } protected ToolbarButton() : base(HoverSampleSet.Toolbar) @@ -159,27 +159,6 @@ namespace osu.Game.Overlays.Toolbar }; } - 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) @@ -195,6 +174,7 @@ namespace osu.Game.Overlays.Toolbar HoverBackground.FadeIn(200); tooltipContainer.FadeIn(100); + return base.OnHover(e); } @@ -218,6 +198,21 @@ namespace osu.Game.Overlays.Toolbar public void OnReleased(GlobalAction action) { } + + private void updateKeyBindingTooltip() + { + if (Hotkey == null) return; + + var realmKeyBinding = realmFactory.Context.All().FirstOrDefault(rkb => rkb.RulesetID == null && rkb.ActionInt == (int)Hotkey.Value); + + if (realmKeyBinding != null) + { + var keyBindingString = realmKeyBinding.KeyCombination.ReadableString(); + + if (!string.IsNullOrEmpty(keyBindingString)) + keyBindingTooltip.Text = $" ({keyBindingString})"; + } + } } public class OpaqueBackground : Container diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index db4e491d9a..165c095514 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -32,14 +32,13 @@ namespace osu.Game.Overlays.Toolbar Add(new OpaqueBackground { Depth = 1 }); - Flow.Add(avatar = new UpdateableAvatar + Flow.Add(avatar = new UpdateableAvatar(openOnClick: false) { Masking = true, Size = new Vector2(32), Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, CornerRadius = 4, - OpenOnClick = { Value = false }, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Shadow, diff --git a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs index ae9c2eb394..b24214ff3d 100644 --- a/osu.Game/Overlays/Volume/VolumeControlReceptor.cs +++ b/osu.Game/Overlays/Volume/VolumeControlReceptor.cs @@ -30,6 +30,8 @@ namespace osu.Game.Overlays.Volume return true; case GlobalAction.ToggleMute: + case GlobalAction.NextVolumeMeter: + case GlobalAction.PreviousVolumeMeter: ActionRequested?.Invoke(action); return true; } diff --git a/osu.Game/Overlays/Volume/VolumeMeter.cs b/osu.Game/Overlays/Volume/VolumeMeter.cs index a15076581e..f4cbbf5a00 100644 --- a/osu.Game/Overlays/Volume/VolumeMeter.cs +++ b/osu.Game/Overlays/Volume/VolumeMeter.cs @@ -3,8 +3,12 @@ using System; using System.Globalization; +using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -17,13 +21,14 @@ using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; namespace osu.Game.Overlays.Volume { - public class VolumeMeter : Container, IKeyBindingHandler + public class VolumeMeter : Container, IKeyBindingHandler, IStateful { private CircularProgress volumeCircle; private CircularProgress volumeCircleGlow; @@ -36,6 +41,33 @@ namespace osu.Game.Overlays.Volume private OsuSpriteText text; private BufferedContainer maxGlow; + private Container selectedGlowContainer; + + private Sample hoverSample; + private Sample notchSample; + private double sampleLastPlaybackTime; + + public event Action StateChanged; + + private SelectionState state; + + public SelectionState State + { + get => state; + set + { + if (state == value) + return; + + state = value; + StateChanged?.Invoke(value); + + updateSelectedState(); + } + } + + private const float transition_length = 500; + public VolumeMeter(string name, float circleSize, Color4 meterColour) { this.circleSize = circleSize; @@ -46,8 +78,12 @@ namespace osu.Game.Overlays.Volume } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, AudioManager audio) { + hoverSample = audio.Samples.Get($"UI/{HoverSampleSet.Button.GetDescription()}-hover"); + notchSample = audio.Samples.Get(@"UI/notch-tick"); + sampleLastPlaybackTime = Time.Current; + Color4 backgroundColour = colours.Gray1; CircularProgress bgProgress; @@ -67,7 +103,6 @@ namespace osu.Game.Overlays.Volume { new BufferedContainer { - Alpha = 0.9f, RelativeSizeAxes = Axes.Both, Children = new Drawable[] { @@ -139,6 +174,24 @@ namespace osu.Game.Overlays.Volume }, }, }, + selectedGlowContainer = new CircularContainer + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = meterColour.Opacity(0.1f), + Radius = 10, + } + }, maxGlow = (text = new OsuSpriteText { Anchor = Anchor.Centre, @@ -163,7 +216,6 @@ namespace osu.Game.Overlays.Volume { new Box { - Alpha = 0.9f, RelativeSizeAxes = Axes.Both, Colour = backgroundColour, }, @@ -178,22 +230,12 @@ namespace osu.Game.Overlays.Volume } }; - Bindable.ValueChanged += volume => - { - this.TransformTo("DisplayVolume", - volume.NewValue, - 400, - Easing.OutQuint); - }; + Bindable.BindValueChanged(volume => { this.TransformTo(nameof(DisplayVolume), volume.NewValue, 400, Easing.OutQuint); }, true); bgProgress.Current.Value = 0.75f; } - protected override void LoadComplete() - { - base.LoadComplete(); - Bindable.TriggerChange(); - } + private int? displayVolumeInt; private double displayVolume; @@ -204,6 +246,11 @@ namespace osu.Game.Overlays.Volume { displayVolume = value; + int intValue = (int)Math.Round(displayVolume * 100); + bool intVolumeChanged = intValue != displayVolumeInt; + + displayVolumeInt = intValue; + if (displayVolume >= 0.995f) { text.Text = "MAX"; @@ -212,14 +259,36 @@ namespace osu.Game.Overlays.Volume else { maxGlow.EffectColour = Color4.Transparent; - text.Text = Math.Round(displayVolume * 100).ToString(CultureInfo.CurrentCulture); + text.Text = intValue.ToString(CultureInfo.CurrentCulture); } volumeCircle.Current.Value = displayVolume * 0.75f; volumeCircleGlow.Current.Value = displayVolume * 0.75f; + + if (intVolumeChanged && IsLoaded) + Scheduler.AddOnce(playTickSound); } } + private void playTickSound() + { + const int tick_debounce_time = 30; + + if (Time.Current - sampleLastPlaybackTime <= tick_debounce_time) + return; + + var channel = notchSample.GetChannel(); + + channel.Frequency.Value = 0.99f + RNG.NextDouble(0.02f) + displayVolume * 0.1f; + + // intentionally pitched down, even when hitting max. + if (displayVolumeInt == 0 || displayVolumeInt == 100) + channel.Frequency.Value -= 0.5f; + + channel.Play(); + sampleLastPlaybackTime = Time.Current; + } + public double Volume { get => Bindable.Value; @@ -280,17 +349,14 @@ namespace osu.Game.Overlays.Volume return true; } - private const float transition_length = 500; - - protected override bool OnHover(HoverEvent e) + protected override bool OnMouseMove(MouseMoveEvent e) { - this.ScaleTo(1.04f, transition_length, Easing.OutExpo); - return false; + State = SelectionState.Selected; + return base.OnMouseMove(e); } protected override void OnHoverLost(HoverLostEvent e) { - this.ScaleTo(1f, transition_length, Easing.OutExpo); } public bool OnPressed(GlobalAction action) @@ -301,10 +367,12 @@ namespace osu.Game.Overlays.Volume switch (action) { case GlobalAction.SelectPrevious: + State = SelectionState.Selected; adjust(1, false); return true; case GlobalAction.SelectNext: + State = SelectionState.Selected; adjust(-1, false); return true; } @@ -315,5 +383,22 @@ namespace osu.Game.Overlays.Volume public void OnReleased(GlobalAction action) { } + + private void updateSelectedState() + { + switch (state) + { + case SelectionState.Selected: + this.ScaleTo(1.04f, transition_length, Easing.OutExpo); + selectedGlowContainer.FadeIn(transition_length, Easing.OutExpo); + hoverSample?.Play(); + break; + + case SelectionState.NotSelected: + this.ScaleTo(1f, transition_length, Easing.OutExpo); + selectedGlowContainer.FadeOut(transition_length, Easing.OutExpo); + break; + } + } } } diff --git a/osu.Game/Overlays/VolumeOverlay.cs b/osu.Game/Overlays/VolumeOverlay.cs index eb639431ae..a96949e96f 100644 --- a/osu.Game/Overlays/VolumeOverlay.cs +++ b/osu.Game/Overlays/VolumeOverlay.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Threading; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Overlays.Volume; using osuTK; @@ -32,6 +33,8 @@ namespace osu.Game.Overlays public Bindable IsMuted { get; } = new Bindable(); + private SelectionCycleFillFlowContainer volumeMeters; + [BackgroundDependencyLoader] private void load(AudioManager audio, OsuColour colours) { @@ -53,7 +56,7 @@ namespace osu.Game.Overlays Margin = new MarginPadding(10), Current = { BindTarget = IsMuted } }, - new FillFlowContainer + volumeMeters = new SelectionCycleFillFlowContainer { Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Both, @@ -61,7 +64,7 @@ namespace osu.Game.Overlays Origin = Anchor.CentreLeft, Spacing = new Vector2(0, offset), Margin = new MarginPadding { Left = offset }, - Children = new Drawable[] + Children = new[] { volumeMeterEffect = new VolumeMeter("EFFECTS", 125, colours.BlueDarker), volumeMeterMaster = new VolumeMeter("MASTER", 150, colours.PinkDarker), @@ -87,9 +90,9 @@ namespace osu.Game.Overlays { base.LoadComplete(); - volumeMeterMaster.Bindable.ValueChanged += _ => Show(); - volumeMeterEffect.Bindable.ValueChanged += _ => Show(); - volumeMeterMusic.Bindable.ValueChanged += _ => Show(); + foreach (var volumeMeter in volumeMeters) + volumeMeter.Bindable.ValueChanged += _ => Show(); + muteButton.Current.ValueChanged += _ => Show(); } @@ -102,23 +105,27 @@ namespace osu.Game.Overlays case GlobalAction.DecreaseVolume: if (State.Value == Visibility.Hidden) Show(); - else if (volumeMeterMusic.IsHovered) - volumeMeterMusic.Decrease(amount, isPrecise); - else if (volumeMeterEffect.IsHovered) - volumeMeterEffect.Decrease(amount, isPrecise); else - volumeMeterMaster.Decrease(amount, isPrecise); + volumeMeters.Selected?.Decrease(amount, isPrecise); return true; case GlobalAction.IncreaseVolume: if (State.Value == Visibility.Hidden) Show(); - else if (volumeMeterMusic.IsHovered) - volumeMeterMusic.Increase(amount, isPrecise); - else if (volumeMeterEffect.IsHovered) - volumeMeterEffect.Increase(amount, isPrecise); else - volumeMeterMaster.Increase(amount, isPrecise); + volumeMeters.Selected?.Increase(amount, isPrecise); + return true; + + case GlobalAction.NextVolumeMeter: + if (State.Value == Visibility.Visible) + volumeMeters.SelectNext(); + Show(); + return true; + + case GlobalAction.PreviousVolumeMeter: + if (State.Value == Visibility.Visible) + volumeMeters.SelectPrevious(); + Show(); return true; case GlobalAction.ToggleMute: @@ -134,6 +141,10 @@ namespace osu.Game.Overlays public override void Show() { + // Focus on the master meter as a default if previously hidden + if (State.Value == Visibility.Hidden) + volumeMeters.Select(volumeMeterMaster); + if (State.Value == Visibility.Visible) schedulePopOut(); diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs index acaaa523a2..6f0b433acb 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Wiki.Markdown case ParagraphBlock paragraphBlock: // Check if paragraph only contains an image - if (paragraphBlock.Inline.Count() == 1 && paragraphBlock.Inline.FirstChild is LinkInline { IsImage: true } linkInline) + if (paragraphBlock.Inline?.Count() == 1 && paragraphBlock.Inline.FirstChild is LinkInline { IsImage: true } linkInline) { container.Add(new WikiMarkdownImageBlock(linkInline)); return; diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs index c2115efeb5..27d1fe9b2f 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImage.cs @@ -2,19 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using Markdig.Syntax.Inlines; -using osu.Framework.Graphics.Containers.Markdown; -using osu.Framework.Graphics.Cursor; +using osu.Game.Graphics.Containers.Markdown; namespace osu.Game.Overlays.Wiki.Markdown { - public class WikiMarkdownImage : MarkdownImage, IHasTooltip + public class WikiMarkdownImage : OsuMarkdownImage { - public string TooltipText { get; } - public WikiMarkdownImage(LinkInline linkInline) - : base(linkInline.Url) + : base(linkInline) { - TooltipText = linkInline.Title; } protected override ImageContainer CreateImageContainer(string url) diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs index 179762103a..501e00bc00 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers.Markdown; +using osu.Framework.Graphics.Sprites; using osuTK; namespace osu.Game.Overlays.Wiki.Markdown @@ -13,7 +14,7 @@ namespace osu.Game.Overlays.Wiki.Markdown public class WikiMarkdownImageBlock : FillFlowContainer { [Resolved] - private IMarkdownTextComponent parentTextComponent { get; set; } + private IMarkdownTextFlowComponent parentFlowComponent { get; set; } private readonly LinkInline linkInline; @@ -30,20 +31,65 @@ namespace osu.Game.Overlays.Wiki.Markdown [BackgroundDependencyLoader] private void load() { + MarkdownTextFlowContainer textFlow; + Children = new Drawable[] { - new WikiMarkdownImage(linkInline) + new BlockMarkdownImage(linkInline), + textFlow = parentFlowComponent.CreateTextFlow().With(t => { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }, - parentTextComponent.CreateSpriteText().With(t => - { - t.Text = linkInline.Title; t.Anchor = Anchor.TopCentre; t.Origin = Anchor.TopCentre; + t.TextAnchor = Anchor.TopCentre; }), }; + + textFlow.AddText(linkInline.Title); + } + + private class BlockMarkdownImage : WikiMarkdownImage + { + public BlockMarkdownImage(LinkInline linkInline) + : base(linkInline) + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + } + + protected override ImageContainer CreateImageContainer(string url) => new BlockImageContainer(url); + + private class BlockImageContainer : ImageContainer + { + public BlockImageContainer(string url) + : base(url) + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + } + + protected override Sprite CreateImageSprite() => new ImageSprite(); + + private class ImageSprite : Sprite + { + public ImageSprite() + { + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + } + + protected override void Update() + { + base.Update(); + + if (Width > Parent.DrawWidth) + { + float ratio = Height / Width; + Width = Parent.DrawWidth; + Height = ratio * Width; + } + } + } + } } } } diff --git a/osu.Game/Overlays/Wiki/WikiArticlePage.cs b/osu.Game/Overlays/Wiki/WikiArticlePage.cs new file mode 100644 index 0000000000..0061bff8ea --- /dev/null +++ b/osu.Game/Overlays/Wiki/WikiArticlePage.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 Markdig.Syntax; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Game.Overlays.Wiki.Markdown; + +namespace osu.Game.Overlays.Wiki +{ + public class WikiArticlePage : CompositeDrawable + { + public Container SidebarContainer { get; } + + public WikiArticlePage(string currentPath, string markdown) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + WikiSidebar sidebar; + + InternalChild = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + }, + Content = new[] + { + new Drawable[] + { + SidebarContainer = new Container + { + AutoSizeAxes = Axes.X, + Child = sidebar = new WikiSidebar(), + }, + new ArticleMarkdownContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + CurrentPath = currentPath, + Text = markdown, + DocumentMargin = new MarginPadding(0), + DocumentPadding = new MarginPadding + { + Vertical = 20, + Left = 30, + Right = 50, + }, + OnAddHeading = sidebar.AddEntry, + } + }, + }, + }; + } + + private class ArticleMarkdownContainer : WikiMarkdownContainer + { + public Action OnAddHeading; + + protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) + { + var heading = base.CreateHeading(headingBlock); + + OnAddHeading(headingBlock, heading); + + return heading; + } + } + } +} diff --git a/osu.Game/Overlays/Wiki/WikiHeader.cs b/osu.Game/Overlays/Wiki/WikiHeader.cs index 6b8cba48b4..fb87486b4e 100644 --- a/osu.Game/Overlays/Wiki/WikiHeader.cs +++ b/osu.Game/Overlays/Wiki/WikiHeader.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.Wiki @@ -51,7 +52,7 @@ namespace osu.Game.Overlays.Wiki Current.Value = e.NewValue.Title; } - private void onCurrentChange(ValueChangedEvent e) + private void onCurrentChange(ValueChangedEvent e) { if (e.NewValue == TabControl.Items.LastOrDefault()) return; diff --git a/osu.Game/Overlays/Wiki/WikiSidebar.cs b/osu.Game/Overlays/Wiki/WikiSidebar.cs new file mode 100644 index 0000000000..ee4e195f3f --- /dev/null +++ b/osu.Game/Overlays/Wiki/WikiSidebar.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 Markdig.Syntax; +using Markdig.Syntax.Inlines; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Overlays.Wiki +{ + public class WikiSidebar : OverlaySidebar + { + private WikiTableOfContents tableOfContents; + + protected override Drawable CreateContent() => new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "CONTENTS", + Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), + Margin = new MarginPadding { Bottom = 5 }, + }, + tableOfContents = new WikiTableOfContents(), + }, + }; + + public void AddEntry(HeadingBlock headingBlock, MarkdownHeading heading) + { + switch (headingBlock.Level) + { + case 2: + case 3: + tableOfContents.AddEntry(getTitle(headingBlock.Inline), heading, headingBlock.Level == 3); + break; + } + } + + private string getTitle(ContainerInline containerInline) + { + foreach (var inline in containerInline) + { + switch (inline) + { + case LiteralInline literalInline: + return literalInline.Content.ToString(); + + case LinkInline linkInline: + if (!linkInline.IsImage) + return getTitle(linkInline); + + break; + } + } + + return string.Empty; + } + } +} diff --git a/osu.Game/Overlays/Wiki/WikiTableOfContents.cs b/osu.Game/Overlays/Wiki/WikiTableOfContents.cs new file mode 100644 index 0000000000..c0615dce1f --- /dev/null +++ b/osu.Game/Overlays/Wiki/WikiTableOfContents.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.Wiki +{ + public class WikiTableOfContents : CompositeDrawable + { + private readonly FillFlowContainer content; + + private TableOfContentsEntry lastMainTitle; + + private TableOfContentsEntry lastSubTitle; + + public WikiTableOfContents() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + InternalChild = content = new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }; + } + + public void AddEntry(string title, MarkdownHeading target, bool subtitle = false) + { + var entry = new TableOfContentsEntry(title, target, subtitle); + + if (subtitle) + { + lastMainTitle.Margin = new MarginPadding(0); + + if (lastSubTitle != null) + lastSubTitle.Margin = new MarginPadding(0); + + content.Add(lastSubTitle = entry.With(d => d.Margin = new MarginPadding { Bottom = 10 })); + + return; + } + + lastSubTitle = null; + + content.Add(lastMainTitle = entry.With(d => d.Margin = new MarginPadding { Bottom = 5 })); + } + + private class TableOfContentsEntry : OsuHoverContainer + { + private readonly MarkdownHeading target; + + private readonly OsuTextFlowContainer textFlow; + + public TableOfContentsEntry(string text, MarkdownHeading target, bool subtitle = false) + { + this.target = target; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Child = textFlow = new OsuTextFlowContainer(t => + { + t.Font = OsuFont.GetFont(size: subtitle ? 12 : 15); + }).With(f => + { + f.AddText(text); + f.RelativeSizeAxes = Axes.X; + f.AutoSizeAxes = Axes.Y; + f.Margin = new MarginPadding { Bottom = 2 }; + }); + Padding = new MarginPadding { Left = subtitle ? 10 : 0 }; + } + + protected override IEnumerable EffectTargets => new Drawable[] { textFlow }; + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider, OverlayScrollContainer scrollContainer) + { + IdleColour = colourProvider.Light2; + HoverColour = colourProvider.Light1; + Action = () => scrollContainer.ScrollTo(target); + } + } + } +} diff --git a/osu.Game/Overlays/WikiOverlay.cs b/osu.Game/Overlays/WikiOverlay.cs index af7bc40f17..bde73b6180 100644 --- a/osu.Game/Overlays/WikiOverlay.cs +++ b/osu.Game/Overlays/WikiOverlay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using System.Threading; using osu.Framework.Allocation; @@ -10,7 +11,6 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.Wiki; -using osu.Game.Overlays.Wiki.Markdown; namespace osu.Game.Overlays { @@ -31,6 +31,8 @@ namespace osu.Game.Overlays private bool displayUpdateRequired = true; + private WikiArticlePage articlePage; + public WikiOverlay() : base(OverlayColourScheme.Orange, false) { @@ -82,6 +84,17 @@ namespace osu.Game.Overlays }, (cancellationToken = new CancellationTokenSource()).Token); } + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (articlePage != null) + { + articlePage.SidebarContainer.Height = DrawHeight; + articlePage.SidebarContainer.Y = Math.Clamp(ScrollFlow.Current - Header.DrawHeight, 0, Math.Max(ScrollFlow.ScrollContent.DrawHeight - DrawHeight - Header.DrawHeight, 0)); + } + } + private void onPathChanged(ValueChangedEvent e) { cancellationToken?.Cancel(); @@ -92,7 +105,7 @@ namespace osu.Game.Overlays Loading.Show(); request.Success += response => Schedule(() => onSuccess(response)); - request.Failure += _ => Schedule(() => LoadDisplay(Empty())); + request.Failure += _ => Schedule(onFail); api.PerformAsync(request); } @@ -115,23 +128,16 @@ namespace osu.Game.Overlays } else { - LoadDisplay(new WikiMarkdownContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - CurrentPath = $@"{api.WebsiteRootUrl}/wiki/{path.Value}/", - Text = response.Markdown, - DocumentMargin = new MarginPadding(0), - DocumentPadding = new MarginPadding - { - Vertical = 20, - Left = 30, - Right = 50, - }, - }); + LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/{path.Value}/", response.Markdown)); } } + private void onFail() + { + LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/", + $"Something went wrong when trying to fetch page \"{path.Value}\".\n\n[Return to the main page](Main_Page).")); + } + private void showParentPage() { var parentPath = string.Join("/", path.Value.Split('/').SkipLast(1)); diff --git a/osu.Game/Performance/HighPerformanceSession.cs b/osu.Game/Performance/HighPerformanceSession.cs new file mode 100644 index 0000000000..661c1046f1 --- /dev/null +++ b/osu.Game/Performance/HighPerformanceSession.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; + +namespace osu.Game.Performance +{ + public class HighPerformanceSession : Component + { + private readonly IBindable localUserPlaying = new Bindable(); + + [BackgroundDependencyLoader] + private void load(OsuGame game) + { + localUserPlaying.BindTo(game.LocalUserPlaying); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + localUserPlaying.BindValueChanged(playing => + { + if (playing.NewValue) + EnableHighPerformanceSession(); + else + DisableHighPerformanceSession(); + }, true); + } + + protected virtual void EnableHighPerformanceSession() + { + } + + protected virtual void DisableHighPerformanceSession() + { + } + } +} diff --git a/osu.Game/Replays/Replay.cs b/osu.Game/Replays/Replay.cs index 5430915394..30e176b5c7 100644 --- a/osu.Game/Replays/Replay.cs +++ b/osu.Game/Replays/Replay.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Replays; +using osu.Game.Utils; namespace osu.Game.Replays { - public class Replay + public class Replay : IDeepCloneable { /// /// Whether all frames for this replay have been received. @@ -15,5 +17,15 @@ namespace osu.Game.Replays public bool HasReceivedAllFrames = true; public List Frames = new List(); + + public Replay DeepClone() + { + return new Replay + { + HasReceivedAllFrames = HasReceivedAllFrames, + // individual frames are mutable for now but hopefully this will not be a thing in the future. + Frames = Frames.ToList(), + }; + } } } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 732dc772b7..6bb780a68b 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -8,11 +8,11 @@ namespace osu.Game.Rulesets.Difficulty { public class DifficultyAttributes { - public Mod[] Mods; - public Skill[] Skills; + public Mod[] Mods { get; set; } + public Skill[] Skills { get; set; } - public double StarRating; - public int MaxCombo; + public double StarRating { get; set; } + public int MaxCombo { get; set; } public DifficultyAttributes() { diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 5780fe39fa..224c9178ae 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Difficulty /// A structure describing the difficulty of the beatmap. public DifficultyAttributes Calculate(params Mod[] mods) { - mods = mods.Select(m => m.CreateCopy()).ToArray(); + mods = mods.Select(m => m.DeepClone()).ToArray(); IBeatmap playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, mods); @@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Difficulty private DifficultyAttributes calculate(IBeatmap beatmap, Mod[] mods, double clockRate) { - var skills = CreateSkills(beatmap, mods); + var skills = CreateSkills(beatmap, mods, clockRate); if (!beatmap.HitObjects.Any()) return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); @@ -180,7 +180,8 @@ namespace osu.Game.Rulesets.Difficulty /// /// The whose difficulty will be calculated. /// Mods to calculate difficulty with. + /// Clockrate to calculate difficulty with. /// The s. - protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods); + protected abstract Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate); } } diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index d208c7fe07..81f4808789 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -22,10 +22,13 @@ namespace osu.Game.Rulesets.Edit // Audio new CheckAudioPresence(), new CheckAudioQuality(), + new CheckMutedObjects(), + new CheckFewHitsounds(), // Compose new CheckUnsnappedObjects(), - new CheckConcurrentObjects() + new CheckConcurrentObjects(), + new CheckZeroLengthObjects(), }; public IEnumerable Run(BeatmapVerifierContext context) diff --git a/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs b/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.cs new file mode 100644 index 0000000000..5185ba6c99 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckFewHitsounds.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.Collections.Generic; +using System.Linq; +using osu.Game.Audio; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckFewHitsounds : ICheck + { + /// + /// 2 measures (4/4) of 120 BPM, typically makes up a few patterns in the map. + /// This is almost always ok, but can still be useful for the mapper to make sure hitsounding coverage is good. + /// + private const int negligible_threshold_time = 4000; + + /// + /// 4 measures (4/4) of 120 BPM, typically makes up a large portion of a section in the song. + /// This is ok if the section is a quiet intro, for example. + /// + private const int warning_threshold_time = 8000; + + /// + /// 12 measures (4/4) of 120 BPM, typically makes up multiple sections in the song. + /// + private const int problem_threshold_time = 24000; + + // Should pass at least this many objects without hitsounds to be considered an issue (should work for Easy diffs too). + private const int warning_threshold_objects = 4; + private const int problem_threshold_objects = 16; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Few or no hitsounds"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateLongPeriodProblem(this), + new IssueTemplateLongPeriodWarning(this), + new IssueTemplateLongPeriodNegligible(this), + new IssueTemplateNoHitsounds(this) + }; + + private bool mapHasHitsounds; + private int objectsWithoutHitsounds; + private double lastHitsoundTime; + + public IEnumerable Run(BeatmapVerifierContext context) + { + if (!context.Beatmap.HitObjects.Any()) + yield break; + + mapHasHitsounds = false; + objectsWithoutHitsounds = 0; + lastHitsoundTime = context.Beatmap.HitObjects.First().StartTime; + + var hitObjectsIncludingNested = new List(); + + foreach (var hitObject in context.Beatmap.HitObjects) + { + // Samples play on the end of objects. Some objects have nested objects to accomplish playing them elsewhere (e.g. slider head/repeat). + foreach (var nestedHitObject in hitObject.NestedHitObjects) + hitObjectsIncludingNested.Add(nestedHitObject); + + hitObjectsIncludingNested.Add(hitObject); + } + + var hitObjectsByEndTime = hitObjectsIncludingNested.OrderBy(o => o.GetEndTime()).ToList(); + var hitObjectCount = hitObjectsByEndTime.Count; + + for (int i = 0; i < hitObjectCount; ++i) + { + var hitObject = hitObjectsByEndTime[i]; + + // This is used to perform an update at the end so that the period after the last hitsounded object can be an issue. + bool isLastObject = i == hitObjectCount - 1; + + foreach (var issue in applyHitsoundUpdate(hitObject, isLastObject)) + yield return issue; + } + + if (!mapHasHitsounds) + yield return new IssueTemplateNoHitsounds(this).Create(); + } + + private IEnumerable applyHitsoundUpdate(HitObject hitObject, bool isLastObject = false) + { + var time = hitObject.GetEndTime(); + bool hasHitsound = hitObject.Samples.Any(isHitsound); + bool couldHaveHitsound = hitObject.Samples.Any(isHitnormal); + + // Only generating issues on hitsounded or last objects ensures we get one issue per long period. + // If there are no hitsounds we let the "No hitsounds" template take precedence. + if (hasHitsound || (isLastObject && mapHasHitsounds)) + { + var timeWithoutHitsounds = time - lastHitsoundTime; + + if (timeWithoutHitsounds > problem_threshold_time && objectsWithoutHitsounds > problem_threshold_objects) + yield return new IssueTemplateLongPeriodProblem(this).Create(lastHitsoundTime, timeWithoutHitsounds); + else if (timeWithoutHitsounds > warning_threshold_time && objectsWithoutHitsounds > warning_threshold_objects) + yield return new IssueTemplateLongPeriodWarning(this).Create(lastHitsoundTime, timeWithoutHitsounds); + else if (timeWithoutHitsounds > negligible_threshold_time && objectsWithoutHitsounds > warning_threshold_objects) + yield return new IssueTemplateLongPeriodNegligible(this).Create(lastHitsoundTime, timeWithoutHitsounds); + } + + if (hasHitsound) + { + mapHasHitsounds = true; + objectsWithoutHitsounds = 0; + lastHitsoundTime = time; + } + else if (couldHaveHitsound) + ++objectsWithoutHitsounds; + } + + private bool isHitsound(HitSampleInfo sample) => HitSampleInfo.AllAdditions.Any(sample.Name.Contains); + private bool isHitnormal(HitSampleInfo sample) => sample.Name.Contains(HitSampleInfo.HIT_NORMAL); + + public abstract class IssueTemplateLongPeriod : IssueTemplate + { + protected IssueTemplateLongPeriod(ICheck check, IssueType type) + : base(check, type, "Long period without hitsounds ({0:F1} seconds).") + { + } + + public Issue Create(double time, double duration) => new Issue(this, duration / 1000f) { Time = time }; + } + + public class IssueTemplateLongPeriodProblem : IssueTemplateLongPeriod + { + public IssueTemplateLongPeriodProblem(ICheck check) + : base(check, IssueType.Problem) + { + } + } + + public class IssueTemplateLongPeriodWarning : IssueTemplateLongPeriod + { + public IssueTemplateLongPeriodWarning(ICheck check) + : base(check, IssueType.Warning) + { + } + } + + public class IssueTemplateLongPeriodNegligible : IssueTemplateLongPeriod + { + public IssueTemplateLongPeriodNegligible(ICheck check) + : base(check, IssueType.Negligible) + { + } + } + + public class IssueTemplateNoHitsounds : IssueTemplate + { + public IssueTemplateNoHitsounds(ICheck check) + : base(check, IssueType.Problem, "There are no hitsounds.") + { + } + + public Issue Create() => new Issue(this); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs new file mode 100644 index 0000000000..a4ff921b7e --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckMutedObjects.cs @@ -0,0 +1,158 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Utils; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckMutedObjects : ICheck + { + /// + /// Volume percentages lower than or equal to this are typically inaudible. + /// + private const int muted_threshold = 5; + + /// + /// Volume percentages lower than or equal to this can sometimes be inaudible depending on sample used and music volume. + /// + private const int low_volume_threshold = 20; + + private enum EdgeType + { + Head, + Repeat, + Tail, + None + } + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Audio, "Low volume hitobjects"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateMutedActive(this), + new IssueTemplateLowVolumeActive(this), + new IssueTemplateMutedPassive(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + foreach (var hitObject in context.Beatmap.HitObjects) + { + // Worth keeping in mind: The samples of an object always play at its end time. + // Objects like spinners have no sound at its start because of this, while hold notes have nested objects to accomplish this. + foreach (var nestedHitObject in hitObject.NestedHitObjects) + { + foreach (var issue in getVolumeIssues(hitObject, nestedHitObject)) + yield return issue; + } + + foreach (var issue in getVolumeIssues(hitObject)) + yield return issue; + } + } + + private IEnumerable getVolumeIssues(HitObject hitObject, HitObject sampledHitObject = null) + { + sampledHitObject ??= hitObject; + if (!sampledHitObject.Samples.Any()) + yield break; + + // Samples that allow themselves to be overridden by control points have a volume of 0. + int maxVolume = sampledHitObject.Samples.Max(sample => sample.Volume > 0 ? sample.Volume : sampledHitObject.SampleControlPoint.SampleVolume); + double samplePlayTime = sampledHitObject.GetEndTime(); + + EdgeType edgeType = getEdgeAtTime(hitObject, samplePlayTime); + // We only care about samples played on the edges of objects, not ones like spinnerspin or slidertick. + if (edgeType == EdgeType.None) + yield break; + + string postfix = hitObject is IHasDuration ? edgeType.ToString().ToLower() : null; + + if (maxVolume <= muted_threshold) + { + if (edgeType == EdgeType.Head) + yield return new IssueTemplateMutedActive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix); + else + yield return new IssueTemplateMutedPassive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix); + } + else if (maxVolume <= low_volume_threshold && edgeType == EdgeType.Head) + { + yield return new IssueTemplateLowVolumeActive(this).Create(hitObject, maxVolume / 100f, sampledHitObject.GetEndTime(), postfix); + } + } + + private EdgeType getEdgeAtTime(HitObject hitObject, double time) + { + if (Precision.AlmostEquals(time, hitObject.StartTime, 1f)) + return EdgeType.Head; + if (Precision.AlmostEquals(time, hitObject.GetEndTime(), 1f)) + return EdgeType.Tail; + + if (hitObject is IHasRepeats hasRepeats) + { + double spanDuration = hasRepeats.Duration / hasRepeats.SpanCount(); + if (spanDuration <= 0) + // Prevents undefined behaviour in cases like where zero/negative-length sliders/hold notes exist. + return EdgeType.None; + + double spans = (time - hitObject.StartTime) / spanDuration; + double acceptableDifference = 1 / spanDuration; // 1 ms of acceptable difference, as with head/tail above. + + if (Precision.AlmostEquals(spans, Math.Ceiling(spans), acceptableDifference) || + Precision.AlmostEquals(spans, Math.Floor(spans), acceptableDifference)) + { + return EdgeType.Repeat; + } + } + + return EdgeType.None; + } + + public abstract class IssueTemplateMuted : IssueTemplate + { + protected IssueTemplateMuted(ICheck check, IssueType type, string unformattedMessage) + : base(check, type, unformattedMessage) + { + } + + public Issue Create(HitObject hitobject, double volume, double time, string postfix = "") + { + string objectName = hitobject.GetType().Name; + if (!string.IsNullOrEmpty(postfix)) + objectName += " " + postfix; + + return new Issue(hitobject, this, objectName, volume) { Time = time }; + } + } + + public class IssueTemplateMutedActive : IssueTemplateMuted + { + public IssueTemplateMutedActive(ICheck check) + : base(check, IssueType.Problem, "{0} has a volume of {1:0%}. Clickable objects must have clearly audible feedback.") + { + } + } + + public class IssueTemplateLowVolumeActive : IssueTemplateMuted + { + public IssueTemplateLowVolumeActive(ICheck check) + : base(check, IssueType.Warning, "{0} has a volume of {1:0%}, ensure this is audible.") + { + } + } + + public class IssueTemplateMutedPassive : IssueTemplateMuted + { + public IssueTemplateMutedPassive(ICheck check) + : base(check, IssueType.Negligible, "{0} has a volume of {1:0%}, ensure there is no distinct sound here in the song if inaudible.") + { + } + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckZeroLengthObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckZeroLengthObjects.cs new file mode 100644 index 0000000000..b9be94736b --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckZeroLengthObjects.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 osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckZeroLengthObjects : ICheck + { + /// + /// The duration can be this low before being treated as having no length, in case of precision errors. Unit is milliseconds. + /// + private const double leniency = 0.5d; + + public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Compose, "Zero-length hitobjects"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateZeroLength(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + foreach (var hitObject in context.Beatmap.HitObjects) + { + if (!(hitObject is IHasDuration hasDuration)) + continue; + + if (hasDuration.Duration < leniency) + yield return new IssueTemplateZeroLength(this).Create(hitObject, hasDuration.Duration); + } + } + + public class IssueTemplateZeroLength : IssueTemplate + { + public IssueTemplateZeroLength(ICheck check) + : base(check, IssueType.Problem, "{0} has a duration of {1:0}.") + { + } + + public Issue Create(HitObject hitobject, double duration) => new Issue(hitobject, this, hitobject.GetType(), duration); + } + } +} diff --git a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs index 8166e6b8ce..071f01ca00 100644 --- a/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs +++ b/osu.Game/Rulesets/Edit/DrawableEditorRulesetWrapper.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit; @@ -52,15 +54,21 @@ namespace osu.Game.Rulesets.Edit if (changeHandler != null) { // for now only regenerate replay on a finalised state change, not HitObjectUpdated. - changeHandler.OnStateChange += updateReplay; + changeHandler.OnStateChange += () => Scheduler.AddOnce(regenerateAutoplay); } else { - beatmap.HitObjectUpdated += _ => updateReplay(); + beatmap.HitObjectUpdated += _ => Scheduler.AddOnce(regenerateAutoplay); } + + Scheduler.AddOnce(regenerateAutoplay); } - private void updateReplay() => Scheduler.AddOnce(drawableRuleset.RegenerateAutoplay); + private void regenerateAutoplay() + { + var autoplayMod = drawableRuleset.Mods.OfType().Single(); + drawableRuleset.SetReplayScore(autoplayMod.CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods)); + } private void addHitObject(HitObject hitObject) { diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index b47cf97a4d..8090fcbd32 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -6,6 +6,7 @@ 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; @@ -43,6 +44,9 @@ namespace osu.Game.Rulesets.Edit protected readonly Ruleset Ruleset; + // Provides `Playfield` + private DependencyContainer dependencies; + [Resolved] protected EditorClock EditorClock { get; private set; } @@ -60,15 +64,20 @@ namespace osu.Game.Rulesets.Edit private InputManager inputManager; - private RadioButtonCollection toolboxCollection; + private EditorRadioButtonCollection toolboxCollection; private FillFlowContainer togglesCollection; + private IBindable hasTiming; + protected HitObjectComposer(Ruleset ruleset) { Ruleset = ruleset; } + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => + dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + [BackgroundDependencyLoader] private void load() { @@ -88,6 +97,8 @@ namespace osu.Game.Rulesets.Edit return; } + dependencies.CacheAs(Playfield); + const float toolbar_width = 200; InternalChildren = new Drawable[] @@ -118,7 +129,7 @@ namespace osu.Game.Rulesets.Edit { new ToolboxGroup("toolbox (1-9)") { - Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X } + Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X } }, new ToolboxGroup("toggles (Q~P)") { @@ -152,6 +163,14 @@ namespace osu.Game.Rulesets.Edit base.LoadComplete(); inputManager = GetContainingInputManager(); + + hasTiming = EditorBeatmap.HasTiming.GetBoundCopy(); + hasTiming.BindValueChanged(timing => + { + // it's important this is performed before the similar code in EditorRadioButton disables the button. + if (!timing.NewValue) + setSelectTool(); + }); } public override Playfield Playfield => drawableRulesetWrapper.Playfield; @@ -211,7 +230,8 @@ namespace osu.Game.Rulesets.Edit if (item != null) { - item.Select(); + if (!item.Selected.Disabled) + item.Select(); return true; } } diff --git a/osu.Game/Rulesets/Edit/HitObjectSelectionBlueprint.cs b/osu.Game/Rulesets/Edit/HitObjectSelectionBlueprint.cs index 56434b1d82..77dc55c6ef 100644 --- a/osu.Game/Rulesets/Edit/HitObjectSelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/HitObjectSelectionBlueprint.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Edit /// protected virtual bool AlwaysShowWhenSelected => false; - protected override bool ShouldBeAlive => (DrawableObject.IsAlive && DrawableObject.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected); + protected override bool ShouldBeAlive => (DrawableObject?.IsAlive == true && DrawableObject.IsPresent) || (AlwaysShowWhenSelected && State == SelectionState.Selected); protected HitObjectSelectionBlueprint(HitObject hitObject) : base(hitObject) diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index feeafb7151..0f22d35bb5 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.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.Diagnostics; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -32,18 +31,6 @@ namespace osu.Game.Rulesets.Judgements private readonly Container aboveHitObjectsContent; - /// - /// Duration of initial fade in. - /// - [Obsolete("Apply any animations manually via ApplyHitAnimations / ApplyMissAnimations. Defaults were moved inside skinned components.")] - protected virtual double FadeInDuration => 100; - - /// - /// Duration to wait until fade out begins. Defaults to . - /// - [Obsolete("Apply any animations manually via ApplyHitAnimations / ApplyMissAnimations. Defaults were moved inside skinned components.")] - protected virtual double FadeOutDelay => FadeInDuration; - /// /// Creates a drawable which visualises a . /// @@ -130,7 +117,7 @@ namespace osu.Game.Rulesets.Judgements LifetimeStart = Result.TimeAbsolute; - using (BeginAbsoluteSequence(Result.TimeAbsolute, true)) + using (BeginAbsoluteSequence(Result.TimeAbsolute)) { // not sure if this should remain going forward. JudgementBody.ResetAnimation(); diff --git a/osu.Game/Rulesets/Judgements/Judgement.cs b/osu.Game/Rulesets/Judgements/Judgement.cs index be69db5ca8..fd576e9b9f 100644 --- a/osu.Game/Rulesets/Judgements/Judgement.cs +++ b/osu.Game/Rulesets/Judgements/Judgement.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 osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -69,14 +68,6 @@ namespace osu.Game.Rulesets.Judgements /// public double MaxHealthIncrease => HealthIncreaseFor(MaxResult); - /// - /// Retrieves the numeric score representation of a . - /// - /// The to find the numeric score representation for. - /// The numeric score representation of . - [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); - /// /// Retrieves the numeric score representation of a . /// diff --git a/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs b/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.cs new file mode 100644 index 0000000000..067657159b --- /dev/null +++ b/osu.Game/Rulesets/Mods/DifficultyAdjustSettingsControl.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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Beatmaps; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Rulesets.Mods +{ + public class DifficultyAdjustSettingsControl : SettingsItem + { + [Resolved] + private IBindable beatmap { get; set; } + + /// + /// Used to track the display value on the setting slider. + /// + /// + /// When the mod is overriding a default, this will match the value of . + /// When there is no override (ie. is null), this value will match the beatmap provided default via . + /// + private readonly BindableNumber sliderDisplayCurrent = new BindableNumber(); + + protected override Drawable CreateControl() => new SliderControl(sliderDisplayCurrent); + + /// + /// Guards against beatmap values displayed on slider bars being transferred to user override. + /// + private bool isInternalChange; + + private DifficultyBindable difficultyBindable; + + public override Bindable Current + { + get => base.Current; + set + { + // Intercept and extract the internal number bindable from DifficultyBindable. + // This will provide bounds and precision specifications for the slider bar. + difficultyBindable = ((DifficultyBindable)value).GetBoundCopy(); + sliderDisplayCurrent.BindTo(difficultyBindable.CurrentNumber); + + base.Current = difficultyBindable; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(current => updateCurrentFromSlider()); + beatmap.BindValueChanged(b => updateCurrentFromSlider(), true); + + sliderDisplayCurrent.BindValueChanged(number => + { + // this handles the transfer of the slider value to the main bindable. + // as such, should be skipped if the slider is being updated via updateFromDifficulty(). + if (!isInternalChange) + Current.Value = number.NewValue; + }); + } + + private void updateCurrentFromSlider() + { + if (Current.Value != null) + { + // a user override has been added or updated. + sliderDisplayCurrent.Value = Current.Value.Value; + return; + } + + var difficulty = beatmap.Value.BeatmapInfo.BaseDifficulty; + + if (difficulty == null) + return; + + // generally should always be implemented, else the slider will have a zero default. + if (difficultyBindable.ReadCurrentFromDifficulty == null) + return; + + isInternalChange = true; + sliderDisplayCurrent.Value = difficultyBindable.ReadCurrentFromDifficulty(difficulty); + isInternalChange = false; + } + + private class SliderControl : CompositeDrawable, IHasCurrentValue + { + // This is required as SettingsItem relies heavily on this bindable for internal use. + // The actual update flow is done via the bindable provided in the constructor. + public Bindable Current { get; set; } = new Bindable(); + + public SliderControl(BindableNumber currentNumber) + { + InternalChildren = new Drawable[] + { + new SettingsSlider + { + ShowsDefaultIndicator = false, + Current = currentNumber, + } + }; + + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + } + } + } +} diff --git a/osu.Game/Rulesets/Mods/DifficultyBindable.cs b/osu.Game/Rulesets/Mods/DifficultyBindable.cs new file mode 100644 index 0000000000..664b88eef4 --- /dev/null +++ b/osu.Game/Rulesets/Mods/DifficultyBindable.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 osu.Framework.Bindables; +using osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Mods +{ + public class DifficultyBindable : Bindable + { + /// + /// Whether the extended limits should be applied to this bindable. + /// + public readonly BindableBool ExtendedLimits = new BindableBool(); + + /// + /// An internal numeric bindable to hold and propagate min/max/precision. + /// The value of this bindable should not be set. + /// + internal readonly BindableFloat CurrentNumber = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + }; + + /// + /// A function that can extract the current value of this setting from a beatmap difficulty for display purposes. + /// + public Func ReadCurrentFromDifficulty; + + public float Precision + { + set => CurrentNumber.Precision = value; + } + + public float MinValue + { + set => CurrentNumber.MinValue = value; + } + + private float maxValue; + + public float MaxValue + { + set + { + if (value == maxValue) + return; + + maxValue = value; + updateMaxValue(); + } + } + + private float? extendedMaxValue; + + /// + /// The maximum value to be used when extended limits are applied. + /// + public float? ExtendedMaxValue + { + set + { + if (value == extendedMaxValue) + return; + + extendedMaxValue = value; + updateMaxValue(); + } + } + + public DifficultyBindable() + : this(null) + { + } + + public DifficultyBindable(float? defaultValue = null) + : base(defaultValue) + { + ExtendedLimits.BindValueChanged(_ => updateMaxValue()); + } + + public override float? Value + { + get => base.Value; + set + { + // Ensure that in the case serialisation runs in the wrong order (and limit extensions aren't applied yet) the deserialised value is still propagated. + if (value != null) + CurrentNumber.MaxValue = MathF.Max(CurrentNumber.MaxValue, value.Value); + + base.Value = value; + } + } + + private void updateMaxValue() + { + CurrentNumber.MaxValue = ExtendedLimits.Value && extendedMaxValue != null ? extendedMaxValue.Value : maxValue; + } + + public override void BindTo(Bindable them) + { + if (!(them is DifficultyBindable otherDifficultyBindable)) + throw new InvalidOperationException($"Cannot bind to a non-{nameof(DifficultyBindable)}."); + + ReadCurrentFromDifficulty = otherDifficultyBindable.ReadCurrentFromDifficulty; + + // the following max value copies are only safe as long as these values are effectively constants. + MaxValue = otherDifficultyBindable.maxValue; + ExtendedMaxValue = otherDifficultyBindable.extendedMaxValue; + + ExtendedLimits.BindTarget = otherDifficultyBindable.ExtendedLimits; + + // the actual values need to be copied after the max value constraints. + CurrentNumber.BindTarget = otherDifficultyBindable.CurrentNumber; + base.BindTo(them); + } + + public override void UnbindFrom(IUnbindable them) + { + if (!(them is DifficultyBindable otherDifficultyBindable)) + throw new InvalidOperationException($"Cannot unbind from a non-{nameof(DifficultyBindable)}."); + + base.UnbindFrom(them); + + CurrentNumber.UnbindFrom(otherDifficultyBindable.CurrentNumber); + ExtendedLimits.UnbindFrom(otherDifficultyBindable.ExtendedLimits); + } + + public new DifficultyBindable GetBoundCopy() => new DifficultyBindable { BindTarget = this }; + } +} diff --git a/osu.Game/Rulesets/Mods/IApplicableToBeatmapProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableToBeatmapProcessor.cs new file mode 100644 index 0000000000..e23a5d8d99 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableToBeatmapProcessor.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Beatmaps; + +namespace osu.Game.Rulesets.Mods +{ + /// + /// Interface for a that applies changes to a . + /// + public interface IApplicableToBeatmapProcessor : IApplicableMod + { + /// + /// Applies this to a . + /// + void ApplyToBeatmapProcessor(IBeatmapProcessor beatmapProcessor); + } +} diff --git a/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs b/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs index 34198da722..42b520ab26 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs @@ -10,13 +10,6 @@ namespace osu.Game.Rulesets.Mods /// public interface IApplicableToDifficulty : IApplicableMod { - /// - /// Called when a beatmap is changed. Can be used to read default values. - /// Any changes made will not be preserved. - /// - /// The difficulty to read from. - void ReadFromDifficulty(BeatmapDifficulty difficulty); - /// /// Called post beatmap conversion. Can be used to apply changes to difficulty attributes. /// diff --git a/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObject.cs b/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObject.cs index 5630315770..c8a9ff2f9a 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObject.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObject.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using osu.Game.Rulesets.Objects.Drawables; namespace osu.Game.Rulesets.Mods @@ -9,13 +8,12 @@ namespace osu.Game.Rulesets.Mods /// /// An interface for s that can be applied to s. /// - public interface IApplicableToDrawableHitObjects : IApplicableMod + public interface IApplicableToDrawableHitObject : IApplicableMod { /// - /// Applies this to a list of s. + /// Applies this to a . /// This will only be invoked with top-level s. Access if adjusting nested objects is necessary. /// - /// The list of s to apply to. - void ApplyToDrawableHitObjects(IEnumerable drawables); + void ApplyToDrawableHitObject(DrawableHitObject drawable); } } diff --git a/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObjects.cs b/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObjects.cs new file mode 100644 index 0000000000..7f926dd8b8 --- /dev/null +++ b/osu.Game/Rulesets/Mods/IApplicableToDrawableHitObjects.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; +using System.Collections.Generic; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.Mods +{ + [Obsolete(@"Use the singular version IApplicableToDrawableHitObject instead.")] // Can be removed 20211216 + public interface IApplicableToDrawableHitObjects : IApplicableToDrawableHitObject + { + void ApplyToDrawableHitObjects(IEnumerable drawables); + + void IApplicableToDrawableHitObject.ApplyToDrawableHitObject(DrawableHitObject drawable) => ApplyToDrawableHitObjects(drawable.Yield()); + } +} diff --git a/osu.Game/Rulesets/Mods/ICreateReplay.cs b/osu.Game/Rulesets/Mods/ICreateReplay.cs new file mode 100644 index 0000000000..098bd8799a --- /dev/null +++ b/osu.Game/Rulesets/Mods/ICreateReplay.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 System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Mods +{ + public interface ICreateReplay + { + public Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList mods); + } +} diff --git a/osu.Game/Rulesets/Mods/IHasSeed.cs b/osu.Game/Rulesets/Mods/IHasSeed.cs new file mode 100644 index 0000000000..001a9d214c --- /dev/null +++ b/osu.Game/Rulesets/Mods/IHasSeed.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.Framework.Bindables; + +namespace osu.Game.Rulesets.Mods +{ + public interface IHasSeed + { + Bindable Seed { get; } + } +} diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 7f48888abe..f2fd02c652 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mods /// The base class for gameplay modifiers. /// [ExcludeFromDynamicCompile] - public abstract class Mod : IMod, IEquatable, IJsonSerializable + public abstract class Mod : IMod, IEquatable, IJsonSerializable, IDeepCloneable { /// /// The name of this mod. @@ -108,9 +108,13 @@ namespace osu.Game.Rulesets.Mods public virtual bool HasImplementation => this is IApplicableMod; /// - /// Returns if this mod is ranked. + /// Whether this mod is playable by an end user. + /// Should be false for cases where the user is not interacting with the game (so it can be excluded from mutliplayer selection, for example). /// [JsonIgnore] + public virtual bool UserPlayable => true; + + [Obsolete("Going forward, the concept of \"ranked\" doesn't exist. The only exceptions are automation mods, which should now override and set UserPlayable to false.")] // Can be removed 20211009 public virtual bool Ranked => false; /// @@ -128,7 +132,7 @@ namespace osu.Game.Rulesets.Mods /// /// Creates a copy of this initialised to a default state. /// - public virtual Mod CreateCopy() + public virtual Mod DeepClone() { var result = (Mod)Activator.CreateInstance(GetType()); result.CopyFrom(this); diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index d6e1d46b06..4849d6ea36 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -7,22 +7,11 @@ using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Replays; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.UI; using osu.Game.Scoring; namespace osu.Game.Rulesets.Mods { - public abstract class ModAutoplay : ModAutoplay, IApplicableToDrawableRuleset - where T : HitObject - { - public virtual void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) - { - drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods)); - } - } - - public abstract class ModAutoplay : Mod, IApplicableFailOverride + public abstract class ModAutoplay : Mod, IApplicableFailOverride, ICreateReplay { public override string Name => "Autoplay"; public override string Acronym => "AT"; @@ -35,6 +24,8 @@ namespace osu.Game.Rulesets.Mods public bool RestartOnFail => false; + public override bool UserPlayable => false; + public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail) }; public override bool HasImplementation => GetType().GenericTypeArguments.Length == 0; diff --git a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs index 0d344b5269..872daadd46 100644 --- a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs +++ b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mods }; [SettingSource("Direction", "The direction of rotation")] - public Bindable Direction { get; } = new Bindable(RotationDirection.Clockwise); + public Bindable Direction { get; } = new Bindable(); public override string Name => "Barrel Roll"; public override string Acronym => "BR"; diff --git a/osu.Game/Rulesets/Mods/ModClassic.cs b/osu.Game/Rulesets/Mods/ModClassic.cs index f1207ec188..1159955e11 100644 --- a/osu.Game/Rulesets/Mods/ModClassic.cs +++ b/osu.Game/Rulesets/Mods/ModClassic.cs @@ -17,8 +17,6 @@ namespace osu.Game.Rulesets.Mods public override string Description => "Feeling nostalgic?"; - public override bool Ranked => false; - public override ModType Type => ModType.Conversion; } } diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index b70eee4e1d..b78c30e8a5 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -1,13 +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.Beatmaps; +using System; +using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; -using System; -using System.Collections.Generic; +using osu.Game.Beatmaps; using osu.Game.Configuration; -using System.Linq; namespace osu.Game.Rulesets.Mods { @@ -33,24 +32,24 @@ namespace osu.Game.Rulesets.Mods protected const int LAST_SETTING_ORDER = 2; - [SettingSource("HP Drain", "Override a beatmap's set HP.", FIRST_SETTING_ORDER)] - public BindableNumber DrainRate { get; } = new BindableFloatWithLimitExtension + [SettingSource("HP Drain", "Override a beatmap's set HP.", FIRST_SETTING_ORDER, SettingControlType = typeof(DifficultyAdjustSettingsControl))] + public DifficultyBindable DrainRate { get; } = new DifficultyBindable { Precision = 0.1f, MinValue = 0, MaxValue = 10, - Default = 5, - Value = 5, + ExtendedMaxValue = 11, + ReadCurrentFromDifficulty = diff => diff.DrainRate, }; - [SettingSource("Accuracy", "Override a beatmap's set OD.", LAST_SETTING_ORDER)] - public BindableNumber OverallDifficulty { get; } = new BindableFloatWithLimitExtension + [SettingSource("Accuracy", "Override a beatmap's set OD.", LAST_SETTING_ORDER, SettingControlType = typeof(DifficultyAdjustSettingsControl))] + public DifficultyBindable OverallDifficulty { get; } = new DifficultyBindable { Precision = 0.1f, MinValue = 0, MaxValue = 10, - Default = 5, - Value = 5, + ExtendedMaxValue = 11, + ReadCurrentFromDifficulty = diff => diff.OverallDifficulty, }; [SettingSource("Extended Limits", "Adjust difficulty beyond sane limits.")] @@ -58,17 +57,11 @@ namespace osu.Game.Rulesets.Mods protected ModDifficultyAdjust() { - ExtendedLimits.BindValueChanged(extend => ApplyLimits(extend.NewValue)); - } - - /// - /// Changes the difficulty adjustment limits. Occurs when the value of is changed. - /// - /// Whether limits should extend beyond sane ranges. - protected virtual void ApplyLimits(bool extended) - { - DrainRate.MaxValue = extended ? 11 : 10; - OverallDifficulty.MaxValue = extended ? 11 : 10; + foreach (var (_, property) in this.GetOrderedSettingsSourceProperties()) + { + if (property.GetValue(this) is DifficultyBindable diffAdjustBindable) + diffAdjustBindable.ExtendedLimits.BindTo(ExtendedLimits); + } } public override string SettingDescription @@ -86,146 +79,20 @@ namespace osu.Game.Rulesets.Mods } } - 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); - /// - /// Transfer initial settings from the beatmap to settings. - /// - /// The beatmap's initial values. - protected virtual void TransferSettings(BeatmapDifficulty difficulty) - { - TransferSetting(DrainRate, difficulty.DrainRate); - TransferSetting(OverallDifficulty, difficulty.OverallDifficulty); - } - - private readonly Dictionary userChangedSettings = new Dictionary(); - - /// - /// Transfer a setting from to a configuration bindable. - /// Only performs the transfer if the user is not currently overriding. - /// - protected void TransferSetting(BindableNumber bindable, T beatmapDefault) - where T : struct, IComparable, IConvertible, IEquatable - { - 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; - } - - internal override void CopyAdjustedSetting(IBindable target, object source) - { - // if the value is non-bindable, it's presumably coming from an external source (like the API) - therefore presume it is not default. - // if the value is bindable, defer to the source's IsDefault to be able to tell. - userChangedSettings[target] = !(source is IBindable bindableSource) || !bindableSource.IsDefault; - base.CopyAdjustedSetting(target, source); - } - - /// - /// Applies a setting from a configuration bindable using , if it has been changed by the user. - /// - protected void ApplySetting(BindableNumber setting, Action applyFunc) - where T : struct, IComparable, IConvertible, IEquatable - { - if (userChangedSettings.TryGetValue(setting, out bool userChangedSetting) && userChangedSetting) - applyFunc.Invoke(setting.Value); - } - /// /// Apply all custom settings to the provided beatmap. /// /// The beatmap to have settings applied. protected virtual void ApplySettings(BeatmapDifficulty difficulty) { - ApplySetting(DrainRate, dr => difficulty.DrainRate = dr); - ApplySetting(OverallDifficulty, od => difficulty.OverallDifficulty = od); - } - - public override void ResetSettingsToDefaults() - { - base.ResetSettingsToDefaults(); - - if (difficulty != null) - { - // base implementation potentially overwrite modified defaults that came from a beatmap selection. - TransferSettings(difficulty); - } - } - - /// - /// A that extends its min/max values to support any assigned value. - /// - protected class BindableDoubleWithLimitExtension : BindableDouble - { - public override double Value - { - get => base.Value; - set - { - if (value < MinValue) - MinValue = value; - if (value > MaxValue) - MaxValue = value; - base.Value = value; - } - } - } - - /// - /// A that extends its min/max values to support any assigned value. - /// - protected class BindableFloatWithLimitExtension : BindableFloat - { - public override float Value - { - get => base.Value; - set - { - if (value < MinValue) - MinValue = value; - if (value > MaxValue) - MaxValue = value; - base.Value = value; - } - } - } - - /// - /// A that extends its min/max values to support any assigned value. - /// - protected class BindableIntWithLimitExtension : BindableInt - { - public override int Value - { - get => base.Value; - set - { - if (value < MinValue) - MinValue = value; - if (value > MaxValue) - MaxValue = value; - base.Value = value; - } - } + if (DrainRate.Value != null) difficulty.DrainRate = DrainRate.Value.Value; + if (OverallDifficulty.Value != null) difficulty.OverallDifficulty = OverallDifficulty.Value.Value; } } } diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index 152657da33..d12f48e973 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Mods 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(); diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index 1290e8136c..0f51e2a6d5 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.cs @@ -15,7 +15,6 @@ namespace osu.Game.Rulesets.Mods 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), typeof(ModDifficultyAdjust) }; public virtual void ReadFromDifficulty(BeatmapDifficulty difficulty) diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 08f2ccb75c..7abae71273 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -31,7 +31,6 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => OsuIcon.ModFlashlight; public override ModType Type => ModType.DifficultyIncrease; public override string Description => "Restricted view area."; - public override bool Ranked => true; internal ModFlashlight() { diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index 203b88951c..c240cdbe6e 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.cs @@ -17,7 +17,6 @@ namespace osu.Game.Rulesets.Mods 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(); diff --git a/osu.Game/Rulesets/Mods/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index 238612b3d2..5a8226115f 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.cs @@ -14,7 +14,6 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "HD"; public override IconUsage? Icon => OsuIcon.ModHidden; public override ModType Type => ModType.DifficultyIncrease; - public override bool Ranked => true; public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index c0f24e116a..abf67c2e2d 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -15,7 +15,6 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyReduction; public override string Description => "You can't fail, no matter what."; public override double ScoreMultiplier => 0.5; - public override bool Ranked => true; public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModAutoplay) }; } } diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index d0b09b50f2..187a4d8e23 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "PF"; public override IconUsage? Icon => OsuIcon.ModPerfect; public override ModType Type => ModType.DifficultyIncrease; - public override bool Ranked => true; public override double ScoreMultiplier => 1; public override string Description => "SS or quit."; diff --git a/osu.Game/Rulesets/Mods/ModRandom.cs b/osu.Game/Rulesets/Mods/ModRandom.cs index da55ab3fbf..1f7742b075 100644 --- a/osu.Game/Rulesets/Mods/ModRandom.cs +++ b/osu.Game/Rulesets/Mods/ModRandom.cs @@ -1,17 +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.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Overlays.Settings; namespace osu.Game.Rulesets.Mods { - public abstract class ModRandom : Mod + public abstract class ModRandom : Mod, IHasSeed { 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; + + [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable + { + Default = null, + Value = null + }; } } diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index 617ae38feb..1abd353d20 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -18,7 +18,6 @@ namespace osu.Game.Rulesets.Mods 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 => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs b/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs index 5b119b5e46..b58ee5ff36 100644 --- a/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs +++ b/osu.Game/Rulesets/Mods/ModWithVisibilityAdjustment.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mods /// A which applies visibility adjustments to s /// with an optional increased visibility adjustment depending on the user's "increase first object visibility" setting. /// - public abstract class ModWithVisibilityAdjustment : Mod, IReadFromConfig, IApplicableToBeatmap, IApplicableToDrawableHitObjects + public abstract class ModWithVisibilityAdjustment : Mod, IReadFromConfig, IApplicableToBeatmap, IApplicableToDrawableHitObject { /// /// The first adjustable object. @@ -73,19 +73,16 @@ namespace osu.Game.Rulesets.Mods } } - public virtual void ApplyToDrawableHitObjects(IEnumerable drawables) + public virtual void ApplyToDrawableHitObject(DrawableHitObject dho) { - foreach (var dho in drawables) + dho.ApplyCustomUpdateState += (o, state) => { - 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); - }; - } + // 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); + }; } /// diff --git a/osu.Game/Rulesets/Mods/MultiMod.cs b/osu.Game/Rulesets/Mods/MultiMod.cs index 2107009dbb..1c41c6b8b3 100644 --- a/osu.Game/Rulesets/Mods/MultiMod.cs +++ b/osu.Game/Rulesets/Mods/MultiMod.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Mods Mods = mods; } - public override Mod CreateCopy() => new MultiMod(Mods.Select(m => m.CreateCopy()).ToArray()); + public override Mod DeepClone() => new MultiMod(Mods.Select(m => m.DeepClone()).ToArray()); public override Type[] IncompatibleMods => Mods.SelectMany(m => m.IncompatibleMods).ToArray(); } diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 6c688c1625..69bdb5fd73 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -156,10 +156,11 @@ namespace osu.Game.Rulesets.Objects.Drawables /// If null, a hitobject is expected to be later applied via (or automatically via pooling). /// protected DrawableHitObject([CanBeNull] HitObject initialHitObject = null) - : base(initialHitObject != null ? new SyntheticHitObjectEntry(initialHitObject) : null) { - if (Entry != null) - ensureEntryHasResult(); + if (initialHitObject == null) return; + + Entry = new SyntheticHitObjectEntry(initialHitObject); + ensureEntryHasResult(); } [BackgroundDependencyLoader] @@ -192,7 +193,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// Applies a hit object to be represented by this . /// - [Obsolete("Use either overload of Apply that takes a single argument of type HitObject or HitObjectLifetimeEntry")] + [Obsolete("Use either overload of Apply that takes a single argument of type HitObject or HitObjectLifetimeEntry")] // Can be removed 20211021. public void Apply([NotNull] HitObject hitObject, [CanBeNull] HitObjectLifetimeEntry lifetimeEntry) { if (lifetimeEntry != null) @@ -403,18 +404,13 @@ namespace osu.Game.Rulesets.Objects.Drawables clearExistingStateTransforms(); - using (BeginAbsoluteSequence(transformTime, true)) + using (BeginAbsoluteSequence(transformTime)) UpdateInitialTransforms(); - using (BeginAbsoluteSequence(StateUpdateTime, true)) + using (BeginAbsoluteSequence(StateUpdateTime)) UpdateStartTimeStateTransforms(); -#pragma warning disable 618 - using (BeginAbsoluteSequence(StateUpdateTime + (Result?.TimeOffset ?? 0), true)) - UpdateStateTransforms(newState); -#pragma warning restore 618 - - using (BeginAbsoluteSequence(HitStateUpdateTime, true)) + using (BeginAbsoluteSequence(HitStateUpdateTime)) UpdateHitStateTransforms(newState); state.Value = newState; @@ -447,7 +443,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// By default, this will fade in the object from zero with no duration. /// /// - /// This is called once before every . This is to ensure a good state in the case + /// This is called once before every . This is to ensure a good state in the case /// the was negative and potentially altered the pre-hit transforms. /// protected virtual void UpdateInitialTransforms() @@ -455,16 +451,6 @@ namespace osu.Game.Rulesets.Objects.Drawables this.FadeInFromZero(); } - /// - /// Apply transforms based on the current . Previous states are automatically cleared. - /// In the case of a non-idle , and if was not set during this call, will be invoked. - /// - /// The new armed state. - [Obsolete("Use UpdateStartTimeStateTransforms and UpdateHitStateTransforms instead")] // Can be removed 20210504 - protected virtual void UpdateStateTransforms(ArmedState state) - { - } - /// /// Apply passive transforms at the 's StartTime. /// This is called each time changes. @@ -516,25 +502,7 @@ namespace osu.Game.Rulesets.Objects.Drawables { if (!(HitObject is IHasComboInformation combo)) return; - var comboColours = CurrentSkin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); - AccentColour.Value = combo.GetComboColour(comboColours); - } - - /// - /// Called to retrieve the combo colour. Automatically assigned to . - /// Defaults to using to decide on a colour. - /// - /// - /// This will only be called if the implements . - /// - /// A list of combo colours provided by the beatmap or skin. Can be null if not available. - [Obsolete("Unused. Implement IHasComboInformation and IHasComboInformation.GetComboColour() on the HitObject model instead.")] // Can be removed 20210527 - protected virtual Color4 GetComboColour(IReadOnlyList 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; + AccentColour.Value = combo.GetComboColour(CurrentSkin); } /// @@ -630,7 +598,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// The time at which state transforms should be applied that line up to 's StartTime. - /// This is used to offset calls to . + /// This is used to offset calls to . /// public double StateUpdateTime => HitObject.StartTime; @@ -748,7 +716,8 @@ namespace osu.Game.Rulesets.Objects.Drawables if (HitObject != null) HitObject.DefaultsApplied -= onDefaultsApplied; - CurrentSkin.SourceChanged -= skinSourceChanged; + if (CurrentSkin != null) + CurrentSkin.SourceChanged -= skinSourceChanged; } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs index 19722fb796..12b4812824 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs @@ -2,15 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; +using osuTK; namespace osu.Game.Rulesets.Objects.Legacy.Catch { /// /// Legacy osu!catch Hit-type, used for parsing Beatmaps. /// - internal sealed class ConvertHit : ConvertHitObject, IHasCombo, IHasXPosition + internal sealed class ConvertHit : ConvertHitObject, IHasPosition, IHasCombo { - public float X { get; set; } + public float X => Position.X; + + public float Y => Position.Y; + + public Vector2 Position { get; set; } public bool NewCombo { get; set; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs index c10c8dc30f..c29179f749 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch return new ConvertHit { - X = position.X, + Position = position, NewCombo = newCombo, ComboOffset = comboOffset }; @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch return new ConvertSlider { - X = position.X, + Position = position, NewCombo = FirstObject || newCombo, ComboOffset = comboOffset, Path = new SliderPath(controlPoints, length), diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs index 56790629b4..fb1afed3b4 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs @@ -2,15 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; +using osuTK; namespace osu.Game.Rulesets.Objects.Legacy.Catch { /// /// Legacy osu!catch Slider-type, used for parsing Beatmaps. /// - internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasXPosition, IHasCombo + internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasPosition, IHasCombo { - public float X { get; set; } + public float X => Position.X; + + public float Y => Position.Y; + + public Vector2 Position { get; set; } public bool NewCombo { get; set; } diff --git a/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs index 4440ca8d21..9c6097a048 100644 --- a/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs +++ b/osu.Game/Rulesets/Objects/Pooling/PoolableDrawableWithLifetime.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; +using osu.Framework.Graphics; using osu.Framework.Graphics.Performance; using osu.Framework.Graphics.Pooling; @@ -16,14 +17,32 @@ namespace osu.Game.Rulesets.Objects.Pooling /// The type storing state and controlling this drawable. public abstract class PoolableDrawableWithLifetime : PoolableDrawable where TEntry : LifetimeEntry { + private TEntry? entry; + /// /// The entry holding essential state of this . /// - public TEntry? Entry { get; private set; } + /// + /// If a non-null value is set before loading is started, the entry is applied when the loading is completed. + /// It is not valid to set an entry while this is loading. + /// + public TEntry? Entry + { + get => entry; + set + { + if (LoadState == LoadState.NotLoaded) + entry = value; + else if (value != null) + Apply(value); + else if (HasEntryApplied) + free(); + } + } /// /// Whether is applied to this . - /// When an initial entry is specified in the constructor, is set but not applied until loading is completed. + /// When an is set during initialization, it is not applied until loading is completed. /// protected bool HasEntryApplied { get; private set; } @@ -65,9 +84,9 @@ namespace osu.Game.Rulesets.Objects.Pooling { base.LoadAsyncComplete(); - // Apply the initial entry given in the constructor. + // Apply the initial entry. if (Entry != null && !HasEntryApplied) - Apply(Entry); + apply(Entry); } /// @@ -76,16 +95,10 @@ namespace osu.Game.Rulesets.Objects.Pooling /// public void Apply(TEntry entry) { - if (HasEntryApplied) - free(); + if (LoadState == LoadState.Loading) + throw new InvalidOperationException($"Cannot apply a new {nameof(TEntry)} while currently loading."); - Entry = entry; - entry.LifetimeChanged += setLifetimeFromEntry; - setLifetimeFromEntry(entry); - - OnApply(entry); - - HasEntryApplied = true; + apply(entry); } protected sealed override void FreeAfterUse() @@ -111,6 +124,20 @@ namespace osu.Game.Rulesets.Objects.Pooling { } + private void apply(TEntry entry) + { + if (HasEntryApplied) + free(); + + this.entry = entry; + entry.LifetimeChanged += setLifetimeFromEntry; + setLifetimeFromEntry(entry); + + OnApply(entry); + + HasEntryApplied = true; + } + private void free() { Debug.Assert(Entry != null && HasEntryApplied); @@ -118,7 +145,7 @@ namespace osu.Game.Rulesets.Objects.Pooling OnFree(Entry); Entry.LifetimeChanged -= setLifetimeFromEntry; - Entry = null; + entry = null; base.LifetimeStart = double.MinValue; base.LifetimeEnd = double.MaxValue; diff --git a/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs b/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.cs new file mode 100644 index 0000000000..d35933dba8 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Pooling/PooledDrawableWithLifetimeContainer.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. + +#nullable enable + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Performance; + +namespace osu.Game.Rulesets.Objects.Pooling +{ + /// + /// A container of s dynamically added/removed by model s. + /// When an entry became alive, a drawable corresponding to the entry is obtained (potentially pooled), and added to this container. + /// The drawable is removed when the entry became dead. + /// + /// The type of entries managed by this container. + /// The type of drawables corresponding to the entries. + public abstract class PooledDrawableWithLifetimeContainer : CompositeDrawable + where TEntry : LifetimeEntry + where TDrawable : Drawable + { + /// + /// All entries added to this container, including dead entries. + /// + /// + /// The enumeration order is undefined. + /// + public IEnumerable Entries => allEntries; + + /// + /// All alive entries and drawables corresponding to the entries. + /// + /// + /// The enumeration order is undefined. + /// + public IEnumerable<(TEntry Entry, TDrawable Drawable)> AliveEntries => aliveDrawableMap.Select(x => (x.Key, x.Value)); + + /// + /// Whether to remove an entry when clock goes backward and crossed its . + /// Used when entries are dynamically added at its to prevent duplicated entries. + /// + protected virtual bool RemoveRewoundEntry => false; + + /// + /// The amount of time prior to the current time within which entries should be considered alive. + /// + internal double PastLifetimeExtension { get; set; } + + /// + /// The amount of time after the current time within which entries should be considered alive. + /// + internal double FutureLifetimeExtension { get; set; } + + private readonly Dictionary aliveDrawableMap = new Dictionary(); + private readonly HashSet allEntries = new HashSet(); + + private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager(); + + protected PooledDrawableWithLifetimeContainer() + { + lifetimeManager.EntryBecameAlive += entryBecameAlive; + lifetimeManager.EntryBecameDead += entryBecameDead; + lifetimeManager.EntryCrossedBoundary += entryCrossedBoundary; + } + + /// + /// Add a to be managed by this container. + /// + /// + /// The aliveness of the entry is not updated until . + /// + public virtual void Add(TEntry entry) + { + allEntries.Add(entry); + lifetimeManager.AddEntry(entry); + } + + /// + /// Remove a from this container. + /// + /// + /// If the entry was alive, the corresponding drawable is removed. + /// + /// Whether the entry was in this container. + public virtual bool Remove(TEntry entry) + { + if (!lifetimeManager.RemoveEntry(entry)) return false; + + allEntries.Remove(entry); + return true; + } + + /// + /// Initialize new corresponding . + /// + /// The corresponding to the entry. + protected abstract TDrawable GetDrawable(TEntry entry); + + private void entryBecameAlive(LifetimeEntry lifetimeEntry) + { + var entry = (TEntry)lifetimeEntry; + Debug.Assert(!aliveDrawableMap.ContainsKey(entry)); + + TDrawable drawable = GetDrawable(entry); + aliveDrawableMap[entry] = drawable; + AddDrawable(entry, drawable); + } + + /// + /// Add a corresponding to to this container. + /// + /// + /// Invoked when the entry became alive and a is obtained by . + /// + protected virtual void AddDrawable(TEntry entry, TDrawable drawable) => AddInternal(drawable); + + private void entryBecameDead(LifetimeEntry lifetimeEntry) + { + var entry = (TEntry)lifetimeEntry; + Debug.Assert(aliveDrawableMap.ContainsKey(entry)); + + TDrawable drawable = aliveDrawableMap[entry]; + aliveDrawableMap.Remove(entry); + RemoveDrawable(entry, drawable); + } + + /// + /// Remove a corresponding to from this container. + /// + /// + /// Invoked when the entry became dead. + /// + protected virtual void RemoveDrawable(TEntry entry, TDrawable drawable) => RemoveInternal(drawable); + + private void entryCrossedBoundary(LifetimeEntry lifetimeEntry, LifetimeBoundaryKind kind, LifetimeBoundaryCrossingDirection direction) + { + if (RemoveRewoundEntry && kind == LifetimeBoundaryKind.Start && direction == LifetimeBoundaryCrossingDirection.Backward) + Remove((TEntry)lifetimeEntry); + } + + /// + /// Remove all s. + /// + public void Clear() + { + foreach (var entry in Entries.ToArray()) + Remove(entry); + + Debug.Assert(aliveDrawableMap.Count == 0); + } + + protected override bool CheckChildrenLife() + { + bool aliveChanged = base.CheckChildrenLife(); + aliveChanged |= lifetimeManager.Update(Time.Current - PastLifetimeExtension, Time.Current + FutureLifetimeExtension); + return aliveChanged; + } + } +} diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 4f66802079..03e6f76cca 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -1,9 +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 JetBrains.Annotations; using osu.Framework.Bindables; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Types @@ -40,11 +39,21 @@ namespace osu.Game.Rulesets.Objects.Types bool LastInCombo { get; set; } /// - /// Retrieves the colour of the combo described by this object from a set of possible combo colours. - /// Defaults to using to decide the colour. + /// Retrieves the colour of the combo described by this object. /// - /// A list of possible combo colours provided by the beatmap or skin. - /// The colour of the combo described by this object. - Color4 GetComboColour([NotNull] IReadOnlyList comboColours) => comboColours.Count > 0 ? comboColours[ComboIndex % comboColours.Count] : Color4.White; + /// The skin to retrieve the combo colour from, if wanted. + Color4 GetComboColour(ISkin skin) => GetSkinComboColour(this, skin, ComboIndex); + + /// + /// Retrieves the colour of the combo described by a given object from a given skin. + /// + /// The combo information, should be this. + /// The skin to retrieve the combo colour from. + /// The index to retrieve the combo colour with. + /// + protected static Color4 GetSkinComboColour(IHasComboInformation combo, ISkin skin, int comboIndex) + { + return skin.GetConfig(new SkinComboColourLookup(comboIndex, combo))?.Value ?? Color4.White; + } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasDisplayColour.cs b/osu.Game/Rulesets/Objects/Types/IHasDisplayColour.cs new file mode 100644 index 0000000000..8807b802d8 --- /dev/null +++ b/osu.Game/Rulesets/Objects/Types/IHasDisplayColour.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.Bindables; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Objects.Types +{ + /// + /// A HitObject which has a preferred display colour. Will be used for editor timeline display. + /// + public interface IHasDisplayColour + { + /// + /// The current display colour of this hit object. + /// + Bindable DisplayColour { get; } + } +} diff --git a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs index bc8994bbe5..d3ee10dd23 100644 --- a/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs +++ b/osu.Game/Rulesets/Replays/FramedReplayInputHandler.cs @@ -6,7 +6,6 @@ using System; using System.Collections.Generic; using System.Linq; -using JetBrains.Annotations; using osu.Game.Input.Handlers; using osu.Game.Replays; @@ -117,7 +116,7 @@ namespace osu.Game.Rulesets.Replays } } - protected virtual bool IsImportant([NotNull] TFrame frame) => false; + protected virtual bool IsImportant(TFrame frame) => false; /// /// Update the current frame based on an incoming time value. diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index 7bdf84ace4..9fdaca88fd 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -127,7 +127,7 @@ namespace osu.Game.Rulesets [CanBeNull] public ModAutoplay GetAutoplayMod() => GetAllMods().OfType().FirstOrDefault(); - public virtual ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => null; + public virtual ISkin CreateLegacySkinProvider([NotNull] ISkin skin, IBeatmap beatmap) => null; protected Ruleset() { diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index 0a34ca9598..1f12f3dfeb 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -96,13 +96,25 @@ namespace osu.Game.Rulesets context.SaveChanges(); - // add any other modes var existingRulesets = context.RulesetInfo.ToList(); + // add any other rulesets which have assemblies present but are not yet in the database. foreach (var r in instances.Where(r => !(r is ILegacyRuleset))) { if (existingRulesets.FirstOrDefault(ri => ri.InstantiationInfo.Equals(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null) - context.RulesetInfo.Add(r.RulesetInfo); + { + var existingSameShortName = existingRulesets.FirstOrDefault(ri => ri.ShortName == r.RulesetInfo.ShortName); + + if (existingSameShortName != null) + { + // even if a matching InstantiationInfo was not found, there may be an existing ruleset with the same ShortName. + // this generally means the user or ruleset provider has renamed their dll but the underlying ruleset is *likely* the same one. + // in such cases, update the instantiation info of the existing entry to point to the new one. + existingSameShortName.InstantiationInfo = r.RulesetInfo.InstantiationInfo; + } + else + context.RulesetInfo.Add(r.RulesetInfo); + } } context.SaveChanges(); diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index f32f70d4ba..6a2601170c 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; @@ -146,7 +145,7 @@ namespace osu.Game.Rulesets.Scoring rollingMaxBaseScore += result.Judgement.MaxNumericResult; } - scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) + 1; + scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; hitEvents.Add(CreateHitEvent(result)); lastHitObject = result.HitObject; @@ -181,7 +180,7 @@ namespace osu.Game.Rulesets.Scoring rollingMaxBaseScore -= result.Judgement.MaxNumericResult; } - scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) - 1; + scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; Debug.Assert(hitEvents.Count > 0); lastHitObject = hitEvents[^1].LastHitObject; @@ -272,8 +271,8 @@ namespace osu.Game.Rulesets.Scoring private double calculateComboRatio(int maxCombo) => maxAchievableCombo > 0 ? (double)maxCombo / maxAchievableCombo : 1; private double getBonusScore(Dictionary statistics) - => statistics.GetOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE - + statistics.GetOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE; + => statistics.GetValueOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE + + statistics.GetValueOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE; private ScoreRank rankFrom(double acc) { @@ -291,7 +290,7 @@ namespace osu.Game.Rulesets.Scoring return ScoreRank.D; } - public int GetStatistic(HitResult result) => scoreResultCounts.GetOrDefault(result); + public int GetStatistic(HitResult result) => scoreResultCounts.GetValueOrDefault(result); public double GetStandardisedScore() => getScore(ScoringMode.Standardised); @@ -339,7 +338,6 @@ namespace osu.Game.Rulesets.Scoring score.MaxCombo = HighestCombo.Value; score.Accuracy = Accuracy.Value; score.Rank = Rank.Value; - score.Date = DateTimeOffset.Now; foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.IsScorable())) score.Statistics[result] = GetStatistic(result); diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index a2dade2627..daf46dcdcc 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -68,10 +68,7 @@ namespace osu.Game.Rulesets.UI private bool frameStablePlayback = true; - /// - /// Whether to enable frame-stable playback. - /// - internal bool FrameStablePlayback + internal override bool FrameStablePlayback { get => frameStablePlayback; set @@ -182,18 +179,11 @@ namespace osu.Game.Rulesets.UI .WithChild(ResumeOverlay))); } - RegenerateAutoplay(); + applyRulesetMods(Mods, config); loadObjects(cancellationToken ?? default); } - 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); - } - /// /// Creates and adds drawable representations of hit objects to the play field. /// @@ -209,8 +199,11 @@ namespace osu.Game.Rulesets.UI Playfield.PostProcess(); - foreach (var mod in Mods.OfType()) - mod.ApplyToDrawableHitObjects(Playfield.AllHitObjects); + foreach (var mod in Mods.OfType()) + { + foreach (var drawableHitObject in Playfield.AllHitObjects) + mod.ApplyToDrawableHitObject(drawableHitObject); + } } public override void RequestResume(Action continueResume) @@ -274,6 +267,12 @@ namespace osu.Game.Rulesets.UI if (!(KeyBindingInputManager is IHasRecordingHandler recordingInputManager)) throw new InvalidOperationException($"A {nameof(KeyBindingInputManager)} which supports recording is not available"); + if (score == null) + { + recordingInputManager.Recorder = null; + return; + } + var recorder = CreateReplayRecorder(score); if (recorder == null) @@ -432,6 +431,11 @@ namespace osu.Game.Rulesets.UI /// public abstract IFrameStableClock FrameStableClock { get; } + /// + /// Whether to enable frame-stable playback. + /// + internal abstract bool FrameStablePlayback { get; set; } + /// /// The mods which are to be applied. /// @@ -485,15 +489,15 @@ namespace osu.Game.Rulesets.UI { get { - foreach (var h in Objects) + foreach (var hitObject in Objects) { - if (h.HitWindows.WindowFor(HitResult.Miss) > 0) - return h.HitWindows; + if (hitObject.HitWindows.WindowFor(HitResult.Miss) > 0) + return hitObject.HitWindows; - foreach (var n in h.NestedHitObjects) + foreach (var nested in hitObject.NestedHitObjects) { - if (h.HitWindows.WindowFor(HitResult.Miss) > 0) - return n.HitWindows; + if (nested.HitWindows.WindowFor(HitResult.Miss) > 0) + return nested.HitWindows; } } @@ -518,7 +522,7 @@ namespace osu.Game.Rulesets.UI /// Sets a replay to be used to record gameplay. /// /// The target to be recorded to. - public abstract void SetRecordTarget(Score score); + public abstract void SetRecordTarget([CanBeNull] Score score); /// /// Invoked when the interactive user requests resuming from a paused state. diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index e66a8c016c..e49e515d2e 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -10,6 +10,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; @@ -34,6 +35,11 @@ namespace osu.Game.Rulesets.UI /// public ISampleStore SampleStore { get; } + /// + /// The shader manager to be used for the ruleset. + /// + public ShaderManager ShaderManager { get; } + /// /// The ruleset config manager. /// @@ -52,6 +58,9 @@ namespace osu.Game.Rulesets.UI SampleStore = parent.Get().GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); SampleStore.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; CacheAs(SampleStore = new FallbackSampleStore(SampleStore, parent.Get())); + + ShaderManager = new ShaderManager(new NamespacedResourceStore(resources, @"Shaders")); + CacheAs(ShaderManager = new FallbackShaderManager(ShaderManager, parent.Get())); } RulesetConfigManager = parent.Get().GetConfigFor(ruleset); @@ -84,6 +93,7 @@ namespace osu.Game.Rulesets.UI SampleStore?.Dispose(); TextureStore?.Dispose(); + ShaderManager?.Dispose(); RulesetConfigManager = null; } @@ -172,5 +182,26 @@ namespace osu.Game.Rulesets.UI primary?.Dispose(); } } + + private class FallbackShaderManager : ShaderManager + { + private readonly ShaderManager primary; + private readonly ShaderManager fallback; + + public FallbackShaderManager(ShaderManager primary, ShaderManager fallback) + : base(new ResourceStore()) + { + this.primary = primary; + this.fallback = fallback; + } + + public override byte[] LoadRaw(string name) => primary.LoadRaw(name) ?? fallback.LoadRaw(name); + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + primary?.Dispose(); + } + } } } diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 83033b2dd5..fee77af0ba 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -3,35 +3,23 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Performance; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Pooling; namespace osu.Game.Rulesets.UI { - public class HitObjectContainer : CompositeDrawable, IHitObjectContainer + public class HitObjectContainer : PooledDrawableWithLifetimeContainer, IHitObjectContainer { - /// - /// All entries in this including dead entries. - /// - public IEnumerable Entries => allEntries; - - /// - /// All alive entries and s used by the entries. - /// - public IEnumerable<(HitObjectLifetimeEntry Entry, DrawableHitObject Drawable)> AliveEntries => aliveDrawableMap.Select(x => (x.Key, x.Value)); - public IEnumerable Objects => InternalChildren.Cast().OrderBy(h => h.HitObject.StartTime); - public IEnumerable AliveObjects => AliveInternalChildren.Cast().OrderBy(h => h.HitObject.StartTime); + public IEnumerable AliveObjects => AliveEntries.Select(pair => pair.Drawable).OrderBy(h => h.HitObject.StartTime); /// /// Invoked when a is judged. @@ -59,34 +47,16 @@ namespace osu.Game.Rulesets.UI /// internal event Action HitObjectUsageFinished; - /// - /// The amount of time prior to the current time within which s should be considered alive. - /// - internal double PastLifetimeExtension { get; set; } - - /// - /// The amount of time after the current time within which s should be considered alive. - /// - internal double FutureLifetimeExtension { get; set; } - private readonly Dictionary startTimeMap = new Dictionary(); - private readonly Dictionary aliveDrawableMap = new Dictionary(); private readonly Dictionary nonPooledDrawableMap = new Dictionary(); - private readonly LifetimeEntryManager lifetimeManager = new LifetimeEntryManager(); - private readonly HashSet allEntries = new HashSet(); - [Resolved(CanBeNull = true)] private IPooledHitObjectProvider pooledObjectProvider { get; set; } public HitObjectContainer() { RelativeSizeAxes = Axes.Both; - - lifetimeManager.EntryBecameAlive += entryBecameAlive; - lifetimeManager.EntryBecameDead += entryBecameDead; - lifetimeManager.EntryCrossedBoundary += entryCrossedBoundary; } protected override void LoadAsyncComplete() @@ -99,63 +69,41 @@ namespace osu.Game.Rulesets.UI #region Pooling support - public void Add(HitObjectLifetimeEntry entry) + public override bool Remove(HitObjectLifetimeEntry entry) { - allEntries.Add(entry); - lifetimeManager.AddEntry(entry); - } - - public bool Remove(HitObjectLifetimeEntry entry) - { - if (!lifetimeManager.RemoveEntry(entry)) return false; + if (!base.Remove(entry)) return false; // This logic is not in `Remove(DrawableHitObject)` because a non-pooled drawable may be removed by specifying its entry. if (nonPooledDrawableMap.Remove(entry, out var drawable)) removeDrawable(drawable); - allEntries.Remove(entry); return true; } - private void entryBecameAlive(LifetimeEntry lifetimeEntry) + protected sealed override DrawableHitObject GetDrawable(HitObjectLifetimeEntry entry) { - var entry = (HitObjectLifetimeEntry)lifetimeEntry; - Debug.Assert(!aliveDrawableMap.ContainsKey(entry)); + if (nonPooledDrawableMap.TryGetValue(entry, out var drawable)) + return drawable; - bool isPooled = !nonPooledDrawableMap.TryGetValue(entry, out var drawable); - drawable ??= pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject, null); - if (drawable == null) - throw new InvalidOperationException($"A drawable representation could not be retrieved for hitobject type: {entry.HitObject.GetType().ReadableName()}."); - - aliveDrawableMap[entry] = drawable; - - if (isPooled) - { - addDrawable(drawable); - HitObjectUsageBegan?.Invoke(entry.HitObject); - } - - OnAdd(drawable); + return pooledObjectProvider?.GetPooledDrawableRepresentation(entry.HitObject, null) ?? + throw new InvalidOperationException($"A drawable representation could not be retrieved for hitobject type: {entry.HitObject.GetType().ReadableName()}."); } - private void entryBecameDead(LifetimeEntry lifetimeEntry) + protected override void AddDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable) { - var entry = (HitObjectLifetimeEntry)lifetimeEntry; - Debug.Assert(aliveDrawableMap.ContainsKey(entry)); + if (nonPooledDrawableMap.ContainsKey(entry)) return; - var drawable = aliveDrawableMap[entry]; - bool isPooled = !nonPooledDrawableMap.ContainsKey(entry); + addDrawable(drawable); + HitObjectUsageBegan?.Invoke(entry.HitObject); + } + protected override void RemoveDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable) + { drawable.OnKilled(); - aliveDrawableMap.Remove(entry); + if (nonPooledDrawableMap.ContainsKey(entry)) return; - if (isPooled) - { - removeDrawable(drawable); - HitObjectUsageFinished?.Invoke(entry.HitObject); - } - - OnRemove(drawable); + removeDrawable(drawable); + HitObjectUsageFinished?.Invoke(entry.HitObject); } private void addDrawable(DrawableHitObject drawable) @@ -201,49 +149,8 @@ namespace osu.Game.Rulesets.UI public int IndexOf(DrawableHitObject hitObject) => IndexOfInternal(hitObject); - private void entryCrossedBoundary(LifetimeEntry entry, LifetimeBoundaryKind kind, LifetimeBoundaryCrossingDirection direction) - { - if (nonPooledDrawableMap.TryGetValue((HitObjectLifetimeEntry)entry, out var drawable)) - OnChildLifetimeBoundaryCrossed(new LifetimeBoundaryCrossedEvent(drawable, kind, direction)); - } - - protected virtual void OnChildLifetimeBoundaryCrossed(LifetimeBoundaryCrossedEvent e) - { - } - #endregion - /// - /// Invoked after a is added to this container. - /// - protected virtual void OnAdd(DrawableHitObject drawableHitObject) - { - Debug.Assert(drawableHitObject.LoadState >= LoadState.Ready); - } - - /// - /// Invoked after a is removed from this container. - /// - protected virtual void OnRemove(DrawableHitObject drawableHitObject) - { - } - - public virtual void Clear() - { - lifetimeManager.ClearEntries(); - foreach (var drawable in nonPooledDrawableMap.Values) - removeDrawable(drawable); - nonPooledDrawableMap.Clear(); - Debug.Assert(InternalChildren.Count == 0 && startTimeMap.Count == 0 && aliveDrawableMap.Count == 0, "All hit objects should have been removed"); - } - - protected override bool CheckChildrenLife() - { - bool aliveChanged = base.CheckChildrenLife(); - aliveChanged |= lifetimeManager.Update(Time.Current - PastLifetimeExtension, Time.Current + FutureLifetimeExtension); - return aliveChanged; - } - private void onNewResult(DrawableHitObject d, JudgementResult r) => NewResult?.Invoke(d, r); private void onRevertResult(DrawableHitObject d, JudgementResult r) => RevertResult?.Invoke(d, r); diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs index cae5da3d16..725cfa9c26 100644 --- a/osu.Game/Rulesets/UI/ModIcon.cs +++ b/osu.Game/Rulesets/UI/ModIcon.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; using osuTK; using osu.Framework.Bindables; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.UI { @@ -29,7 +30,7 @@ namespace osu.Game.Rulesets.UI private const float size = 80; - public virtual string TooltipText => showTooltip ? mod.IconTooltip : null; + public virtual LocalisableString TooltipText => showTooltip ? mod.IconTooltip : null; private Mod mod; private readonly bool showTooltip; diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index b154288dba..52aecb27de 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -356,8 +356,8 @@ namespace osu.Game.Rulesets.UI // This is done before Apply() so that the state is updated once when the hitobject is applied. if (mods != null) { - foreach (var m in mods.OfType()) - m.ApplyToDrawableHitObjects(dho.Yield()); + foreach (var m in mods.OfType()) + m.ApplyToDrawableHitObject(dho); } } diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 75c3a4661c..e6cd2aa3dc 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -30,12 +30,14 @@ namespace osu.Game.Rulesets.UI { set { - if (recorder != null) + if (value != null && recorder != null) throw new InvalidOperationException("Cannot attach more than one recorder"); + recorder?.Expire(); recorder = value; - KeyBindingContainer.Add(recorder); + if (recorder != null) + KeyBindingContainer.Add(recorder); } } @@ -175,7 +177,7 @@ namespace osu.Game.Rulesets.UI { base.ReloadMappings(); - KeyBindings = KeyBindings.Where(b => KeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList(); + KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList(); } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index cde4182f2d..3b15bc2cdf 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Layout; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -17,6 +19,20 @@ namespace osu.Game.Rulesets.UI.Scrolling private readonly IBindable timeRange = new BindableDouble(); private readonly IBindable direction = new Bindable(); + /// + /// Whether the scrolling direction is horizontal or vertical. + /// + private Direction scrollingAxis => direction.Value == ScrollingDirection.Left || direction.Value == ScrollingDirection.Right ? Direction.Horizontal : Direction.Vertical; + + /// + /// The scrolling axis is inverted if objects temporally farther in the future have a smaller position value across the scrolling axis. + /// + /// + /// is inverted, because given two objects, one of which is at the current time and one of which is 1000ms in the future, + /// in the current time instant the future object is spatially above the current object, and therefore has a smaller value of the Y coordinate of its position. + /// + private bool axisInverted => direction.Value == ScrollingDirection.Down || direction.Value == ScrollingDirection.Right; + /// /// A set of top-level s which have an up-to-date layout. /// @@ -45,119 +61,83 @@ namespace osu.Game.Rulesets.UI.Scrolling timeRange.ValueChanged += _ => layoutCache.Invalidate(); } - public override void Clear() + /// + /// Given a position at , return the time of the object corresponding to the position. + /// + /// + /// If there are multiple valid time values, one arbitrary time is returned. + /// + public double TimeAtPosition(float localPosition, double currentTime) { - base.Clear(); - - layoutComputed.Clear(); + float scrollPosition = axisInverted ? -localPosition : localPosition; + return scrollingInfo.Algorithm.TimeAt(scrollPosition, currentTime, timeRange.Value, scrollLength); } /// - /// Given a position in screen space, return the time within this column. + /// Given a position at the current time in screen space, return the time of the object corresponding the position. /// + /// + /// If there are multiple valid time values, one arbitrary time is returned. + /// 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, scrollLength); + Vector2 pos = ToLocalSpace(screenSpacePosition); + float localPosition = scrollingAxis == Direction.Horizontal ? pos.X : pos.Y; + localPosition -= axisInverted ? scrollLength : 0; + return TimeAtPosition(localPosition, Time.Current); } /// - /// Given a time, return the screen space position within this column. + /// Given a time, return the position along the scrolling axis within this at time . + /// + public float PositionAtTime(double time, double currentTime) + { + float scrollPosition = scrollingInfo.Algorithm.PositionAt(time, currentTime, timeRange.Value, scrollLength); + return axisInverted ? -scrollPosition : scrollPosition; + } + + /// + /// Given a time, return the position along the scrolling axis within this at the current time. + /// + public float PositionAtTime(double time) => PositionAtTime(time, Time.Current); + + /// + /// Given a time, return the screen space position within this . + /// In the non-scrolling axis, the center of this is returned. /// public Vector2 ScreenSpacePositionAtTime(double time) { - var pos = scrollingInfo.Algorithm.PositionAt(time, Time.Current, scrollingInfo.TimeRange.Value, scrollLength); - - 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)); - } + float localPosition = PositionAtTime(time, Time.Current); + localPosition += axisInverted ? scrollLength : 0; + return scrollingAxis == Direction.Horizontal + ? ToScreenSpace(new Vector2(localPosition, DrawHeight / 2)) + : ToScreenSpace(new Vector2(DrawWidth / 2, localPosition)); } - private float scrollLength + /// + /// Given a start time and end time of a scrolling object, return the length of the object along the scrolling axis. + /// + public float LengthAtTime(double startTime, double endTime) { - get - { - switch (scrollingInfo.Direction.Value) - { - case ScrollingDirection.Left: - case ScrollingDirection.Right: - return DrawWidth; - - default: - return DrawHeight; - } - } + return scrollingInfo.Algorithm.GetLength(startTime, endTime, timeRange.Value, scrollLength); } - private float getBreadth() - { - switch (scrollingInfo.Direction.Value) - { - case ScrollingDirection.Up: - case ScrollingDirection.Down: - return DrawWidth; + private float scrollLength => scrollingAxis == Direction.Horizontal ? DrawWidth : DrawHeight; - default: - return DrawHeight; - } + protected override void AddDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable) + { + base.AddDrawable(entry, drawable); + + invalidateHitObject(drawable); + drawable.DefaultsApplied += invalidateHitObject; } - private void flipPositionIfRequired(ref float position) + protected override void RemoveDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable) { - // 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. + base.RemoveDrawable(entry, drawable); - switch (scrollingInfo.Direction.Value) - { - case ScrollingDirection.Down: - position = DrawHeight - position; - break; - - case ScrollingDirection.Right: - position = DrawWidth - position; - break; - } - } - - protected override void OnAdd(DrawableHitObject drawableHitObject) - { - invalidateHitObject(drawableHitObject); - drawableHitObject.DefaultsApplied += invalidateHitObject; - } - - protected override void OnRemove(DrawableHitObject drawableHitObject) - { - layoutComputed.Remove(drawableHitObject); - - drawableHitObject.DefaultsApplied -= invalidateHitObject; + drawable.DefaultsApplied -= invalidateHitObject; + layoutComputed.Remove(drawable); } private void invalidateHitObject(DrawableHitObject hitObject) @@ -206,6 +186,9 @@ namespace osu.Game.Rulesets.UI.Scrolling private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject) { + // Origin position may be relative to the parent size + Debug.Assert(hitObject.Parent != null); + float originAdjustment = 0.0f; // calculate the dimension of the part of the hitobject that should already be visible @@ -236,18 +219,11 @@ namespace osu.Game.Rulesets.UI.Scrolling { if (hitObject.HitObject is IHasDuration e) { - switch (direction.Value) - { - case ScrollingDirection.Up: - case ScrollingDirection.Down: - 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, e.EndTime, timeRange.Value, scrollLength); - break; - } + float length = LengthAtTime(hitObject.HitObject.StartTime, e.EndTime); + if (scrollingAxis == Direction.Horizontal) + hitObject.Width = length; + else + hitObject.Height = length; } foreach (var obj in hitObject.NestedHitObjects) @@ -261,24 +237,12 @@ namespace osu.Game.Rulesets.UI.Scrolling private void updatePosition(DrawableHitObject hitObject, double currentTime) { - switch (direction.Value) - { - case ScrollingDirection.Up: - hitObject.Y = scrollingInfo.Algorithm.PositionAt(hitObject.HitObject.StartTime, currentTime, timeRange.Value, scrollLength); - break; + float position = PositionAtTime(hitObject.HitObject.StartTime, currentTime); - case ScrollingDirection.Down: - hitObject.Y = -scrollingInfo.Algorithm.PositionAt(hitObject.HitObject.StartTime, currentTime, timeRange.Value, scrollLength); - break; - - case ScrollingDirection.Left: - hitObject.X = scrollingInfo.Algorithm.PositionAt(hitObject.HitObject.StartTime, currentTime, timeRange.Value, scrollLength); - break; - - case ScrollingDirection.Right: - hitObject.X = -scrollingInfo.Algorithm.PositionAt(hitObject.HitObject.StartTime, currentTime, timeRange.Value, scrollLength); - break; - } + if (scrollingAxis == Direction.Horizontal) + hitObject.X = position; + else + hitObject.Y = position; } } } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 97cb5ca7ab..2f17167297 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -65,6 +65,10 @@ namespace osu.Game.Scoring.Legacy scoreInfo.Mods = currentRuleset.ConvertFromLegacyMods((LegacyMods)sr.ReadInt32()).ToArray(); + // lazer replays get a really high version number. + if (version < LegacyScoreEncoder.FIRST_LAZER_VERSION) + scoreInfo.Mods = scoreInfo.Mods.Append(currentRuleset.GetAllMods().OfType().Single()).ToArray(); + currentBeatmap = workingBeatmap.GetPlayableBeatmap(currentRuleset.RulesetInfo, scoreInfo.Mods); scoreInfo.Beatmap = currentBeatmap.BeatmapInfo; diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index f8dd6953ad..288552879c 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -15,7 +15,16 @@ namespace osu.Game.Scoring.Legacy { public class LegacyScoreEncoder { - public const int LATEST_VERSION = 128; + /// + /// Database version in stable-compatible YYYYMMDD format. + /// Should be incremented if any changes are made to the format/usage. + /// + public const int LATEST_VERSION = FIRST_LAZER_VERSION; + + /// + /// The first stable-compatible YYYYMMDD format version given to lazer usage of replays. + /// + public const int FIRST_LAZER_VERSION = 30000000; private readonly Score score; private readonly IBeatmap beatmap; diff --git a/osu.Game/Scoring/Score.cs b/osu.Game/Scoring/Score.cs index 4e82b1584e..83e4389dc8 100644 --- a/osu.Game/Scoring/Score.cs +++ b/osu.Game/Scoring/Score.cs @@ -2,12 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Replays; +using osu.Game.Utils; namespace osu.Game.Scoring { - public class Score + public class Score : IDeepCloneable { public ScoreInfo ScoreInfo = new ScoreInfo(); public Replay Replay = new Replay(); + + public Score DeepClone() + { + return new Score + { + ScoreInfo = ScoreInfo.DeepClone(), + Replay = Replay.DeepClone(), + }; + } } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index a6faaf6379..a0c4d5a026 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -7,7 +7,6 @@ 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.Online.API; @@ -19,7 +18,7 @@ using osu.Game.Utils; namespace osu.Game.Scoring { - public class ScoreInfo : IHasFiles, IHasPrimaryKey, ISoftDelete, IEquatable + public class ScoreInfo : IHasFiles, IHasPrimaryKey, ISoftDelete, IEquatable, IDeepCloneable { public int ID { get; set; } @@ -46,7 +45,7 @@ namespace osu.Game.Scoring [JsonIgnore] public int Combo { get; set; } // Todo: Shouldn't exist in here - [JsonIgnore] + [JsonProperty("ruleset_id")] public int RulesetID { get; set; } [JsonProperty("passed")] @@ -76,9 +75,6 @@ namespace osu.Game.Scoring else if (localAPIMods != null) scoreMods = apiMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - if (IsLegacyScore) - scoreMods = scoreMods.Append(rulesetInstance.GetAllMods().OfType().Single()).ToArray(); - return scoreMods; } set @@ -201,45 +197,24 @@ namespace osu.Game.Scoring [JsonProperty("position")] public int? Position { get; set; } - private bool isLegacyScore; - /// /// Whether this represents a legacy (osu!stable) score. /// [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 | | 1 - // lazer | | - // lazer (offline) | Guest | 1 - - return ID > 0 && UserID == 1 && UserString != "Guest"; - } - set => isLegacyScore = value; - } + public bool IsLegacyScore => Mods.OfType().Any(); public IEnumerable GetStatisticsForDisplay() { foreach (var r in Ruleset.CreateInstance().GetHitResults()) { - int value = Statistics.GetOrDefault(r.result); + int value = Statistics.GetValueOrDefault(r.result); switch (r.result) { case HitResult.SmallTickHit: { - int total = value + Statistics.GetOrDefault(HitResult.SmallTickMiss); + int total = value + Statistics.GetValueOrDefault(HitResult.SmallTickMiss); if (total > 0) yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); @@ -248,7 +223,7 @@ namespace osu.Game.Scoring case HitResult.LargeTickHit: { - int total = value + Statistics.GetOrDefault(HitResult.LargeTickMiss); + int total = value + Statistics.GetValueOrDefault(HitResult.LargeTickMiss); if (total > 0) yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); @@ -267,6 +242,15 @@ namespace osu.Game.Scoring } } + public ScoreInfo DeepClone() + { + var clone = (ScoreInfo)MemberwiseClone(); + + clone.Statistics = new Dictionary(clone.Statistics); + + return clone; + } + public override string ToString() => $"{User} playing {Beatmap}"; public bool Equals(ScoreInfo other) diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 9d3b952ada..83bcac01ac 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -7,10 +7,10 @@ using System.IO; using System.Linq; using System.Linq.Expressions; using System.Threading; +using System.Threading.Tasks; 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; @@ -72,6 +72,9 @@ namespace osu.Game.Scoring } } + protected override Task Populate(ScoreInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) + => Task.CompletedTask; + protected override void ExportModelTo(ScoreInfo model, Stream outputStream) { var file = model.Files.SingleOrDefault(); @@ -204,9 +207,9 @@ namespace osu.Game.Scoring } else { - // This score is guaranteed to be an osu!lazer score. + // This is guaranteed to be a non-legacy 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().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetOrDefault(r)).Sum(); + beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); } updateScore(beatmapMaxCombo, accuracy); diff --git a/osu.Game/Scoring/ScoreRank.cs b/osu.Game/Scoring/ScoreRank.cs index 696d493830..f3b4551ff8 100644 --- a/osu.Game/Scoring/ScoreRank.cs +++ b/osu.Game/Scoring/ScoreRank.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.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Scoring { + [LocalisableEnum(typeof(ScoreRankEnumLocalisationMapper))] public enum ScoreRank { [Description(@"D")] @@ -31,4 +35,40 @@ namespace osu.Game.Scoring [Description(@"SS+")] XH, } + + public class ScoreRankEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(ScoreRank value) + { + switch (value) + { + case ScoreRank.XH: + return BeatmapsStrings.RankXH; + + case ScoreRank.X: + return BeatmapsStrings.RankX; + + case ScoreRank.SH: + return BeatmapsStrings.RankSH; + + case ScoreRank.S: + return BeatmapsStrings.RankS; + + case ScoreRank.A: + return BeatmapsStrings.RankA; + + case ScoreRank.B: + return BeatmapsStrings.RankB; + + case ScoreRank.C: + return BeatmapsStrings.RankC; + + case ScoreRank.D: + return BeatmapsStrings.RankD; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index bd4577fd57..f0c90cc409 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -5,8 +5,8 @@ using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Utils; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Backgrounds; @@ -31,6 +31,8 @@ namespace osu.Game.Screens.Backgrounds [Resolved] private IBindable beatmap { get; set; } + protected virtual bool AllowStoryboardBackground => true; + public BackgroundScreenDefault(bool animateOnEnter = true) : base(animateOnEnter) { @@ -51,14 +53,41 @@ namespace osu.Game.Screens.Backgrounds mode.ValueChanged += _ => Next(); beatmap.ValueChanged += _ => Next(); introSequence.ValueChanged += _ => Next(); - seasonalBackgroundLoader.SeasonalBackgroundChanged += Next; + seasonalBackgroundLoader.SeasonalBackgroundChanged += () => Next(); currentDisplay = RNG.Next(0, background_count); Next(); } - private void display(Background newBackground) + private ScheduledDelegate nextTask; + private CancellationTokenSource cancellationTokenSource; + + /// + /// Request loading the next background. + /// + /// Whether a new background was queued for load. May return false if the current background is still valid. + public bool Next() + { + var nextBackground = createBackground(); + + // in the case that the background hasn't changed, we want to avoid cancelling any tasks that could still be loading. + if (nextBackground == background) + return false; + + cancellationTokenSource?.Cancel(); + cancellationTokenSource = new CancellationTokenSource(); + + nextTask?.Cancel(); + nextTask = Scheduler.AddDelayed(() => + { + LoadComponentAsync(nextBackground, displayNext, cancellationTokenSource.Token); + }, 100); + + return true; + } + + private void displayNext(Background newBackground) { background?.FadeOut(800, Easing.InOutSine); background?.Expire(); @@ -67,76 +96,55 @@ namespace osu.Game.Screens.Backgrounds currentDisplay++; } - private ScheduledDelegate nextTask; - private CancellationTokenSource cancellationTokenSource; - - public void Next() - { - nextTask?.Cancel(); - cancellationTokenSource?.Cancel(); - cancellationTokenSource = new CancellationTokenSource(); - nextTask = Scheduler.AddDelayed(() => LoadComponentAsync(createBackground(), display, cancellationTokenSource.Token), 100); - } - private Background createBackground() { - Background newBackground; - string backgroundName; + // seasonal background loading gets highest priority. + Background newBackground = seasonalBackgroundLoader.LoadNextBackground(); - 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) + if (newBackground == null && user.Value?.IsSupporter == true) { switch (mode.Value) { case BackgroundSource.Beatmap: - newBackground = new BeatmapBackground(beatmap.Value, backgroundName); - break; + case BackgroundSource.BeatmapWithStoryboard: + { + if (mode.Value == BackgroundSource.BeatmapWithStoryboard && AllowStoryboardBackground) + newBackground = new BeatmapBackgroundWithStoryboard(beatmap.Value, getBackgroundTextureName()); + newBackground ??= new BeatmapBackground(beatmap.Value, getBackgroundTextureName()); - default: - newBackground = new SkinnedBackground(skin.Value, backgroundName); + break; + } + + case BackgroundSource.Skin: + // default skins should use the default background rotation, which won't be the case if a SkinBackground is created for them. + if (skin.Value is DefaultSkin || skin.Value is DefaultLegacySkin) + break; + + newBackground = new SkinBackground(skin.Value, getBackgroundTextureName()); break; } } - else - newBackground = new Background(backgroundName); + // this method is called in many cases where the background might not necessarily need to change. + // if an equivalent background is currently being shown, we don't want to load it again. + if (newBackground?.Equals(background) == true) + return background; + + newBackground ??= new Background(getBackgroundTextureName()); newBackground.Depth = currentDisplay; return newBackground; } - private class SkinnedBackground : Background + private string getBackgroundTextureName() { - private readonly Skin skin; - - public SkinnedBackground(Skin skin, string fallbackTextureName) - : base(fallbackTextureName) + switch (introSequence.Value) { - this.skin = skin; - } + case IntroSequence.Welcome: + return @"Intro/Welcome/menu-background"; - [BackgroundDependencyLoader] - private void load() - { - Sprite.Texture = skin.GetTexture("menu-background") ?? Sprite.Texture; + default: + return $@"Menu/menu-background-{currentDisplay % background_count + 1}"; } } } diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs similarity index 69% rename from osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs rename to osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs index 1f608d28fd..d66856ebd8 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs @@ -5,9 +5,11 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -16,26 +18,30 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.RadioButtons { - public class DrawableRadioButton : OsuButton + public class EditorRadioButton : OsuButton, IHasTooltip { /// - /// Invoked when this has been selected. + /// Invoked when this has been selected. /// public Action Selected; + public readonly RadioButton Button; + private Color4 defaultBackgroundColour; private Color4 defaultBubbleColour; private Color4 selectedBackgroundColour; private Color4 selectedBubbleColour; private Drawable icon; - private readonly RadioButton button; - public DrawableRadioButton(RadioButton button) + [Resolved(canBeNull: true)] + private EditorBeatmap editorBeatmap { get; set; } + + public EditorRadioButton(RadioButton button) { - this.button = button; + Button = button; - Text = button.Item.ToString(); + Text = button.Label; Action = button.Select; RelativeSizeAxes = Axes.X; @@ -57,7 +63,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons Colour = Color4.Black.Opacity(0.5f) }; - Add(icon = (button.CreateIcon?.Invoke() ?? new Circle()).With(b => + Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => { b.Blending = BlendingParameters.Additive; b.Anchor = Anchor.CentreLeft; @@ -71,13 +77,16 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons { base.LoadComplete(); - button.Selected.ValueChanged += selected => + Button.Selected.ValueChanged += selected => { updateSelectionState(); if (selected.NewValue) - Selected?.Invoke(button); + Selected?.Invoke(Button); }; + editorBeatmap?.HasTiming.BindValueChanged(hasTiming => Button.Selected.Disabled = !hasTiming.NewValue, true); + + Button.Selected.BindDisabledChanged(disabled => Enabled.Value = !disabled, true); updateSelectionState(); } @@ -86,8 +95,8 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons if (!IsLoaded) return; - BackgroundColour = button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour; - icon.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; + BackgroundColour = Button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour; + icon.Colour = Button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; } protected override SpriteText CreateText() => new OsuSpriteText @@ -97,5 +106,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons Anchor = Anchor.CentreLeft, X = 40f }; + + public LocalisableString TooltipText => Enabled.Value ? string.Empty : "Add at least one timing point first!"; } } diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButtonCollection.cs b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButtonCollection.cs similarity index 85% rename from osu.Game/Screens/Edit/Components/RadioButtons/RadioButtonCollection.cs rename to osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButtonCollection.cs index 16574c0baf..6a7b0c9ef7 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButtonCollection.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButtonCollection.cs @@ -9,7 +9,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Components.RadioButtons { - public class RadioButtonCollection : CompositeDrawable + public class EditorRadioButtonCollection : CompositeDrawable { private IReadOnlyList items; @@ -28,13 +28,13 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons } } - private readonly FlowContainer buttonContainer; + private readonly FlowContainer buttonContainer; - public RadioButtonCollection() + public EditorRadioButtonCollection() { AutoSizeAxes = Axes.Y; - InternalChild = buttonContainer = new FillFlowContainer + InternalChild = buttonContainer = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -58,7 +58,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons currentlySelected = null; }; - buttonContainer.Add(new DrawableRadioButton(button)); + buttonContainer.Add(new EditorRadioButton(button)); } } } diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs index dcf5f8a788..ca79dd15d7 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons /// /// The item related to this button. /// - public object Item; + public string Label; /// /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. @@ -26,21 +26,14 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons private readonly Action action; - public RadioButton(object item, Action action, Func createIcon = null) + public RadioButton(string label, Action action, Func createIcon = null) { - Item = item; + Label = label; CreateIcon = createIcon; this.action = action; Selected = new BindableBool(); } - public RadioButton(string item) - : this(item, null) - { - Item = item; - action = null; - } - /// /// Selects this . /// diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 8a4d381535..185f029d14 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; 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; @@ -94,6 +95,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Creates a for a specific item. /// /// The item to create the overlay for. + [CanBeNull] protected virtual SelectionBlueprint CreateBlueprintFor(T item) => null; protected virtual DragBox CreateDragBox(Action performSelect) => new DragBox(performSelect); diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 3e97e15cca..79b38861ee 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using Humanizer; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -256,9 +257,10 @@ namespace osu.Game.Screens.Edit.Compose.Components if (drawable == null) return null; - return CreateHitObjectBlueprintFor(item).With(b => b.DrawableObject = drawable); + return CreateHitObjectBlueprintFor(item)?.With(b => b.DrawableObject = drawable); } + [CanBeNull] public virtual HitObjectSelectionBlueprint CreateHitObjectBlueprintFor(HitObject hitObject) => null; private void hitObjectAdded(HitObject obj) diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs index 3b1dae6c3d..3ac40fda0f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osuTK; using osuTK.Graphics; @@ -58,6 +59,6 @@ namespace osu.Game.Screens.Edit.Compose.Components icon.FadeColour(!IsHeld && IsHovered ? Color4.White : Color4.Black, TRANSFORM_DURATION, Easing.OutQuint); } - public string TooltipText { get; } + public LocalisableString TooltipText { get; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index 6f04f36b83..a642768574 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -89,7 +90,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } else { - placementBlueprint = CreateBlueprintFor(obj.NewValue); + placementBlueprint = CreateBlueprintFor(obj.NewValue).AsNonNull(); placementBlueprint.Colour = Color4.MediumPurple; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index dbe689be2f..7f8cc1c8fa 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -39,6 +38,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private Bindable indexInCurrentComboBindable; private Bindable comboIndexBindable; + private Bindable displayColourBindable; private readonly ExtendableCircle circle; private readonly Border border; @@ -108,44 +108,61 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { base.LoadComplete(); - if (Item is IHasComboInformation comboInfo) + switch (Item) { - indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); - indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true); + case IHasDisplayColour displayColour: + displayColourBindable = displayColour.DisplayColour.GetBoundCopy(); + displayColourBindable.BindValueChanged(_ => updateColour(), true); + break; - comboIndexBindable = comboInfo.ComboIndexBindable.GetBoundCopy(); - comboIndexBindable.BindValueChanged(_ => updateComboColour(), true); + case IHasComboInformation comboInfo: + indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); + indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true); - skin.SourceChanged += updateComboColour; + comboIndexBindable = comboInfo.ComboIndexBindable.GetBoundCopy(); + comboIndexBindable.BindValueChanged(_ => updateColour(), true); + + skin.SourceChanged += updateColour; + break; } } protected override void OnSelected() { // base logic hides selected blueprints when not selected, but timeline doesn't do that. - updateComboColour(); + updateColour(); } protected override void OnDeselected() { // base logic hides selected blueprints when not selected, but timeline doesn't do that. - updateComboColour(); + updateColour(); } private void updateComboIndex() => comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString(); - private void updateComboColour() + private void updateColour() { - if (!(Item is IHasComboInformation combo)) - return; + Color4 colour; - var comboColours = skin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); - var comboColour = combo.GetComboColour(comboColours); + switch (Item) + { + case IHasDisplayColour displayColour: + colour = displayColour.DisplayColour.Value; + break; + + case IHasComboInformation combo: + colour = combo.GetComboColour(skin); + break; + + default: + return; + } if (IsSelected) { border.Show(); - comboColour = comboColour.Lighten(0.3f); + colour = colour.Lighten(0.3f); } else { @@ -153,9 +170,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } if (Item is IHasDuration duration && duration.Duration > 0) - circle.Colour = ColourInfo.GradientHorizontal(comboColour, comboColour.Lighten(0.4f)); + circle.Colour = ColourInfo.GradientHorizontal(colour, colour.Lighten(0.4f)); else - circle.Colour = comboColour; + circle.Colour = colour; var col = circle.Colour.TopLeft.Linear; colouredComponents.Colour = OsuColour.ForegroundTextColourFor(col); diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 61056aeced..b56f9bee14 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -73,15 +73,7 @@ namespace osu.Game.Screens.Edit.Compose { Debug.Assert(ruleset != null); - 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(content)); + return new RulesetSkinProvidingContainer(ruleset, EditorBeatmap.PlayableBeatmap, beatmap.Value.Skin).WithChild(content); } #region Input Handling diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 986a4efb28..71dd47b058 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -128,7 +128,7 @@ namespace osu.Game.Screens.Edit // clone these locally for now to avoid incurring overhead on GetPlayableBeatmap usages. // eventually we will want to improve how/where this is done as there are issues with *not* cloning it in all cases. - playableBeatmap.ControlPointInfo = playableBeatmap.ControlPointInfo.CreateCopy(); + playableBeatmap.ControlPointInfo = playableBeatmap.ControlPointInfo.DeepClone(); } catch (Exception e) { diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index be53abbd55..7de98e5e85 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -46,12 +46,22 @@ namespace osu.Game.Screens.Edit public readonly IBeatmap PlayableBeatmap; + /// + /// Whether at least one timing control point is present and providing timing information. + /// + public IBindable HasTiming => hasTiming; + + private readonly Bindable hasTiming = new Bindable(); + [CanBeNull] public readonly ISkin BeatmapSkin; [Resolved] private BindableBeatDivisor beatDivisor { get; set; } + [Resolved] + private EditorClock editorClock { get; set; } + private readonly IBeatmapProcessor beatmapProcessor; private readonly Dictionary> startTimeBindables = new Dictionary>(); @@ -238,6 +248,8 @@ namespace osu.Game.Screens.Edit if (batchPendingUpdates.Count > 0) UpdateState(); + + hasTiming.Value = !ReferenceEquals(ControlPointInfo.TimingPointAt(editorClock.CurrentTime), TimingControlPoint.DEFAULT); } protected override void UpdateState() diff --git a/osu.Game/Screens/Edit/Setup/ColoursSection.cs b/osu.Game/Screens/Edit/Setup/ColoursSection.cs index cb7deadcb7..4a81959a54 100644 --- a/osu.Game/Screens/Edit/Setup/ColoursSection.cs +++ b/osu.Game/Screens/Edit/Setup/ColoursSection.cs @@ -25,6 +25,7 @@ namespace osu.Game.Screens.Edit.Setup comboColours = new LabelledColourPalette { Label = "Hitcircle / Slider Combos", + FixedLabelWidth = LABEL_WIDTH, ColourNamePrefix = "Combo" } }; diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index 493d3ed20c..a8800d524f 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -28,6 +28,7 @@ namespace osu.Game.Screens.Edit.Setup circleSizeSlider = new LabelledSliderBar { Label = "Object Size", + FixedLabelWidth = LABEL_WIDTH, Description = "The size of all hit objects", Current = new BindableFloat(Beatmap.BeatmapInfo.BaseDifficulty.CircleSize) { @@ -40,6 +41,7 @@ namespace osu.Game.Screens.Edit.Setup healthDrainSlider = new LabelledSliderBar { Label = "Health Drain", + FixedLabelWidth = LABEL_WIDTH, Description = "The rate of passive health drain throughout playable time", Current = new BindableFloat(Beatmap.BeatmapInfo.BaseDifficulty.DrainRate) { @@ -52,6 +54,7 @@ namespace osu.Game.Screens.Edit.Setup approachRateSlider = new LabelledSliderBar { Label = "Approach Rate", + FixedLabelWidth = LABEL_WIDTH, Description = "The speed at which objects are presented to the player", Current = new BindableFloat(Beatmap.BeatmapInfo.BaseDifficulty.ApproachRate) { @@ -64,6 +67,7 @@ namespace osu.Game.Screens.Edit.Setup overallDifficultySlider = new LabelledSliderBar { Label = "Overall Difficulty", + FixedLabelWidth = LABEL_WIDTH, Description = "The harshness of hit windows and difficulty of special objects (ie. spinners)", Current = new BindableFloat(Beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty) { diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs index a33a70af65..69c27702f8 100644 --- a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs +++ b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs @@ -56,9 +56,9 @@ namespace osu.Game.Screens.Edit.Setup public void DisplayFileChooser() { - FileSelector fileSelector; + OsuFileSelector fileSelector; - Target.Child = fileSelector = new FileSelector(currentFile.Value?.DirectoryName, handledExtensions) + Target.Child = fileSelector = new OsuFileSelector(currentFile.Value?.DirectoryName, handledExtensions) { RelativeSizeAxes = Axes.X, Height = 400, diff --git a/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs b/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.cs new file mode 100644 index 0000000000..ee9d86029e --- /dev/null +++ b/osu.Game/Screens/Edit/Setup/LabelledRomanisedTextBox.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.Beatmaps; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Screens.Edit.Setup +{ + internal class LabelledRomanisedTextBox : LabelledTextBox + { + protected override OsuTextBox CreateTextBox() => new RomanisedTextBox(); + + private class RomanisedTextBox : OsuTextBox + { + protected override bool CanAddCharacter(char character) + => MetadataUtils.IsRomanised(character); + } + } +} diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 889a5eab5e..9e93b0b038 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -3,75 +3,117 @@ using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Screens.Edit.Setup { internal class MetadataSection : SetupSection { - private LabelledTextBox artistTextBox; - private LabelledTextBox titleTextBox; + protected LabelledTextBox ArtistTextBox; + protected LabelledTextBox RomanisedArtistTextBox; + + protected LabelledTextBox TitleTextBox; + protected LabelledTextBox RomanisedTitleTextBox; + private LabelledTextBox creatorTextBox; private LabelledTextBox difficultyTextBox; + private LabelledTextBox sourceTextBox; + private LabelledTextBox tagsTextBox; public override LocalisableString Title => "Metadata"; [BackgroundDependencyLoader] private void load() { - Children = new Drawable[] + var metadata = Beatmap.Metadata; + + Children = new[] { - artistTextBox = new LabelledTextBox - { - Label = "Artist", - Current = { Value = Beatmap.Metadata.Artist }, - TabbableContentContainer = this - }, - titleTextBox = new LabelledTextBox - { - Label = "Title", - Current = { Value = Beatmap.Metadata.Title }, - TabbableContentContainer = this - }, - creatorTextBox = new LabelledTextBox - { - Label = "Creator", - Current = { Value = Beatmap.Metadata.AuthorString }, - TabbableContentContainer = this - }, - difficultyTextBox = new LabelledTextBox - { - Label = "Difficulty Name", - Current = { Value = Beatmap.BeatmapInfo.Version }, - TabbableContentContainer = this - }, + ArtistTextBox = createTextBox("Artist", + !string.IsNullOrEmpty(metadata.ArtistUnicode) ? metadata.ArtistUnicode : metadata.Artist), + RomanisedArtistTextBox = createTextBox("Romanised Artist", + !string.IsNullOrEmpty(metadata.Artist) ? metadata.Artist : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), + + Empty(), + + TitleTextBox = createTextBox("Title", + !string.IsNullOrEmpty(metadata.TitleUnicode) ? metadata.TitleUnicode : metadata.Title), + RomanisedTitleTextBox = createTextBox("Romanised Title", + !string.IsNullOrEmpty(metadata.Title) ? metadata.Title : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), + + Empty(), + + creatorTextBox = createTextBox("Creator", metadata.AuthorString), + difficultyTextBox = createTextBox("Difficulty Name", Beatmap.BeatmapInfo.Version), + sourceTextBox = createTextBox("Source", metadata.Source), + tagsTextBox = createTextBox("Tags", metadata.Tags) }; foreach (var item in Children.OfType()) item.OnCommit += onCommit; } + private TTextBox createTextBox(string label, string initialValue) + where TTextBox : LabelledTextBox, new() + => new TTextBox + { + Label = label, + FixedLabelWidth = LABEL_WIDTH, + Current = { Value = initialValue }, + TabbableContentContainer = this + }; + protected override void LoadComplete() { base.LoadComplete(); - if (string.IsNullOrEmpty(artistTextBox.Current.Value)) - GetContainingInputManager().ChangeFocus(artistTextBox); + if (string.IsNullOrEmpty(ArtistTextBox.Current.Value)) + GetContainingInputManager().ChangeFocus(ArtistTextBox); + + ArtistTextBox.Current.BindValueChanged(artist => transferIfRomanised(artist.NewValue, RomanisedArtistTextBox)); + TitleTextBox.Current.BindValueChanged(title => transferIfRomanised(title.NewValue, RomanisedTitleTextBox)); + updateReadOnlyState(); + } + + private void transferIfRomanised(string value, LabelledTextBox target) + { + if (MetadataUtils.IsRomanised(value)) + target.Current.Value = value; + + updateReadOnlyState(); + updateMetadata(); + } + + private void updateReadOnlyState() + { + RomanisedArtistTextBox.ReadOnly = MetadataUtils.IsRomanised(ArtistTextBox.Current.Value); + RomanisedTitleTextBox.ReadOnly = MetadataUtils.IsRomanised(TitleTextBox.Current.Value); } private void onCommit(TextBox sender, bool newText) { if (!newText) return; - // for now, update these on commit rather than making BeatmapMetadata bindables. + // for now, update on commit rather than making BeatmapMetadata bindables. // after switching database engines we can reconsider if switching to bindables is a good direction. - Beatmap.Metadata.Artist = artistTextBox.Current.Value; - Beatmap.Metadata.Title = titleTextBox.Current.Value; + updateMetadata(); + } + + private void updateMetadata() + { + Beatmap.Metadata.ArtistUnicode = ArtistTextBox.Current.Value; + Beatmap.Metadata.Artist = RomanisedArtistTextBox.Current.Value; + + Beatmap.Metadata.TitleUnicode = TitleTextBox.Current.Value; + Beatmap.Metadata.Title = RomanisedTitleTextBox.Current.Value; + Beatmap.Metadata.AuthorString = creatorTextBox.Current.Value; Beatmap.BeatmapInfo.Version = difficultyTextBox.Current.Value; + Beatmap.Metadata.Source = sourceTextBox.Current.Value; + Beatmap.Metadata.Tags = tagsTextBox.Current.Value; } } } diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index 12270f2aa4..ba22c82ecc 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -54,6 +54,7 @@ namespace osu.Game.Screens.Edit.Setup backgroundTextBox = new FileChooserLabelledTextBox(".jpg", ".jpeg", ".png") { Label = "Background", + FixedLabelWidth = LABEL_WIDTH, PlaceholderText = "Click to select a background image", Current = { Value = working.Value.Metadata.BackgroundFile }, Target = backgroundFileChooserContainer, @@ -72,6 +73,7 @@ namespace osu.Game.Screens.Edit.Setup audioTrackTextBox = new FileChooserLabelledTextBox(".mp3", ".ogg") { Label = "Audio Track", + FixedLabelWidth = LABEL_WIDTH, PlaceholderText = "Click to select a track", Current = { Value = working.Value.Metadata.AudioFile }, Target = audioTrackFileChooserContainer, diff --git a/osu.Game/Screens/Edit/Setup/SetupSection.cs b/osu.Game/Screens/Edit/Setup/SetupSection.cs index 8964e651df..1f988d62e2 100644 --- a/osu.Game/Screens/Edit/Setup/SetupSection.cs +++ b/osu.Game/Screens/Edit/Setup/SetupSection.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; using osuTK; namespace osu.Game.Screens.Edit.Setup @@ -15,6 +16,11 @@ namespace osu.Game.Screens.Edit.Setup { private readonly FillFlowContainer flow; + /// + /// Used to align some of the child s together to achieve a grid-like look. + /// + protected const float LABEL_WIDTH = 160; + [Resolved] protected OsuColour Colours { get; private set; } @@ -53,7 +59,7 @@ namespace osu.Game.Screens.Edit.Setup { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(10), Direction = FillDirection.Vertical, } } diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index fe63138d28..7a98cf63c3 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -26,7 +26,7 @@ namespace osu.Game.Screens.Edit.Timing [Resolved] private EditorClock clock { get; set; } - public const float TIMING_COLUMN_WIDTH = 220; + public const float TIMING_COLUMN_WIDTH = 230; public IEnumerable ControlGroups { @@ -91,7 +91,7 @@ namespace osu.Game.Screens.Edit.Timing { Text = group.Time.ToEditorFormattedString(), Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.Bold), - Width = 60, + Width = 70, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, }, diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index cc8778d9ae..0434135547 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -67,8 +67,11 @@ namespace osu.Game.Screens /// Invoked when the back button has been pressed to close any overlays before exiting this . /// /// + /// If this has not yet finished loading, the exit will occur immediately without this method being invoked. + /// /// Return true to block this from being exited after closing an overlay. /// Return false if this should continue exiting. + /// /// bool OnBackButton(); } diff --git a/osu.Game/Screens/Import/FileImportScreen.cs b/osu.Game/Screens/Import/FileImportScreen.cs index ee8ef6926d..7e1d55b3e2 100644 --- a/osu.Game/Screens/Import/FileImportScreen.cs +++ b/osu.Game/Screens/Import/FileImportScreen.cs @@ -23,7 +23,7 @@ namespace osu.Game.Screens.Import { public override bool HideOverlaysOnEnter => true; - private FileSelector fileSelector; + private OsuFileSelector fileSelector; private Container contentContainer; private TextFlowContainer currentFileText; @@ -57,7 +57,7 @@ namespace osu.Game.Screens.Import Colour = colours.GreySeafoamDark, RelativeSizeAxes = Axes.Both, }, - fileSelector = new FileSelector(validFileExtensions: game.HandledExtensions.ToArray()) + fileSelector = new OsuFileSelector(validFileExtensions: game.HandledExtensions.ToArray()) { RelativeSizeAxes = Axes.Both, Width = 0.65f diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index a836f7bf09..bdb0157746 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -261,7 +261,7 @@ namespace osu.Game.Screens.Menu switch (state) { default: - return true; + return false; case ButtonSystemState.Initial: State = ButtonSystemState.TopLevel; @@ -297,7 +297,7 @@ namespace osu.Game.Screens.Menu Logger.Log($"{nameof(ButtonSystem)}'s state changed from {lastState} to {state}"); - using (buttonArea.BeginDelayedSequence(lastState == ButtonSystemState.Initial ? 150 : 0, true)) + using (buttonArea.BeginDelayedSequence(lastState == ButtonSystemState.Initial ? 150 : 0)) { buttonArea.ButtonSystemState = state; diff --git a/osu.Game/Screens/Menu/Disclaimer.cs b/osu.Game/Screens/Menu/Disclaimer.cs index 72eb9c7c0c..7f34e1e395 100644 --- a/osu.Game/Screens/Menu/Disclaimer.cs +++ b/osu.Game/Screens/Menu/Disclaimer.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.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -36,6 +37,8 @@ namespace osu.Game.Screens.Menu private readonly Bindable currentUser = new Bindable(); private FillFlowContainer fill; + private readonly List expendableText = new List(); + public Disclaimer(OsuScreen nextScreen = null) { this.nextScreen = nextScreen; @@ -54,7 +57,7 @@ namespace osu.Game.Screens.Menu { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Icon = FontAwesome.Solid.Flask, + Icon = OsuIcon.Logo, Size = new Vector2(icon_size), Y = icon_y, }, @@ -70,37 +73,55 @@ namespace osu.Game.Screens.Menu { textFlow = new LinkFlowContainer { - RelativeSizeAxes = Axes.X, + Width = 680, AutoSizeAxes = Axes.Y, TextAnchor = Anchor.TopCentre, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Spacing = new Vector2(0, 2), - LayoutDuration = 2000, - LayoutEasing = Easing.OutQuint - }, - supportFlow = new LinkFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - TextAnchor = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Alpha = 0, - Spacing = new Vector2(0, 2), }, } - } + }, + supportFlow = new LinkFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + TextAnchor = Anchor.BottomCentre, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Padding = new MarginPadding(20), + Alpha = 0, + Spacing = new Vector2(0, 2), + }, }; - textFlow.AddText("This 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.AddText("this is osu!", t => t.Font = t.Font.With(Typeface.Torus, 30, FontWeight.Regular)); + + expendableText.AddRange(textFlow.AddText("lazer", t => + { + t.Font = t.Font.With(Typeface.Torus, 30, FontWeight.Regular); + t.Colour = colours.PinkLight; + })); + + static void formatRegular(SpriteText t) => t.Font = OsuFont.GetFont(size: 20, weight: FontWeight.Regular); + static void formatSemiBold(SpriteText t) => t.Font = OsuFont.GetFont(size: 20, weight: FontWeight.SemiBold); textFlow.NewParagraph(); - static void format(SpriteText t) => t.Font = OsuFont.GetFont(size: 15, weight: FontWeight.SemiBold); + textFlow.AddText("the next ", formatRegular); + textFlow.AddText("major update", t => + { + t.Font = t.Font.With(Typeface.Torus, 20, FontWeight.SemiBold); + t.Colour = colours.Pink; + }); + expendableText.AddRange(textFlow.AddText(" coming to osu!", formatRegular)); + textFlow.AddText(".", formatRegular); - textFlow.AddParagraph(getRandomTip(), t => t.Font = t.Font.With(Typeface.Torus, 20, FontWeight.SemiBold)); + textFlow.NewParagraph(); + textFlow.NewParagraph(); + + textFlow.AddParagraph("today's tip:", formatSemiBold); + textFlow.AddParagraph(getRandomTip(), formatRegular); textFlow.NewParagraph(); textFlow.NewParagraph(); @@ -116,19 +137,19 @@ namespace osu.Game.Screens.Menu if (e.NewValue.IsSupporter) { - supportFlow.AddText("Eternal thanks to you for supporting osu!", format); + supportFlow.AddText("Eternal thanks to you for supporting osu!", formatSemiBold); } else { - supportFlow.AddText("Consider becoming an ", format); - supportFlow.AddLink("osu!supporter", "https://osu.ppy.sh/home/support", creationParameters: format); - supportFlow.AddText(" to help support the game", format); + supportFlow.AddText("Consider becoming an ", formatSemiBold); + supportFlow.AddLink("osu!supporter", "https://osu.ppy.sh/home/support", formatSemiBold); + supportFlow.AddText(" to help support osu!'s development", formatSemiBold); } heart = supportFlow.AddIcon(FontAwesome.Solid.Heart, t => { t.Padding = new MarginPadding { Left = 5, Top = 3 }; - t.Font = t.Font.With(size: 12); + t.Font = t.Font.With(size: 20); t.Origin = Anchor.Centre; t.Colour = colours.Pink; }).First(); @@ -160,7 +181,7 @@ namespace osu.Game.Screens.Menu icon.Delay(500).FadeIn(500).ScaleTo(1, 500, Easing.OutQuint); - using (BeginDelayedSequence(3000, true)) + using (BeginDelayedSequence(3000)) { icon.FadeColour(iconColour, 200, Easing.OutQuint); icon.MoveToY(icon_y * 1.3f, 500, Easing.OutCirc) @@ -169,7 +190,15 @@ namespace osu.Game.Screens.Menu .MoveToY(icon_y, 160, Easing.InQuart) .FadeColour(Color4.White, 160); - fill.Delay(520 + 160).MoveToOffset(new Vector2(0, 15), 160, Easing.OutQuart); + using (BeginDelayedSequence(520 + 160)) + { + fill.MoveToOffset(new Vector2(0, 15), 160, Easing.OutQuart); + Schedule(() => expendableText.ForEach(t => + { + t.FadeOut(100); + t.ScaleTo(new Vector2(0, 1), 100, Easing.OutQuart); + })); + } } supportFlow.FadeOut().Delay(2000).FadeIn(500); @@ -201,7 +230,7 @@ namespace osu.Game.Screens.Menu "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, what used to be \"osu!direct\" is available to all users on lazer. You can access it anywhere using Ctrl-D!", + "What used to be \"osu!direct\" is available to all users just like on the website. 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!", diff --git a/osu.Game/Screens/Menu/IntroSequence.cs b/osu.Game/Screens/Menu/IntroSequence.cs index d92d38da45..3a5cd6857a 100644 --- a/osu.Game/Screens/Menu/IntroSequence.cs +++ b/osu.Game/Screens/Menu/IntroSequence.cs @@ -189,7 +189,7 @@ namespace osu.Game.Screens.Menu double remainingTime() => length - TransformDelay; - using (BeginDelayedSequence(250, true)) + using (BeginDelayedSequence(250)) { welcomeText.FadeIn(700); welcomeText.TransformSpacingTo(new Vector2(20, 0), remainingTime(), Easing.Out); @@ -212,17 +212,17 @@ namespace osu.Game.Screens.Menu lineBottomLeft.MoveTo(new Vector2(-line_end_offset, line_end_offset), line_duration, Easing.OutQuint); lineBottomRight.MoveTo(new Vector2(line_end_offset, line_end_offset), line_duration, Easing.OutQuint); - using (BeginDelayedSequence(length * 0.56, true)) + using (BeginDelayedSequence(length * 0.56)) { bigRing.ResizeTo(logo_size, 500, Easing.InOutQuint); bigRing.Foreground.Delay(250).ResizeTo(1, 850, Easing.OutQuint); - using (BeginDelayedSequence(250, true)) + using (BeginDelayedSequence(250)) { backgroundFill.ResizeHeightTo(1, remainingTime(), Easing.InOutQuart); backgroundFill.RotateTo(-90, remainingTime(), Easing.InOutQuart); - using (BeginDelayedSequence(50, true)) + using (BeginDelayedSequence(50)) { foregroundFill.ResizeWidthTo(1, remainingTime(), Easing.InOutQuart); foregroundFill.RotateTo(-90, remainingTime(), Easing.InOutQuart); @@ -239,19 +239,19 @@ namespace osu.Game.Screens.Menu purpleCircle.Delay(rotation_delay).RotateTo(-180, remainingTime() - rotation_delay, Easing.InOutQuart); purpleCircle.ResizeTo(circle_size, remainingTime(), Easing.InOutQuart); - using (BeginDelayedSequence(appear_delay, true)) + using (BeginDelayedSequence(appear_delay)) { yellowCircle.MoveToY(-circle_size / 2, remainingTime(), Easing.InOutQuart); yellowCircle.Delay(rotation_delay).RotateTo(-180, remainingTime() - rotation_delay, Easing.InOutQuart); yellowCircle.ResizeTo(circle_size, remainingTime(), Easing.InOutQuart); - using (BeginDelayedSequence(appear_delay, true)) + using (BeginDelayedSequence(appear_delay)) { blueCircle.MoveToX(-circle_size / 2, remainingTime(), Easing.InOutQuart); blueCircle.Delay(rotation_delay).RotateTo(-180, remainingTime() - rotation_delay, Easing.InOutQuart); blueCircle.ResizeTo(circle_size, remainingTime(), Easing.InOutQuart); - using (BeginDelayedSequence(appear_delay, true)) + using (BeginDelayedSequence(appear_delay)) { pinkCircle.MoveToX(circle_size / 2, remainingTime(), Easing.InOutQuart); pinkCircle.Delay(rotation_delay).RotateTo(-180, remainingTime() - rotation_delay, Easing.InOutQuart); diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs index abe6c62461..0ea83fe5e7 100644 --- a/osu.Game/Screens/Menu/IntroTriangles.cs +++ b/osu.Game/Screens/Menu/IntroTriangles.cs @@ -172,27 +172,27 @@ namespace osu.Game.Screens.Menu lazerLogo.Hide(); background.ApplyToBackground(b => b.Hide()); - using (BeginAbsoluteSequence(0, true)) + using (BeginAbsoluteSequence(0)) { - using (BeginDelayedSequence(text_1, true)) + using (BeginDelayedSequence(text_1)) welcomeText.FadeIn().OnComplete(t => t.Text = "wel"); - using (BeginDelayedSequence(text_2, true)) + using (BeginDelayedSequence(text_2)) welcomeText.FadeIn().OnComplete(t => t.Text = "welcome"); - using (BeginDelayedSequence(text_3, true)) + using (BeginDelayedSequence(text_3)) welcomeText.FadeIn().OnComplete(t => t.Text = "welcome to"); - using (BeginDelayedSequence(text_4, true)) + using (BeginDelayedSequence(text_4)) { welcomeText.FadeIn().OnComplete(t => t.Text = "welcome to osu!"); welcomeText.TransformTo(nameof(welcomeText.Spacing), new Vector2(50, 0), 5000); } - using (BeginDelayedSequence(text_glitch, true)) + using (BeginDelayedSequence(text_glitch)) triangles.FadeIn(); - using (BeginDelayedSequence(rulesets_1, true)) + using (BeginDelayedSequence(rulesets_1)) { rulesetsScale.ScaleTo(0.8f, 1000); rulesets.FadeIn().ScaleTo(1).TransformSpacingTo(new Vector2(200, 0)); @@ -200,18 +200,18 @@ namespace osu.Game.Screens.Menu triangles.FadeOut(); } - using (BeginDelayedSequence(rulesets_2, true)) + using (BeginDelayedSequence(rulesets_2)) { rulesets.ScaleTo(2).TransformSpacingTo(new Vector2(30, 0)); } - using (BeginDelayedSequence(rulesets_3, true)) + using (BeginDelayedSequence(rulesets_3)) { rulesets.ScaleTo(4).TransformSpacingTo(new Vector2(10, 0)); rulesetsScale.ScaleTo(1.3f, 1000); } - using (BeginDelayedSequence(logo_1, true)) + using (BeginDelayedSequence(logo_1)) { rulesets.FadeOut(); @@ -223,7 +223,7 @@ namespace osu.Game.Screens.Menu logoContainerSecondary.ScaleTo(scale_start).Then().ScaleTo(scale_start - scale_adjust * 0.25f, logo_scale_duration, Easing.InQuad); } - using (BeginDelayedSequence(logo_2, true)) + using (BeginDelayedSequence(logo_2)) { lazerLogo.FadeOut().OnComplete(_ => { diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs index 521e863683..f74043b045 100644 --- a/osu.Game/Screens/Menu/IntroWelcome.cs +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -143,7 +143,7 @@ namespace osu.Game.Screens.Menu { base.LoadComplete(); - using (BeginDelayedSequence(0, true)) + using (BeginDelayedSequence(0)) { scaleContainer.ScaleTo(0.9f).ScaleTo(1, delay_step_two).OnComplete(_ => Expire()); scaleContainer.FadeInFromZero(1800); diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index 283be913b0..a9376325cd 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -72,8 +72,6 @@ namespace osu.Game.Screens.Menu set => colourAndTriangles.FadeTo(value ? 1 : 0, transition_length, Easing.OutQuint); } - public bool BeatMatching = true; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => logoContainer.ReceivePositionalInputAt(screenSpacePos); public bool Ripple @@ -272,8 +270,6 @@ namespace osu.Game.Screens.Menu { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); - if (!BeatMatching) return; - lastBeatIndex = beatIndex; var beatLength = timingPoint.BeatLength; diff --git a/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs b/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs index c4dc2a2b8f..ae1ca1b967 100644 --- a/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs +++ b/osu.Game/Screens/OnlinePlay/Components/DrawableGameType.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Online.Rooms; @@ -16,7 +17,7 @@ namespace osu.Game.Screens.OnlinePlay.Components { private readonly GameType type; - public string TooltipText => type.Name; + public LocalisableString TooltipText => type.Name; public DrawableGameType(GameType type) { diff --git a/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs index 9aceb39a27..e531ddb0ec 100644 --- a/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Components/ParticipantsList.cs @@ -5,7 +5,6 @@ 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; @@ -91,7 +90,7 @@ namespace osu.Game.Screens.OnlinePlay.Components }); } - private class UserTile : CompositeDrawable, IHasTooltip + private class UserTile : CompositeDrawable { public User User { @@ -99,8 +98,6 @@ namespace osu.Game.Screens.OnlinePlay.Components set => avatar.User = value; } - public string TooltipText => User?.Username ?? string.Empty; - private readonly UpdateableAvatar avatar; public UserTile() @@ -116,7 +113,7 @@ namespace osu.Game.Screens.OnlinePlay.Components RelativeSizeAxes = Axes.Both, Colour = Color4Extensions.FromHex(@"27252d"), }, - avatar = new UpdateableAvatar { RelativeSizeAxes = Axes.Both }, + avatar = new UpdateableAvatar(showUsernameTooltip: true) { RelativeSizeAxes = Axes.Both }, }; } } diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs index 227a772b2d..422576648c 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs @@ -84,10 +84,10 @@ namespace osu.Game.Screens.OnlinePlay.Components private JoinRoomRequest currentJoinRoomRequest; - public virtual void JoinRoom(Room room, Action onSuccess = null, Action onError = null) + public virtual void JoinRoom(Room room, string password = null, Action onSuccess = null, Action onError = null) { currentJoinRoomRequest?.Cancel(); - currentJoinRoomRequest = new JoinRoomRequest(room); + currentJoinRoomRequest = new JoinRoomRequest(room, password); currentJoinRoomRequest.Success += () => { diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs new file mode 100644 index 0000000000..b2e35d7020 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs @@ -0,0 +1,93 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Screens.Ranking.Expanded; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Components +{ + public class StarRatingRangeDisplay : OnlinePlayComposite + { + [Resolved] + private OsuColour colours { get; set; } + + private StarRatingDisplay minDisplay; + private Drawable minBackground; + private StarRatingDisplay maxDisplay; + private Drawable maxBackground; + + public StarRatingRangeDisplay() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 1, + Children = new[] + { + minBackground = new Box + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + }, + maxBackground = new Box + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.5f), + }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + minDisplay = new StarRatingDisplay(default), + maxDisplay = new StarRatingDisplay(default) + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Playlist.BindCollectionChanged(updateRange, true); + } + + private void updateRange(object sender, NotifyCollectionChangedEventArgs e) + { + var orderedDifficulties = Playlist.Select(p => p.Beatmap.Value).OrderBy(b => b.StarDifficulty).ToArray(); + + StarDifficulty minDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[0].StarDifficulty : 0, 0); + StarDifficulty maxDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[^1].StarDifficulty : 0, 0); + + minDisplay.Current.Value = minDifficulty; + maxDisplay.Current.Value = maxDifficulty; + + minBackground.Colour = colours.ForDifficultyRating(minDifficulty.DifficultyRating, true); + maxBackground.Colour = colours.ForDifficultyRating(maxDifficulty.DifficultyRating, true); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index 38a9ace619..a3a61ccc36 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -202,7 +202,6 @@ namespace osu.Game.Screens.OnlinePlay Child = modDisplay = new ModDisplay { Scale = new Vector2(0.4f), - DisplayUnrankedText = false, ExpansionMode = ExpansionMode.AlwaysExpanded } } diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs index a3cc383b67..834e82fcfd 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -31,7 +31,6 @@ namespace osu.Game.Screens.OnlinePlay { Anchor = Anchor.Centre, Origin = Anchor.Centre, - DisplayUnrankedText = false, Scale = new Vector2(0.8f), ExpansionMode = ExpansionMode.AlwaysContracted, }); diff --git a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs index 66262e7dc4..d5abaaab4e 100644 --- a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs @@ -25,14 +25,13 @@ namespace osu.Game.Screens.OnlinePlay public new Func IsValidMod { get => base.IsValidMod; - set => base.IsValidMod = m => m.HasImplementation && !(m is ModAutoplay) && value(m); + set => base.IsValidMod = m => m.HasImplementation && m.UserPlayable && value(m); } public FreeModSelectOverlay() { IsValidMod = m => true; - MultiplierSection.Alpha = 0; DeselectAllButton.Alpha = 0; Drawable selectAllButton; diff --git a/osu.Game/Screens/OnlinePlay/IRoomManager.cs b/osu.Game/Screens/OnlinePlay/IRoomManager.cs index 8ff02536f3..34c1393ff1 100644 --- a/osu.Game/Screens/OnlinePlay/IRoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/IRoomManager.cs @@ -6,6 +6,8 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Online.Rooms; +#nullable enable + namespace osu.Game.Screens.OnlinePlay { [Cached(typeof(IRoomManager))] @@ -32,15 +34,16 @@ namespace osu.Game.Screens.OnlinePlay /// The to create. /// An action to be invoked if the creation succeeds. /// An action to be invoked if an error occurred. - void CreateRoom(Room room, Action onSuccess = null, Action onError = null); + void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null); /// /// Joins a . /// /// The to join. must be populated. + /// An optional password to use for the join operation. /// /// - void JoinRoom(Room room, Action onSuccess = null, Action onError = null); + void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null); /// /// Parts the currently-joined . diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index 35782c6104..236408851f 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -6,19 +6,25 @@ using System.Collections.Generic; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; +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 osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osuTK; @@ -26,7 +32,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class DrawableRoom : OsuClickableContainer, IStateful, IFilterable, IHasContextMenu + public class DrawableRoom : OsuClickableContainer, IStateful, IFilterable, IHasContextMenu, IHasPopover, IKeyBindingHandler { public const float SELECTION_BORDER_WIDTH = 4; private const float corner_radius = 5; @@ -46,6 +52,12 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components [Resolved] private BeatmapManager beatmaps { get; set; } + [Resolved(canBeNull: true)] + private Bindable selectedRoom { get; set; } + + [Resolved(canBeNull: true)] + private LoungeSubScreen lounge { get; set; } + public readonly Room Room; private SelectionState state; @@ -91,6 +103,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public bool FilteringActive { get; set; } + private PasswordProtectedIcon passwordIcon; + + private readonly Bindable hasPassword = new Bindable(); + public DrawableRoom(Room room) { Room = room; @@ -200,6 +216,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components }, }, }, + passwordIcon = new PasswordProtectedIcon { Alpha = 0 } }, }, }, @@ -222,10 +239,69 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components this.FadeInFromZero(transition_duration); else Alpha = 0; + + hasPassword.BindTo(Room.HasPassword); + hasPassword.BindValueChanged(v => passwordIcon.Alpha = v.NewValue ? 1 : 0, true); + } + + public Popover GetPopover() => new PasswordEntryPopover(Room) { JoinRequested = lounge.Join }; + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("Create copy", MenuItemType.Standard, () => + { + parentScreen?.OpenNewRoom(Room.DeepClone()); + }) + }; + + public bool OnPressed(GlobalAction action) + { + if (selectedRoom.Value != Room) + return false; + + switch (action) + { + case GlobalAction.Select: + Click(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { } protected override bool ShouldBeConsideredForInput(Drawable child) => state == SelectionState.Selected; + protected override bool OnMouseDown(MouseDownEvent e) + { + if (selectedRoom.Value != Room) + return true; + + return base.OnMouseDown(e); + } + + protected override bool OnClick(ClickEvent e) + { + if (Room != selectedRoom.Value) + { + selectedRoom.Value = Room; + return true; + } + + if (Room.HasPassword.Value) + { + this.ShowPopover(); + return true; + } + + lounge?.Join(Room, null); + + return base.OnClick(e); + } + private class RoomName : OsuSpriteText { [Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))] @@ -238,12 +314,83 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - public MenuItem[] ContextMenuItems => new MenuItem[] + public class PasswordProtectedIcon : CompositeDrawable { - new OsuMenuItem("Create copy", MenuItemType.Standard, () => + [BackgroundDependencyLoader] + private void load(OsuColour colours) { - parentScreen?.OpenNewRoom(Room.CreateCopy()); - }) - }; + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + + Size = new Vector2(32); + + InternalChildren = new Drawable[] + { + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopCentre, + Colour = colours.Gray5, + Rotation = 45, + RelativeSizeAxes = Axes.Both, + Width = 2, + }, + new SpriteIcon + { + Icon = FontAwesome.Solid.Lock, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding(6), + Size = new Vector2(14), + } + }; + } + } + + public class PasswordEntryPopover : OsuPopover + { + private readonly Room room; + + public Action JoinRequested; + + public PasswordEntryPopover(Room room) + { + this.room = room; + } + + private OsuPasswordTextBox passwordTextbox; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Child = new FillFlowContainer + { + Margin = new MarginPadding(10), + Spacing = new Vector2(5), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + passwordTextbox = new OsuPasswordTextBox + { + Width = 200, + }, + new TriangleButton + { + Width = 80, + Text = "Join Room", + Action = () => JoinRequested?.Invoke(room, passwordTextbox.Text) + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Schedule(() => GetContainingInputManager().ChangeFocus(passwordTextbox)); + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs index 8e59dc8579..07e412ee75 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs @@ -24,8 +24,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public class RoomsContainer : CompositeDrawable, IKeyBindingHandler { - public Action JoinRequested; - private readonly IBindableList rooms = new BindableList(); private readonly FillFlowContainer roomFlow; @@ -93,7 +91,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { bool matchingFilter = true; - matchingFilter &= r.Room.Playlist.Count == 0 || r.Room.Playlist.Any(i => i.Ruleset.Value.Equals(criteria.Ruleset)); + matchingFilter &= r.Room.Playlist.Count == 0 || criteria.Ruleset == null || 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)); @@ -121,19 +119,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { foreach (var room in rooms) { - roomFlow.Add(new DrawableRoom(room) - { - Action = () => - { - if (room == selectedRoom.Value) - { - joinSelected(); - return; - } - - selectRoom(room); - } - }); + roomFlow.Add(new DrawableRoom(room)); } Filter(filter?.Value); @@ -150,7 +136,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components roomFlow.Remove(toRemove); - selectRoom(null); + selectedRoom.Value = null; } } @@ -160,18 +146,9 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components roomFlow.SetLayoutPosition(room, room.Room.Position.Value); } - private void selectRoom(Room room) => selectedRoom.Value = room; - - private void joinSelected() - { - if (selectedRoom.Value == null) return; - - JoinRequested?.Invoke(selectedRoom.Value); - } - protected override bool OnClick(ClickEvent e) { - selectRoom(null); + selectedRoom.Value = null; return base.OnClick(e); } @@ -181,10 +158,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { switch (action) { - case GlobalAction.Select: - joinSelected(); - return true; - case GlobalAction.SelectNext: beginRepeatSelection(() => selectNext(1), action); return true; @@ -253,7 +226,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components // we already have a valid selection only change selection if we still have a room to switch to. if (room != null) - selectRoom(room); + selectedRoom.Value = room; } #endregion diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index f24577a8a5..f43109c4fa 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -6,6 +6,7 @@ using System.Linq; 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.Input.Events; @@ -46,10 +47,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge [CanBeNull] private IDisposable joiningRoomOperation { get; set; } + private RoomsContainer roomsContainer; + [BackgroundDependencyLoader] private void load() { - RoomsContainer roomsContainer; OsuScrollContainer scrollContainer; InternalChildren = new Drawable[] @@ -70,7 +72,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge RelativeSizeAxes = Axes.Both, ScrollbarOverlapsContent = false, Padding = new MarginPadding(10), - Child = roomsContainer = new RoomsContainer { JoinRequested = joinRequested } + Child = roomsContainer = new RoomsContainer() }, loadingLayer = new LoadingLayer(true), } @@ -150,31 +152,39 @@ namespace osu.Game.Screens.OnlinePlay.Lounge onReturning(); } - private void onReturning() - { - filter.HoldFocus = true; - } - public override bool OnExiting(IScreen next) { - filter.HoldFocus = false; + onLeaving(); return base.OnExiting(next); } public override void OnSuspending(IScreen next) { + onLeaving(); base.OnSuspending(next); - filter.HoldFocus = false; } - private void joinRequested(Room room) + private void onReturning() + { + filter.HoldFocus = true; + } + + private void onLeaving() + { + filter.HoldFocus = false; + + // ensure any password prompt is dismissed. + this.HidePopover(); + } + + public void Join(Room room, string password) { if (joiningRoomOperation != null) return; joiningRoomOperation = ongoingOperationTracker?.BeginOperation(); - RoomManager?.JoinRoom(room, r => + RoomManager?.JoinRoom(room, password, r => { Open(room); joiningRoomOperation?.Dispose(); diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs index 5699da740c..61bb39d0c5 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs @@ -25,8 +25,12 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components private void load() { Masking = true; + + Add(Settings = CreateSettings()); } + protected abstract OnlinePlayComposite CreateSettings(); + protected override void PopIn() { Settings.MoveToY(0, TRANSITION_DURATION, Easing.OutQuint); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index fe9979b161..338d2c9e84 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -27,16 +27,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public class MultiplayerMatchSettingsOverlay : MatchSettingsOverlay { - [BackgroundDependencyLoader] - private void load() - { - Child = Settings = new MatchSettings + protected override OnlinePlayComposite CreateSettings() + => new MatchSettings { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Y, SettingsApplied = Hide }; - } protected class MatchSettings : OnlinePlayComposite { @@ -47,6 +44,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match public OsuTextBox NameField, MaxParticipantsField; public RoomAvailabilityPicker AvailabilityPicker; public GameTypePicker TypePicker; + public OsuTextBox PasswordTextBox; public TriangleButton ApplyButton; public OsuSpriteText ErrorText; @@ -93,7 +91,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match RelativeSizeAxes = Axes.Both, RowDimensions = new[] { - new Dimension(GridSizeMode.Distributed), + new Dimension(), new Dimension(GridSizeMode.AutoSize), }, Content = new[] @@ -193,12 +191,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }, new Section("Password (optional)") { - Alpha = disabled_alpha, - Child = new SettingsPasswordTextBox + Child = PasswordTextBox = new SettingsPasswordTextBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, - ReadOnly = true, }, }, } @@ -275,6 +271,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Type.BindValueChanged(type => TypePicker.Current.Value = type.NewValue, true); MaxParticipants.BindValueChanged(count => MaxParticipantsField.Text = count.NewValue?.ToString(), true); RoomID.BindValueChanged(roomId => initialBeatmapControl.Alpha = roomId.NewValue == null ? 1 : 0, true); + Password.BindValueChanged(password => PasswordTextBox.Text = password.NewValue ?? string.Empty, true); operationInProgress.BindTo(ongoingOperationTracker.InProgress); operationInProgress.BindValueChanged(v => @@ -307,7 +304,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match // Otherwise, update the room directly in preparation for it to be submitted to the API on match creation. if (client.Room != null) { - client.ChangeSettings(name: NameField.Text).ContinueWith(t => Schedule(() => + client.ChangeSettings(name: NameField.Text, password: PasswordTextBox.Text).ContinueWith(t => Schedule(() => { if (t.IsCompletedSuccessfully) onSuccess(currentRoom.Value); @@ -320,6 +317,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match currentRoom.Value.Name.Value = NameField.Text; currentRoom.Value.Availability.Value = AvailabilityPicker.Current.Value; currentRoom.Value.Type.Value = TypePicker.Current.Value; + currentRoom.Value.Password.Value = PasswordTextBox.Current.Value; if (int.TryParse(MaxParticipantsField.Text, out int max)) currentRoom.Value.MaxParticipants.Value = max; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index f2dd9a6f25..baf9570209 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -72,25 +71,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { var localUser = Client.LocalUser; - if (localUser == null) - return; + int newCountReady = Room?.Users.Count(u => u.State == MultiplayerUserState.Ready) ?? 0; + int newCountTotal = Room?.Users.Count(u => u.State != MultiplayerUserState.Spectating) ?? 0; - Debug.Assert(Room != null); - - int newCountReady = Room.Users.Count(u => u.State == MultiplayerUserState.Ready); - int newCountTotal = Room.Users.Count(u => u.State != MultiplayerUserState.Spectating); - - string countText = $"({newCountReady} / {newCountTotal} ready)"; - - switch (localUser.State) + switch (localUser?.State) { - case MultiplayerUserState.Idle: + default: button.Text = "Ready"; updateButtonColour(true); break; case MultiplayerUserState.Spectating: case MultiplayerUserState.Ready: + string countText = $"({newCountReady} / {newCountTotal} ready)"; + if (Room?.Host?.Equals(localUser) == true) { button.Text = $"Start match {countText}"; @@ -108,7 +102,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match bool enableButton = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value; // When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready. - if (localUser.State == MultiplayerUserState.Spectating) + if (localUser?.State == MultiplayerUserState.Spectating) enableButton &= Room?.Host?.Equals(localUser) == true && newCountReady > 0; button.Enabled.Value = enableButton; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs index 04150902bc..db99c6a5d5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -57,14 +56,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void updateState() { - var localUser = Client.LocalUser; - - if (localUser == null) - return; - - Debug.Assert(Room != null); - - switch (localUser.State) + switch (Client.LocalUser?.State) { default: button.Text = "Spectate"; @@ -81,7 +73,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match break; } - button.Enabled.Value = Client.Room?.State != MultiplayerRoomState.Closed && !operationInProgress.Value; + button.Enabled.Value = Client.Room != null + && Client.Room.State != MultiplayerRoomState.Closed + && !operationInProgress.Value; } private class ButtonWithTrianglesExposed : TriangleButton diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 62ef70ed68..561fa220c8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -48,6 +48,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } + [Resolved] + private Bindable currentRoom { get; set; } + private MultiplayerMatchSettingsOverlay settingsOverlay; private readonly IBindable isConnected = new Bindable(); @@ -185,7 +188,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - DisplayUnrankedText = false, Current = UserMods, Scale = new Vector2(0.8f), }, @@ -274,6 +276,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!connected.NewValue) Schedule(this.Exit); }, true); + + currentRoom.BindValueChanged(room => + { + if (room.NewValue == null) + { + // the room has gone away. + // this could mean something happened during the join process, or an external connection issue occurred. + // one specific scenario is where the underlying room is created, but the signalr server returns an error during the join process. this triggers a PartRoom operation (see https://github.com/ppy/osu/blob/7654df94f6f37b8382be7dfcb4f674e03bd35427/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs#L97) + Schedule(this.Exit); + } + }, true); } protected override void UpdateMods() @@ -306,18 +319,36 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return true; } + return base.OnBackButton(); + } + + public override bool OnExiting(IScreen next) + { + // the room may not be left immediately after a disconnection due to async flow, + // so checking the IsConnected status is also required. + if (client.Room == null || !client.IsConnected.Value) + { + // room has not been created yet; exit immediately. + return base.OnExiting(next); + } + if (!exitConfirmed && dialogOverlay != null) { - dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => + if (dialogOverlay.CurrentDialog is ConfirmDialog confirmDialog) + confirmDialog.PerformOkAction(); + else { - exitConfirmed = true; - this.Exit(); - })); + dialogOverlay.Push(new ConfirmDialog("Are you sure you want to leave this multiplayer match?", () => + { + exitConfirmed = true; + this.Exit(); + })); + } return true; } - return base.OnBackButton(); + return base.OnExiting(next); } private ModSettingChangeTracker modSettingChangeTracker; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index 1bbe49a705..043cce4630 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -125,9 +125,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { const float padding = 44; // enough margin to avoid the hit error display. - leaderboard.Position = new Vector2( - padding, - padding + HUDOverlay.TopScoringElementsHeight); + leaderboard.Position = new Vector2(padding, padding + HUDOverlay.TopScoringElementsHeight); } private void onMatchStarted() => Scheduler.Add(() => diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs index 8526196902..cbba4babe5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs @@ -38,9 +38,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } public override void CreateRoom(Room room, Action onSuccess = null, Action onError = null) - => base.CreateRoom(room, r => joinMultiplayerRoom(r, onSuccess, onError), onError); + => base.CreateRoom(room, r => joinMultiplayerRoom(r, r.Password.Value, onSuccess, onError), onError); - public override void JoinRoom(Room room, Action onSuccess = null, Action onError = null) + public override void JoinRoom(Room room, string password = null, Action onSuccess = null, Action onError = null) { if (!multiplayerClient.IsConnected.Value) { @@ -56,7 +56,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - base.JoinRoom(room, r => joinMultiplayerRoom(r, onSuccess, onError), onError); + base.JoinRoom(room, password, r => joinMultiplayerRoom(r, password, onSuccess, onError), onError); } public override void PartRoom() @@ -79,11 +79,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer }); } - private void joinMultiplayerRoom(Room room, Action onSuccess = null, Action onError = null) + private void joinMultiplayerRoom(Room room, string password, Action onSuccess = null, Action onError = null) { Debug.Assert(room.RoomID.Value != null); - multiplayerClient.JoinRoom(room).ContinueWith(t => + multiplayerClient.JoinRoom(room, password).ContinueWith(t => { if (t.IsCompletedSuccessfully) Schedule(() => onSuccess?.Invoke(room)); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 5bef934e6a..f4a334e9d3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -139,7 +139,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { Scale = new Vector2(0.5f), ExpansionMode = ExpansionMode.AlwaysContracted, - DisplayUnrankedText = false, } }, userStateDisplay = new StateDisplay diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs index 9e1a020eca..20d12d62a3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs @@ -34,7 +34,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public void Stop() => IsRunning = false; - public bool Seek(double position) => true; + public bool Seek(double position) + { + CurrentTime = position; + return true; + } public void ResetSpeedAdjustments() { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs index efc12eaaa5..cf0dfbb585 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs @@ -1,8 +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; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Timing; @@ -28,16 +31,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public const double MAXIMUM_START_DELAY = 15000; + public event Action ReadyToStart; + /// /// The master clock which is used to control the timing of all player clocks clocks. /// public IAdjustableClock MasterClock { get; } + public IBindable MasterState => masterState; + /// /// The player clocks. /// private readonly List playerClocks = new List(); + private readonly Bindable masterState = new Bindable(); + private bool hasStarted; private double? firstStartAttemptTime; @@ -46,7 +55,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate MasterClock = master; } - public void AddPlayerClock(ISpectatorPlayerClock clock) => playerClocks.Add(clock); + public void AddPlayerClock(ISpectatorPlayerClock clock) + { + Debug.Assert(!playerClocks.Contains(clock)); + playerClocks.Add(clock); + } public void RemovePlayerClock(ISpectatorPlayerClock clock) => playerClocks.Remove(clock); @@ -62,8 +75,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate return; } - updateCatchup(); - updateMasterClock(); + updatePlayerCatchup(); + updateMasterState(); } /// @@ -81,14 +94,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate int readyCount = playerClocks.Count(s => !s.WaitingOnFrames.Value); if (readyCount == playerClocks.Count) - return hasStarted = true; + return performStart(); if (readyCount > 0) { firstStartAttemptTime ??= Time.Current; if (Time.Current - firstStartAttemptTime > MAXIMUM_START_DELAY) - return hasStarted = true; + return performStart(); + } + + bool performStart() + { + ReadyToStart?.Invoke(); + return hasStarted = true; } return false; @@ -97,7 +116,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// Updates the catchup states of all player clocks clocks. /// - private void updateCatchup() + private void updatePlayerCatchup() { for (int i = 0; i < playerClocks.Count; i++) { @@ -111,6 +130,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate // This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the player clock. if (timeDelta < -SYNC_TARGET) { + // Importantly, set the clock to a non-catchup state. if this isn't done, updateMasterState may incorrectly pause the master clock + // when it is required to be running (ie. if all players are ahead of the master). + clock.IsCatchingUp = false; clock.Stop(); continue; } @@ -135,19 +157,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate } /// - /// Updates the master clock's running state. + /// Updates the state of the master clock. /// - private void updateMasterClock() + private void updateMasterState() { bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp); - - if (MasterClock.IsRunning != anyInSync) - { - if (anyInSync) - MasterClock.Start(); - else - MasterClock.Stop(); - } + masterState.Value = anyInSync ? MasterClockState.Synchronised : MasterClockState.TooFarAhead; } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs index bd698108f6..3c644ccb78 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Framework.Bindables; using osu.Framework.Timing; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate @@ -10,11 +12,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public interface ISyncManager { + /// + /// An event which is invoked when gameplay is ready to start. + /// + event Action ReadyToStart; + /// /// The master clock which player clocks should synchronise to. /// IAdjustableClock MasterClock { get; } + /// + /// An event which is invoked when the state of is changed. + /// + IBindable MasterState { get; } + /// /// Adds an to manage. /// diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs new file mode 100644 index 0000000000..8982d1669d --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + public enum MasterClockState + { + /// + /// The master clock is synchronised with at least one player clock. + /// + Synchronised, + + /// + /// The master clock is too far ahead of any player clock and needs to slow down. + /// + TooFarAhead + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs index ab3ead68b5..55c4270c70 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorLeaderboard.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public void AddClock(int userId, IClock clock) { if (!UserScores.TryGetValue(userId, out var data)) - return; + throw new ArgumentException(@"Provided user is not tracked by this leaderboard", nameof(userId)); ((SpectatingTrackedUserData)data).Clock = clock; } @@ -27,7 +27,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public void RemoveClock(int userId) { if (!UserScores.TryGetValue(userId, out var data)) - return; + throw new ArgumentException(@"Provided user is not tracked by this leaderboard", nameof(userId)); ((SpectatingTrackedUserData)data).Clock = null; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs index 0fe9e01d9d..2c157b0564 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs @@ -17,7 +17,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public class MultiSpectatorPlayer : SpectatorPlayer { private readonly Bindable waitingOnFrames = new Bindable(true); - private readonly Score score; private readonly ISpectatorPlayerClock spectatorPlayerClock; /// @@ -28,7 +27,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public MultiSpectatorPlayer([NotNull] Score score, [NotNull] ISpectatorPlayerClock spectatorPlayerClock) : base(score) { - this.score = score; this.spectatorPlayerClock = spectatorPlayerClock; } @@ -43,7 +41,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate base.UpdateAfterChildren(); // This is required because the frame stable clock is set to WaitingOnFrames = false for one frame. - waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || score.Replay.Frames.Count == 0; + waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || Score.Replay.Frames.Count == 0; } protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 277aa5d772..2a2759e0dd 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -5,6 +5,7 @@ using System; 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.Game.Online.Multiplayer; @@ -22,6 +23,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate // Isolates beatmap/ruleset to this screen. public override bool DisallowExternalBeatmapRulesetChanges => true; + // We are managing our own adjustments. For now, this happens inside the Player instances themselves. + public override bool AllowRateAdjustments => false; + /// /// Whether all spectating players have finished loading. /// @@ -39,6 +43,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate private PlayerGrid grid; private MultiSpectatorLeaderboard leaderboard; private PlayerArea currentAudioSource; + private bool canStartMasterClock; /// /// Creates a new . @@ -97,15 +102,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate Expanded = { Value = true }, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - }, leaderboardContainer.Add); + }, l => + { + foreach (var instance in instances) + leaderboard.AddClock(instance.UserId, instance.GameplayClock); + + leaderboardContainer.Add(leaderboard); + }); } protected override void LoadComplete() { base.LoadComplete(); - masterClockContainer.Stop(); masterClockContainer.Reset(); + masterClockContainer.Stop(); + + syncManager.ReadyToStart += onReadyToStart; + syncManager.MasterState.BindValueChanged(onMasterStateChanged, true); } protected override void Update() @@ -126,19 +140,45 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate private bool isCandidateAudioSource([CanBeNull] ISpectatorPlayerClock clock) => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames.Value; + private void onReadyToStart() + { + // Seek the master clock to the gameplay time. + // This is chosen as the first available frame in the players' replays, which matches the seek by each individual SpectatorPlayer. + var startTime = instances.Where(i => i.Score != null) + .SelectMany(i => i.Score.Replay.Frames) + .Select(f => f.Time) + .DefaultIfEmpty(0) + .Min(); + + masterClockContainer.Seek(startTime); + masterClockContainer.Start(); + + // Although the clock has been started, this flag is set to allow for later synchronisation state changes to also be able to start it. + canStartMasterClock = true; + } + + private void onMasterStateChanged(ValueChangedEvent state) + { + switch (state.NewValue) + { + case MasterClockState.Synchronised: + if (canStartMasterClock) + masterClockContainer.Start(); + + break; + + case MasterClockState.TooFarAhead: + masterClockContainer.Stop(); + break; + } + } + protected override void OnUserStateChanged(int userId, SpectatorState spectatorState) { } protected override void StartGameplay(int userId, GameplayState gameplayState) - { - var instance = instances.Single(i => i.UserId == userId); - - instance.LoadScore(gameplayState.Score); - - syncManager.AddPlayerClock(instance.GameplayClock); - leaderboard.AddClock(instance.UserId, instance.GameplayClock); - } + => instances.Single(i => i.UserId == userId).LoadScore(gameplayState.Score); protected override void EndGameplay(int userId) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs index fe79e5db72..95ccc08608 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs @@ -27,7 +27,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// Whether a is loaded in the area. /// - public bool PlayerLoaded => stack?.CurrentScreen is Player; + public bool PlayerLoaded => (stack?.CurrentScreen as Player)?.IsLoaded == true; /// /// The user id this corresponds to. diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs index eb0b23f13f..0b28bc1a7e 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs @@ -56,6 +56,9 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(typeof(Room))] protected Bindable Availability { get; private set; } + [Resolved(typeof(Room), nameof(Room.Password))] + public Bindable Password { get; private set; } + [Resolved(typeof(Room))] protected Bindable Duration { get; private set; } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index 90e499c67f..25b02e5084 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.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 osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -30,17 +31,19 @@ namespace osu.Game.Screens.OnlinePlay [Cached] public abstract class OnlinePlayScreen : OsuScreen, IHasSubScreenStack { - public override bool CursorVisible => (screenStack.CurrentScreen as IOnlinePlaySubScreen)?.CursorVisible ?? true; + public override bool CursorVisible => (screenStack?.CurrentScreen as IOnlinePlaySubScreen)?.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; + private MultiplayerWaveContainer waves; - private readonly OsuButton createButton; - private readonly LoungeSubScreen loungeSubScreen; - private readonly ScreenStack screenStack; + private OsuButton createButton; + + private ScreenStack screenStack; + + private LoungeSubScreen loungeSubScreen; private readonly IBindable isIdle = new BindableBool(); @@ -54,7 +57,7 @@ namespace osu.Game.Screens.OnlinePlay private readonly Bindable currentFilter = new Bindable(new FilterCriteria()); [Cached] - private OngoingOperationTracker ongoingOperationTracker { get; set; } + private readonly OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker(); [Resolved(CanBeNull = true)] private MusicController music { get; set; } @@ -65,11 +68,14 @@ namespace osu.Game.Screens.OnlinePlay [Resolved] protected IAPIProvider API { get; private set; } + [Resolved(CanBeNull = true)] + private IdleTracker idleTracker { get; set; } + [Resolved(CanBeNull = true)] private OsuLogo logo { get; set; } - private readonly Drawable header; - private readonly Drawable headerBackground; + private Drawable header; + private Drawable headerBackground; protected OnlinePlayScreen() { @@ -78,6 +84,14 @@ namespace osu.Game.Screens.OnlinePlay RelativeSizeAxes = Axes.Both; Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; + RoomManager = CreateRoomManager(); + } + + private readonly IBindable apiState = new Bindable(); + + [BackgroundDependencyLoader] + private void load() + { var backgroundColour = Color4Extensions.FromHex(@"3e3a44"); InternalChild = waves = new MultiplayerWaveContainer @@ -144,27 +158,14 @@ namespace osu.Game.Screens.OnlinePlay }; button.Action = () => OpenNewRoom(); }), - RoomManager = CreateRoomManager(), - ongoingOperationTracker = new OngoingOperationTracker() + RoomManager, + ongoingOperationTracker, } }; - screenStack.ScreenPushed += screenPushed; - screenStack.ScreenExited += screenExited; - - screenStack.Push(loungeSubScreen = CreateLounge()); - } - - private readonly IBindable apiState = new Bindable(); - - [BackgroundDependencyLoader(true)] - private void load(IdleTracker idleTracker) - { - apiState.BindTo(API.State); - apiState.BindValueChanged(onlineStateChanged, true); - - if (idleTracker != null) - isIdle.BindTo(idleTracker.IsIdle); + // a lot of the functionality in this class depends on loungeSubScreen being in a ready to go state. + // as such, we intentionally load this inline so it is ready alongside this screen. + LoadComponent(loungeSubScreen = CreateLounge()); } private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => @@ -179,7 +180,20 @@ namespace osu.Game.Screens.OnlinePlay protected override void LoadComplete() { base.LoadComplete(); - isIdle.BindValueChanged(idle => UpdatePollingRate(idle.NewValue), true); + + screenStack.ScreenPushed += screenPushed; + screenStack.ScreenExited += screenExited; + + screenStack.Push(loungeSubScreen); + + apiState.BindTo(API.State); + apiState.BindValueChanged(onlineStateChanged, true); + + if (idleTracker != null) + { + isIdle.BindTo(idleTracker.IsIdle); + isIdle.BindValueChanged(idle => UpdatePollingRate(idle.NewValue), true); + } } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -222,7 +236,9 @@ namespace osu.Game.Screens.OnlinePlay this.FadeIn(250); this.ScaleTo(1, 250, Easing.OutSine); - screenStack.CurrentScreen?.OnResuming(last); + Debug.Assert(screenStack.CurrentScreen != null); + screenStack.CurrentScreen.OnResuming(last); + base.OnResuming(last); UpdatePollingRate(isIdle.Value); @@ -233,27 +249,34 @@ namespace osu.Game.Screens.OnlinePlay this.ScaleTo(1.1f, 250, Easing.InSine); this.FadeOut(250); - screenStack.CurrentScreen?.OnSuspending(next); + Debug.Assert(screenStack.CurrentScreen != null); + screenStack.CurrentScreen.OnSuspending(next); UpdatePollingRate(isIdle.Value); } public override bool OnExiting(IScreen next) { + var subScreen = screenStack.CurrentScreen as Drawable; + if (subScreen?.IsLoaded == true && screenStack.CurrentScreen.OnExiting(next)) + return true; + RoomManager.PartRoom(); waves.Hide(); this.Delay(WaveContainer.DISAPPEAR_DURATION).FadeOut(); - screenStack.CurrentScreen?.OnExiting(next); base.OnExiting(next); return false; } public override bool OnBackButton() { - if ((screenStack.CurrentScreen as IOnlinePlaySubScreen)?.OnBackButton() == true) + if (!(screenStack.CurrentScreen is IOnlinePlaySubScreen onlineSubScreen)) + return false; + + if (((Drawable)onlineSubScreen).IsLoaded && onlineSubScreen.AllowBackButton && onlineSubScreen.OnBackButton()) return true; if (screenStack.CurrentScreen != null && !(screenStack.CurrentScreen is LoungeSubScreen)) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 3e7e557aad..be28de5c43 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -72,8 +72,8 @@ namespace osu.Game.Screens.OnlinePlay // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. // Similarly, freeMods is currently empty but should only contain the allowed mods. - Mods.Value = selectedItem?.Value?.RequiredMods.Select(m => m.CreateCopy()).ToArray() ?? Array.Empty(); - FreeMods.Value = selectedItem?.Value?.AllowedMods.Select(m => m.CreateCopy()).ToArray() ?? Array.Empty(); + Mods.Value = selectedItem?.Value?.RequiredMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); + FreeMods.Value = selectedItem?.Value?.AllowedMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); Mods.BindValueChanged(onModsChanged); Ruleset.BindValueChanged(onRulesetChanged); @@ -108,8 +108,8 @@ namespace osu.Game.Screens.OnlinePlay } }; - item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy())); - item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.CreateCopy())); + item.RequiredMods.AddRange(Mods.Value.Select(m => m.DeepClone())); + item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.DeepClone())); SelectItem(item); return true; @@ -161,7 +161,7 @@ namespace osu.Game.Screens.OnlinePlay /// /// The to check. /// Whether is a valid mod for online play. - protected virtual bool IsValidMod(Mod mod) => mod.HasImplementation && !ModUtils.FlattenMod(mod).Any(m => m is ModAutoplay); + protected virtual bool IsValidMod(Mod mod) => mod.HasImplementation && ModUtils.FlattenMod(mod).All(m => m.UserPlayable); /// /// Checks whether a given is valid for per-player free-mod selection. diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs index 5062a296a8..88ac5ef6e5 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs @@ -26,16 +26,13 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public Action EditPlaylist; - [BackgroundDependencyLoader] - private void load() - { - Child = Settings = new MatchSettings + protected override OnlinePlayComposite CreateSettings() + => new MatchSettings { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Y, EditPlaylist = () => EditPlaylist?.Invoke() }; - } protected class MatchSettings : OnlinePlayComposite { @@ -75,7 +72,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists RelativeSizeAxes = Axes.Both, RowDimensions = new[] { - new Dimension(GridSizeMode.Distributed), + new Dimension(), new Dimension(GridSizeMode.AutoSize), }, Content = new[] diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 260d4961ff..567ea6b988 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Screens; @@ -54,11 +55,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists return new PlaylistsResultsScreen(score, RoomId.Value.Value, PlaylistItem, true); } - protected override Score CreateScore() + protected override async Task PrepareScoreForResultsAsync(Score score) { - var score = base.CreateScore(); - score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.GetStandardisedScore()); - return score; + await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); + + Score.ScoreInfo.TotalScore = (int)Math.Round(ScoreProcessor.GetStandardisedScore()); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 26ee21a2c3..092394446b 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -175,7 +175,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - DisplayUnrankedText = false, Current = UserMods, Scale = new Vector2(0.8f), }, diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 21335fc90c..076fa77336 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -55,10 +55,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists item.Ruleset.Value = Ruleset.Value; item.RequiredMods.Clear(); - item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy())); + item.RequiredMods.AddRange(Mods.Value.Select(m => m.DeepClone())); item.AllowedMods.Clear(); - item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.CreateCopy())); + item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.DeepClone())); } } } diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index aeb51813e4..c3b2612e79 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -242,7 +242,6 @@ namespace osu.Game.Screens logo.Anchor = Anchor.TopLeft; logo.Origin = Anchor.Centre; logo.RelativePositionAxes = Axes.Both; - logo.BeatMatching = true; logo.Triangles = true; logo.Ripple = true; } diff --git a/osu.Game/Screens/Play/BreakOverlay.cs b/osu.Game/Screens/Play/BreakOverlay.cs index 36f825b8f6..1665ee83ae 100644 --- a/osu.Game/Screens/Play/BreakOverlay.cs +++ b/osu.Game/Screens/Play/BreakOverlay.cs @@ -126,7 +126,7 @@ namespace osu.Game.Screens.Play if (!b.HasEffect) continue; - using (BeginAbsoluteSequence(b.StartTime, true)) + using (BeginAbsoluteSequence(b.StartTime)) { fadeContainer.FadeIn(BREAK_FADE_DURATION); breakArrows.Show(BREAK_FADE_DURATION); @@ -143,7 +143,7 @@ namespace osu.Game.Screens.Play remainingTimeCounter.CountTo(b.Duration).CountTo(0, b.Duration); - using (BeginDelayedSequence(b.Duration - BREAK_FADE_DURATION, true)) + using (BeginDelayedSequence(b.Duration - BREAK_FADE_DURATION)) { fadeContainer.FadeOut(BREAK_FADE_DURATION); breakArrows.Hide(BREAK_FADE_DURATION); diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 4a28da0dde..2608c93fa1 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -2,23 +2,24 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; +using Humanizer; +using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osuTK; -using osuTK.Graphics; -using osu.Game.Graphics; -using osu.Framework.Allocation; -using osu.Game.Graphics.UserInterface; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; -using System.Collections.Generic; -using System.Linq; 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.Graphics.UserInterface; using osu.Game.Input.Bindings; -using Humanizer; -using osu.Framework.Graphics.Effects; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Play { @@ -46,13 +47,13 @@ namespace osu.Game.Screens.Play /// /// Action that is invoked when is triggered. /// - protected virtual Action SelectAction => () => InternalButtons.Children.FirstOrDefault(f => f.Selected.Value)?.Click(); + protected virtual Action SelectAction => () => InternalButtons.Selected?.Click(); public abstract string Header { get; } public abstract string Description { get; } - protected ButtonContainer InternalButtons; + protected SelectionCycleFillFlowContainer InternalButtons; public IReadOnlyList Buttons => InternalButtons; private FillFlowContainer retryCounterContainer; @@ -116,7 +117,7 @@ namespace osu.Game.Screens.Play } } }, - InternalButtons = new ButtonContainer + InternalButtons = new SelectionCycleFillFlowContainer { Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, @@ -183,8 +184,6 @@ namespace osu.Game.Screens.Play } }; - button.Selected.ValueChanged += selected => buttonSelectionChanged(button, selected.NewValue); - InternalButtons.Add(button); } @@ -216,14 +215,6 @@ namespace osu.Game.Screens.Play { } - private void buttonSelectionChanged(DialogButton button, bool isSelected) - { - if (!isSelected) - InternalButtons.Deselect(); - else - InternalButtons.Select(button); - } - private void updateRetryCount() { // "You've retried 1,065 times in this session" @@ -255,46 +246,6 @@ namespace osu.Game.Screens.Play }; } - protected class ButtonContainer : FillFlowContainer - { - 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) @@ -302,7 +253,7 @@ namespace osu.Game.Screens.Play protected override bool OnMouseMove(MouseMoveEvent e) { - Selected.Value = true; + State = SelectionState.Selected; return base.OnMouseMove(e); } } diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs index 45ba05e036..324e5d43b5 100644 --- a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs @@ -12,6 +12,8 @@ namespace osu.Game.Screens.Play.HUD [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } + public bool UsesFixedAnchor { get; set; } + [BackgroundDependencyLoader] private void load(OsuColour colours) { diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index c4575c5ad0..718ae24cf1 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -17,6 +17,8 @@ namespace osu.Game.Screens.Play.HUD [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } + public bool UsesFixedAnchor { get; set; } + public DefaultComboCounter() { Current.Value = DisplayedCount = 0; diff --git a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs index ed297f0ffc..4f93868a66 100644 --- a/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs @@ -72,6 +72,8 @@ namespace osu.Game.Screens.Play.HUD } } + public bool UsesFixedAnchor { get; set; } + public DefaultHealthDisplay() { Size = new Vector2(1, 5); diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 16e3642181..63de5c8de5 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -20,6 +20,8 @@ namespace osu.Game.Screens.Play.HUD [Resolved(canBeNull: true)] private HUDOverlay hud { get; set; } + public bool UsesFixedAnchor { get; set; } + [BackgroundDependencyLoader] private void load(OsuColour colours) { diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index 5d0263772d..89f61785e8 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -214,7 +214,10 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters protected override void OnNewJudgement(JudgementResult judgement) { - if (!judgement.IsHit) + if (!judgement.IsHit || judgement.HitObject.HitWindows?.WindowFor(HitResult.Miss) == 0) + return; + + if (!judgement.Type.IsScorable() || judgement.Type.IsBonus()) return; if (judgementsContainer.Count > max_concurrent_judgements) @@ -244,7 +247,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private float getRelativeJudgementPosition(double value) => Math.Clamp((float)((value / maxHitWindow) + 1) / 2, 0, 1); - private class JudgementLine : CompositeDrawable + internal class JudgementLine : CompositeDrawable { private const int judgement_fade_duration = 5000; diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs index e9ccbcdae2..dda2a6da95 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/ColourHitErrorMeter.cs @@ -7,6 +7,7 @@ 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; @@ -24,7 +25,13 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters InternalChild = judgementsFlow = new JudgementFlow(); } - protected override void OnNewJudgement(JudgementResult judgement) => judgementsFlow.Push(GetColourForHitResult(judgement.Type)); + protected override void OnNewJudgement(JudgementResult judgement) + { + if (!judgement.Type.IsScorable() || judgement.Type.IsBonus()) + return; + + judgementsFlow.Push(GetColourForHitResult(judgement.Type)); + } private class JudgementFlow : FillFlowContainer { @@ -53,7 +60,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters } } - private class HitErrorCircle : Container + internal class HitErrorCircle : Container { public bool IsRemoved { get; private set; } diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs index b0f9928b13..788ba5be8c 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs @@ -22,6 +22,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters [Resolved] private OsuColour colours { get; set; } + public bool UsesFixedAnchor { get; set; } + [BackgroundDependencyLoader(true)] private void load(DrawableRuleset drawableRuleset) { @@ -32,15 +34,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { base.LoadComplete(); - processor.NewJudgement += onNewJudgement; - } - - private void onNewJudgement(JudgementResult result) - { - if (result.HitObject.HitWindows?.WindowFor(HitResult.Miss) == 0) - return; - - OnNewJudgement(result); + processor.NewJudgement += OnNewJudgement; } protected abstract void OnNewJudgement(JudgementResult judgement); @@ -49,6 +43,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters { switch (result) { + case HitResult.SmallTickMiss: + case HitResult.LargeTickMiss: case HitResult.Miss: return colours.Red; @@ -61,6 +57,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters case HitResult.Good: return colours.GreenLight; + case HitResult.SmallTickHit: + case HitResult.LargeTickHit: case HitResult.Great: return colours.Blue; @@ -74,7 +72,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters base.Dispose(isDisposing); if (processor != null) - processor.NewJudgement -= onNewJudgement; + processor.NewJudgement -= OnNewJudgement; } } } diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs index 1737634e31..5c5b66d496 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -23,8 +23,6 @@ namespace osu.Game.Screens.Play.HUD private const double pop_out_duration = 150; - private const Easing pop_out_easing = Easing.None; - private const double fade_out_duration = 100; /// @@ -59,6 +57,8 @@ namespace osu.Game.Screens.Play.HUD set => counterContainer.Alpha = value ? 1 : 0; } + public bool UsesFixedAnchor { get; set; } + public LegacyComboCounter() { AutoSizeAxes = Axes.Both; @@ -168,9 +168,9 @@ namespace osu.Game.Screens.Play.HUD 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); + popOutCount.ScaleTo(1, pop_out_duration); + popOutCount.FadeOut(pop_out_duration); + popOutCount.MoveTo(displayedCountSpriteText.Position, pop_out_duration); } private void transformNoPopOut(int newValue) @@ -184,7 +184,7 @@ namespace osu.Game.Screens.Play.HUD { ((IHasText)displayedCountSpriteText).Text = formatCount(newValue); displayedCountSpriteText.ScaleTo(1.1f); - displayedCountSpriteText.ScaleTo(1, pop_out_duration, pop_out_easing); + displayedCountSpriteText.ScaleTo(1, pop_out_duration); } private void scheduledPopOutSmall(uint id) @@ -259,7 +259,7 @@ namespace osu.Game.Screens.Play.HUD } private void transformRoll(int currentValue, int newValue) => - this.TransformTo(nameof(DisplayedCount), newValue, getProportionalDuration(currentValue, newValue), Easing.None); + this.TransformTo(nameof(DisplayedCount), newValue, getProportionalDuration(currentValue, newValue)); private string formatCount(int count) => $@"{count}x"; diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index cffdb21fb8..2f7ca74372 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -3,18 +3,15 @@ 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.UserInterface; -using osu.Game.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; using osuTK; -using osu.Game.Graphics.Containers; -using osu.Framework.Input.Events; -using osu.Game.Graphics; namespace osu.Game.Screens.Play.HUD { @@ -22,8 +19,6 @@ namespace osu.Game.Screens.Play.HUD { private const int fade_duration = 1000; - public bool DisplayUnrankedText = true; - public ExpansionMode ExpansionMode = ExpansionMode.ExpandOnHover; private readonly Bindable> current = new Bindable>(); @@ -42,7 +37,6 @@ namespace osu.Game.Screens.Play.HUD } private readonly FillFlowContainer iconsContainer; - private readonly OsuSpriteText unrankedText; public ModDisplay() { @@ -63,13 +57,6 @@ namespace osu.Game.Screens.Play.HUD AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, }, - unrankedText = new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = @"/ UNRANKED /", - Font = OsuFont.Numeric.With(size: 12) - } }, }; } @@ -102,11 +89,6 @@ namespace osu.Game.Screens.Play.HUD private void appearTransform() { - if (DisplayUnrankedText && Current.Value.Any(m => !m.Ranked)) - unrankedText.FadeInFromZero(fade_duration, Easing.OutQuint); - else - unrankedText.Hide(); - expand(); using (iconsContainer.BeginDelayedSequence(1200)) diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index c3bfe19b29..a10c16fcd5 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -55,20 +55,27 @@ namespace osu.Game.Screens.Play.HUD foreach (var userId in playingUsers) { - // probably won't be required in the final implementation. - var resolvedUser = userLookupCache.GetUserAsync(userId).Result; - var trackedUser = CreateUserData(userId, scoreProcessor); trackedUser.ScoringMode.BindTo(scoringMode); - - var leaderboardScore = AddPlayer(resolvedUser, resolvedUser?.Id == api.LocalUser.Value.Id); - leaderboardScore.Accuracy.BindTo(trackedUser.Accuracy); - leaderboardScore.TotalScore.BindTo(trackedUser.Score); - leaderboardScore.Combo.BindTo(trackedUser.CurrentCombo); - leaderboardScore.HasQuit.BindTo(trackedUser.UserQuit); - UserScores[userId] = trackedUser; } + + userLookupCache.GetUsersAsync(playingUsers.ToArray()).ContinueWith(users => Schedule(() => + { + foreach (var user in users.Result) + { + if (user == null) + continue; + + var trackedUser = UserScores[user.Id]; + + var leaderboardScore = AddPlayer(user, user.Id == api.LocalUser.Value.Id); + leaderboardScore.Accuracy.BindTo(trackedUser.Accuracy); + leaderboardScore.TotalScore.BindTo(trackedUser.Score); + leaderboardScore.Combo.BindTo(trackedUser.CurrentCombo); + leaderboardScore.HasQuit.BindTo(trackedUser.UserQuit); + } + })); } protected override void LoadComplete() @@ -84,6 +91,8 @@ namespace osu.Game.Screens.Play.HUD usersChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { userId })); } + // bind here is to support players leaving the match. + // new players are not supported. playingUsers.BindTo(multiplayerClient.CurrentMatchPlayingUserIds); playingUsers.BindCollectionChanged(usersChanged); diff --git a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs index e08044b14c..b64e5ca98f 100644 --- a/osu.Game/Screens/Play/HUD/SkinnableInfo.cs +++ b/osu.Game/Screens/Play/HUD/SkinnableInfo.cs @@ -32,6 +32,9 @@ namespace osu.Game.Screens.Play.HUD public Anchor Origin { get; set; } + /// + public bool UsesFixedAnchor { get; set; } + public List Children { get; } = new List(); [JsonConstructor] @@ -53,6 +56,9 @@ namespace osu.Game.Screens.Play.HUD Anchor = component.Anchor; Origin = component.Origin; + if (component is ISkinnableDrawable skinnable) + UsesFixedAnchor = skinnable.UsesFixedAnchor; + if (component is Container container) { foreach (var child in container.OfType().OfType()) diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index fcbc6fae15..3fbb55872b 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -100,7 +100,13 @@ namespace osu.Game.Screens.Play { // The source is stopped by a frequency fade first. if (isPaused.NewValue) - this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => AdjustableSource.Stop()); + { + this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => + { + if (IsPaused.Value == isPaused.NewValue) + AdjustableSource.Stop(); + }); + } else this.TransformBindableTo(pauseFreqAdjust, 1, 200, Easing.In); } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index a9f3edf049..0e4d38660b 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -24,10 +23,8 @@ using osu.Game.IO.Archives; using osu.Game.Online.API; using osu.Game.Online.Spectator; using osu.Game.Overlays; -using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; @@ -35,6 +32,7 @@ using osu.Game.Scoring.Legacy; using osu.Game.Screens.Ranking; using osu.Game.Skinning; using osu.Game.Users; +using osuTK.Graphics; namespace osu.Game.Screens.Play { @@ -84,10 +82,6 @@ namespace osu.Game.Screens.Play [Resolved] private ScoreManager scoreManager { get; set; } - private RulesetInfo rulesetInfo; - - private Ruleset ruleset; - [Resolved] private IAPIProvider api { get; set; } @@ -97,6 +91,10 @@ namespace osu.Game.Screens.Play [Resolved] private SpectatorClient spectatorClient { get; set; } + protected Ruleset GameplayRuleset { get; private set; } + + protected GameplayBeatmap GameplayBeatmap { get; private set; } + private Sample sampleRestart; public BreakOverlay BreakOverlay; @@ -137,6 +135,8 @@ namespace osu.Game.Screens.Play public readonly PlayerConfiguration Configuration; + protected Score Score { get; private set; } + /// /// Create a new player instance. /// @@ -145,8 +145,6 @@ namespace osu.Game.Screens.Play Configuration = configuration ?? new PlayerConfiguration(); } - private GameplayBeatmap gameplayBeatmap; - private ScreenSuspensionHandler screenSuspension; private DependencyContainer dependencies; @@ -161,30 +159,32 @@ namespace osu.Game.Screens.Play if (!LoadedBeatmapSuccessfully) return; - // replays should never be recorded or played back when autoplay is enabled - if (!Mods.Value.Any(m => m is ModAutoplay)) - PrepareReplay(); + Score = CreateScore(); + + // ensure the score is in a consistent state with the current player. + Score.ScoreInfo.Beatmap = Beatmap.Value.BeatmapInfo; + Score.ScoreInfo.Ruleset = GameplayRuleset.RulesetInfo; + Score.ScoreInfo.Mods = Mods.Value.ToArray(); + + PrepareReplay(); + + ScoreProcessor.NewJudgement += result => ScoreProcessor.PopulateScore(Score.ScoreInfo); gameActive.BindValueChanged(_ => updatePauseOnFocusLostState(), true); } - [CanBeNull] - private Score recordingScore; - /// /// Run any recording / playback setup for replays. /// protected virtual void PrepareReplay() { - DrawableRuleset.SetRecordTarget(recordingScore = new Score()); - - ScoreProcessor.NewJudgement += result => ScoreProcessor.PopulateScore(recordingScore.ScoreInfo); + DrawableRuleset.SetRecordTarget(Score); } [BackgroundDependencyLoader(true)] private void load(AudioManager audio, OsuConfigManager config, OsuGameBase game) { - Mods.Value = base.Mods.Value.Select(m => m.CreateCopy()).ToArray(); + Mods.Value = base.Mods.Value.Select(m => m.DeepClone()).ToArray(); if (Beatmap.Value is DummyWorkingBeatmap) return; @@ -204,16 +204,16 @@ namespace osu.Game.Screens.Play if (game is OsuGame osuGame) LocalUserPlaying.BindTo(osuGame.LocalUserPlaying); - DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, Mods.Value); + DrawableRuleset = GameplayRuleset.CreateDrawableRulesetWith(playableBeatmap, Mods.Value); dependencies.CacheAs(DrawableRuleset); - ScoreProcessor = ruleset.CreateScoreProcessor(); + ScoreProcessor = GameplayRuleset.CreateScoreProcessor(); ScoreProcessor.ApplyBeatmap(playableBeatmap); ScoreProcessor.Mods.BindTo(Mods); dependencies.CacheAs(ScoreProcessor); - HealthProcessor = ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); + HealthProcessor = GameplayRuleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime); HealthProcessor.ApplyBeatmap(playableBeatmap); dependencies.CacheAs(HealthProcessor); @@ -223,34 +223,28 @@ namespace osu.Game.Screens.Play InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime); - AddInternal(gameplayBeatmap = new GameplayBeatmap(playableBeatmap)); + AddInternal(GameplayBeatmap = new GameplayBeatmap(playableBeatmap)); AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer)); - dependencies.CacheAs(gameplayBeatmap); + 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)); + var rulesetSkinProvider = new RulesetSkinProvidingContainer(GameplayRuleset, playableBeatmap, Beatmap.Value.Skin); // 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)); + GameplayClockContainer.Add(rulesetSkinProvider); rulesetSkinProvider.AddRange(new[] { - // underlay and gameplay should have access the to skinning sources. + // underlay and gameplay should have access to the 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))); + // also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.) + // we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there. + rulesetSkinProvider.Add(createOverlayComponents(Beatmap.Value)); if (!DrawableRuleset.AllowGameplayOverlays) { @@ -284,7 +278,7 @@ namespace osu.Game.Screens.Play { HealthProcessor.ApplyResult(r); ScoreProcessor.ApplyResult(r); - gameplayBeatmap.ApplyResult(r); + GameplayBeatmap.ApplyResult(r); }; DrawableRuleset.RevertResult += r => @@ -295,12 +289,12 @@ namespace osu.Game.Screens.Play DimmableStoryboard.HasStoryboardEnded.ValueChanged += storyboardEnded => { - if (storyboardEnded.NewValue && completionProgressDelegate == null) - updateCompletionState(); + if (storyboardEnded.NewValue) + progressToResults(true); }; // Bind the judgement processors to ourselves - ScoreProcessor.HasCompleted.BindValueChanged(_ => updateCompletionState()); + ScoreProcessor.HasCompleted.BindValueChanged(scoreCompletionChanged); HealthProcessor.Failed += onFail; foreach (var mod in Mods.Value.OfType()) @@ -374,7 +368,7 @@ namespace osu.Game.Screens.Play }, skipOutroOverlay = new SkipOverlay(Beatmap.Value.Storyboard.LatestEventTime ?? 0) { - RequestSkip = () => updateCompletionState(true), + RequestSkip = () => progressToResults(false), Alpha = 0 }, FailOverlay = new FailOverlay @@ -473,18 +467,18 @@ namespace osu.Game.Screens.Play if (Beatmap.Value.Beatmap == null) throw new InvalidOperationException("Beatmap was not loaded"); - rulesetInfo = Ruleset.Value ?? Beatmap.Value.BeatmapInfo.Ruleset; - ruleset = rulesetInfo.CreateInstance(); + var rulesetInfo = Ruleset.Value ?? Beatmap.Value.BeatmapInfo.Ruleset; + GameplayRuleset = rulesetInfo.CreateInstance(); try { - playable = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, Mods.Value); + playable = Beatmap.Value.GetPlayableBeatmap(GameplayRuleset.RulesetInfo, Mods.Value); } catch (BeatmapInvalidForRulesetException) { // 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(); + GameplayRuleset = rulesetInfo.CreateInstance(); playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo, Mods.Value); } @@ -506,19 +500,25 @@ namespace osu.Game.Screens.Play } /// - /// Exits the . + /// Attempts to complete a user request to exit gameplay. /// + /// + /// + /// This should only be called in response to a user interaction. Exiting is not guaranteed. + /// This will interrupt any pending progression to the results screen, even if the transition has begun. + /// + /// /// /// Whether the pause or fail dialog should be shown before performing an exit. - /// If true and a dialog is not yet displayed, the exit will be blocked the the relevant dialog will display instead. + /// If and a dialog is not yet displayed, the exit will be blocked and the relevant dialog will display instead. /// protected void PerformExit(bool showDialogFirst) { - // if a restart has been requested, cancel any pending completion (user has shown intent to restart). - completionProgressDelegate?.Cancel(); + // if an exit has been requested, cancel any pending completion (the user has shown intention to exit). + resultsDisplayDelegate?.Cancel(); - // there is a chance that the exit was performed after the transition to results has started. - // we want to give the user what they want, so forcefully return to this screen (to proceed with the upwards exit process). + // there is a chance that an exit request occurs after the transition to results has already started. + // even in such a case, the user has shown intent, so forcefully return to this screen (to proceed with the upwards exit process). if (!this.IsCurrentScreen()) { ValidForResume = false; @@ -541,7 +541,7 @@ namespace osu.Game.Screens.Play return; } - // there's a chance the pausing is not supported in the current state, at which point immediate exit should be preferred. + // even if this call has requested a dialog, there is a chance the current player mode doesn't support pausing. if (pausingSupportedByCurrentState) { // in the case a dialog needs to be shown, attempt to pause and show it. @@ -549,14 +549,12 @@ namespace osu.Game.Screens.Play Pause(); return; } - - // if the score is ready for display but results screen has not been pushed yet (e.g. storyboard is still playing beyond gameplay), then transition to results screen instead of exiting. - if (prepareScoreForDisplayTask != null && completionProgressDelegate == null) - { - updateCompletionState(true); - } } + // The actual exit is performed if + // - the pause / fail dialog was not requested + // - the pause / fail dialog was requested but is already displayed (user showing intention to exit). + // - the pause / fail dialog was requested but couldn't be displayed due to the type or state of this Player instance. this.Exit(); } @@ -578,6 +576,29 @@ namespace osu.Game.Screens.Play /// The destination time to seek to. public void Seek(double time) => GameplayClockContainer.Seek(time); + private ScheduledDelegate frameStablePlaybackResetDelegate; + + /// + /// Seeks to a specific time in gameplay, bypassing frame stability. + /// + /// + /// Intermediate hitobject judgements may not be applied or reverted correctly during this seek. + /// + /// The destination time to seek to. + internal void NonFrameStableSeek(double time) + { + if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed) + frameStablePlaybackResetDelegate.RunTask(); + + bool wasFrameStable = DrawableRuleset.FrameStablePlayback; + DrawableRuleset.FrameStablePlayback = false; + + Seek(time); + + // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek. + frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable); + } + /// /// Restart gameplay via a parent . /// This can be called from a child screen in order to trigger the restart process. @@ -597,95 +618,143 @@ namespace osu.Game.Screens.Play PerformExit(false); } - private ScheduledDelegate completionProgressDelegate; + /// + /// This delegate, when set, means the results screen has been queued to appear. + /// The display of the results screen may be delayed by any work being done in . + /// + /// + /// Once set, this can *only* be cancelled by rewinding, ie. if ScoreProcessor.HasCompleted becomes . + /// Even if the user requests an exit, it will forcefully proceed to the results screen (see special case in ). + /// + private ScheduledDelegate resultsDisplayDelegate; + + /// + /// A task which asynchronously prepares a completed score for display at results. + /// This may include performing net requests or importing the score into the database, generally to ensure things are in a sane state for the play session. + /// private Task prepareScoreForDisplayTask; /// /// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime. /// - /// If in a state where a storyboard outro is to be played, offers the choice of skipping beyond it. /// Thrown if this method is called more than once without changing state. - private void updateCompletionState(bool skipStoryboardOutro = false) + private void scoreCompletionChanged(ValueChangedEvent completed) { - // screen may be in the exiting transition phase. + // If this player instance is in the middle of an exit, don't attempt any kind of state update. if (!this.IsCurrentScreen()) return; - if (!ScoreProcessor.HasCompleted.Value) + // Special case to handle rewinding post-completion. This is the only way already queued forward progress can be cancelled. + // TODO: Investigate whether this can be moved to a RewindablePlayer subclass or similar. + // Currently, even if this scenario is hit, prepareScoreForDisplay has already been queued (and potentially run). + // In scenarios where rewinding is possible (replay, spectating) this is a non-issue as no submission/import work is done, + // but it still doesn't feel right that this exists here. + if (!completed.NewValue) { - completionProgressDelegate?.Cancel(); - completionProgressDelegate = null; + resultsDisplayDelegate?.Cancel(); + resultsDisplayDelegate = null; + ValidForResume = true; skipOutroOverlay.Hide(); 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 (HealthProcessor.HasFailed) return; + // Setting this early in the process means that even if something were to go wrong in the order of events following, there + // is no chance that a user could return to the (already completed) Player instance from a child screen. ValidForResume = false; - if (!Configuration.ShowResults) return; + // Ensure we are not writing to the replay any more, as we are about to consume and store the score. + DrawableRuleset.SetRecordTarget(null); - prepareScoreForDisplayTask ??= Task.Run(async () => - { - var score = CreateScore(); - - try - { - await PrepareScoreForResultsAsync(score).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error(ex, "Score preparation failed!"); - } - - try - { - await ImportScore(score).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.Error(ex, "Score import failed!"); - } - - return score.ScoreInfo; - }); - - if (skipStoryboardOutro) - { - scheduleCompletion(); + if (!Configuration.ShowResults) return; - } + + prepareScoreForDisplayTask ??= Task.Run(prepareScoreForResults); bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value; if (storyboardHasOutro) { + // if the current beatmap has a storyboard, the progression to results will be handled by the storyboard ending + // or the user pressing the skip outro button. skipOutroOverlay.Show(); return; } - using (BeginDelayedSequence(RESULTS_DISPLAY_DELAY)) - scheduleCompletion(); + progressToResults(true); } - private void scheduleCompletion() => completionProgressDelegate = Schedule(() => + /// + /// Asynchronously run score preparation operations (database import, online submission etc.). + /// + /// The final score. + private async Task prepareScoreForResults() { - if (!prepareScoreForDisplayTask.IsCompleted) + var scoreCopy = Score.DeepClone(); + + try { - scheduleCompletion(); - return; + await PrepareScoreForResultsAsync(scoreCopy).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error(ex, @"Score preparation failed!"); } - // screen may be in the exiting transition phase. - if (this.IsCurrentScreen()) + try + { + await ImportScore(scoreCopy).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.Error(ex, @"Score import failed!"); + } + + return scoreCopy.ScoreInfo; + } + + /// + /// Queue the results screen for display. + /// + /// + /// A final display will only occur once all work is completed in . This means that even after calling this method, the results screen will never be shown until ScoreProcessor.HasCompleted becomes . + /// + /// Calling this method multiple times will have no effect. + /// + /// Whether a minimum delay () should be added before the screen is displayed. + private void progressToResults(bool withDelay) + { + if (resultsDisplayDelegate != null) + // Note that if progressToResults is called one withDelay=true and then withDelay=false, this no-delay timing will not be + // accounted for. shouldn't be a huge concern (a user pressing the skip button after a results progression has already been queued + // may take x00 more milliseconds than expected in the very rare edge case). + // + // If required we can handle this more correctly by rescheduling here. + return; + + double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0; + + resultsDisplayDelegate = new ScheduledDelegate(() => + { + if (prepareScoreForDisplayTask?.IsCompleted != true) + // If the asynchronous preparation has not completed, keep repeating this delegate. + return; + + resultsDisplayDelegate?.Cancel(); + + if (!this.IsCurrentScreen()) + // This player instance may already be in the process of exiting. + return; + this.Push(CreateResults(prepareScoreForDisplayTask.Result)); - }); + }, Time.Current + delay, 50); + + Scheduler.Add(resultsDisplayDelegate); + } protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value; @@ -701,6 +770,7 @@ namespace osu.Game.Screens.Play return false; HasFailed = true; + Score.ScoreInfo.Passed = false; // There is a chance that we could be in a paused state as the ruleset's internal clock (see FrameStabilityContainer) // could process an extra frame after the GameplayClock is stopped. @@ -825,6 +895,7 @@ namespace osu.Game.Screens.Play { b.IgnoreUserSettings.Value = false; b.BlurAmount.Value = 0; + b.FadeColour(Color4.White, 250); // bind component bindables. b.IsBreakTime.BindTo(breakTracker.IsBreakTime); @@ -882,11 +953,12 @@ namespace osu.Game.Screens.Play { screenSuspension?.Expire(); - if (completionProgressDelegate != null && !completionProgressDelegate.Cancelled && !completionProgressDelegate.Completed) + // if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap. + if (prepareScoreForDisplayTask == null) { - // proceed to result screen if beatmap already finished playing - completionProgressDelegate.RunTask(); - return true; + Score.ScoreInfo.Passed = false; + // potentially should be ScoreRank.F instead? this is the best alternative for now. + Score.ScoreInfo.Rank = ScoreRank.D; } // EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous. @@ -905,41 +977,18 @@ namespace osu.Game.Screens.Play } /// - /// Creates the player's . + /// Creates the player's . /// - /// The . - protected virtual Score CreateScore() + /// The . + protected virtual Score CreateScore() => new Score { - var score = new Score - { - ScoreInfo = new ScoreInfo - { - Beatmap = Beatmap.Value.BeatmapInfo, - Ruleset = rulesetInfo, - Mods = Mods.Value.ToArray(), - } - }; - - if (DrawableRuleset.ReplayScore != null) - { - score.ScoreInfo.User = DrawableRuleset.ReplayScore.ScoreInfo?.User ?? new GuestUser(); - score.Replay = DrawableRuleset.ReplayScore.Replay; - } - else - { - score.ScoreInfo.User = api.LocalUser.Value; - score.Replay = new Replay { Frames = recordingScore?.Replay.Frames.ToList() ?? new List() }; - } - - ScoreProcessor.PopulateScore(score.ScoreInfo); - - return score; - } + ScoreInfo = new ScoreInfo { User = api.LocalUser.Value }, + }; /// - /// Imports the player's to the local database. + /// Imports the player's to the local database. /// - /// The to import. + /// The to import. /// The imported score. protected virtual async Task ImportScore(Score score) { @@ -951,7 +1000,7 @@ namespace osu.Game.Screens.Play using (var stream = new MemoryStream()) { - new LegacyScoreEncoder(score, gameplayBeatmap.PlayableBeatmap).Encode(stream); + new LegacyScoreEncoder(score, GameplayBeatmap.PlayableBeatmap).Encode(stream); replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr"); } @@ -970,9 +1019,9 @@ namespace osu.Game.Screens.Play } /// - /// Prepare the for display at results. + /// Prepare the for display at results. /// - /// The to prepare. + /// The to prepare. /// A task that prepares the provided score. On completion, the score is assumed to be ready for display. protected virtual Task PrepareScoreForResultsAsync(Score score) => Task.CompletedTask; diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index ce580e2b53..5f6b4ca2b0 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -184,8 +184,6 @@ namespace osu.Game.Screens.Play { if (epilepsyWarning != null) epilepsyWarning.DimmableBackground = b; - - b?.FadeColour(Color4.White, 800, Easing.OutQuint); }); Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); @@ -334,6 +332,8 @@ namespace osu.Game.Screens.Play content.FadeInFromZero(400); content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); + + ApplyToBackground(b => b?.FadeColour(Color4.White, 800, Easing.OutQuint)); } private void contentOut() diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index e23cc22929..adbb5a53f6 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -1,9 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Input.Bindings; +using osu.Framework.Threading; +using osu.Game.Beatmaps; +using osu.Game.Extensions; using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Scoring; using osu.Game.Screens.Ranking; @@ -11,15 +19,20 @@ namespace osu.Game.Screens.Play { public class ReplayPlayer : Player, IKeyBindingHandler { - protected readonly Score Score; + private readonly Func, Score> createScore; // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) protected override bool CheckModsAllowFailure() => false; public ReplayPlayer(Score score, PlayerConfiguration configuration = null) + : this((_, __) => score, configuration) + { + } + + public ReplayPlayer(Func, Score> createScore, PlayerConfiguration configuration = null) : base(configuration) { - Score = score; + this.createScore = createScore; } protected override void PrepareReplay() @@ -27,25 +40,31 @@ namespace osu.Game.Screens.Play DrawableRuleset?.SetReplayScore(Score); } - protected override Score CreateScore() - { - var baseScore = base.CreateScore(); - - // Since the replay score doesn't contain statistics, we'll pass them through here. - Score.ScoreInfo.HitEvents = baseScore.ScoreInfo.HitEvents; - - return Score; - } + protected override Score CreateScore() => createScore(GameplayBeatmap.PlayableBeatmap, Mods.Value); // Don't re-import replay scores as they're already present in the database. protected override Task ImportScore(Score score) => Task.CompletedTask; protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, false); + private ScheduledDelegate keyboardSeekDelegate; + public bool OnPressed(GlobalAction action) { + const double keyboard_seek_amount = 5000; + switch (action) { + case GlobalAction.SeekReplayBackward: + keyboardSeekDelegate?.Cancel(); + keyboardSeekDelegate = this.BeginKeyRepeat(Scheduler, () => keyboardSeek(-1)); + return true; + + case GlobalAction.SeekReplayForward: + keyboardSeekDelegate?.Cancel(); + keyboardSeekDelegate = this.BeginKeyRepeat(Scheduler, () => keyboardSeek(1)); + return true; + case GlobalAction.TogglePauseReplay: if (GameplayClockContainer.IsPaused.Value) GameplayClockContainer.Start(); @@ -55,10 +74,24 @@ namespace osu.Game.Screens.Play } return false; + + void keyboardSeek(int direction) + { + double target = Math.Clamp(GameplayClockContainer.CurrentTime + direction * keyboard_seek_amount, 0, GameplayBeatmap.HitObjects.Last().GetEndTime()); + + Seek(target); + } } public void OnReleased(GlobalAction action) { + switch (action) + { + case GlobalAction.SeekReplayBackward: + case GlobalAction.SeekReplayForward: + keyboardSeekDelegate?.Cancel(); + break; + } } } } diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs index d0ef4131dc..d90e8e0168 100644 --- a/osu.Game/Screens/Play/SoloPlayer.cs +++ b/osu.Game/Screens/Play/SoloPlayer.cs @@ -6,18 +6,29 @@ using System.Diagnostics; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Online.Solo; +using osu.Game.Rulesets; using osu.Game.Scoring; namespace osu.Game.Screens.Play { public class SoloPlayer : SubmittingPlayer { + public SoloPlayer() + : this(null) + { + } + + protected SoloPlayer(PlayerConfiguration configuration = null) + : base(configuration) + { + } + protected override APIRequest CreateTokenRequest() { if (!(Beatmap.Value.BeatmapInfo.OnlineBeatmapID is int beatmapId)) return null; - if (!(Ruleset.Value.ID is int rulesetId)) + if (!(Ruleset.Value.ID is int rulesetId) || Ruleset.Value.ID > ILegacyRuleset.MAX_LEGACY_RULESET_ID) return null; return new CreateSoloScoreRequest(beatmapId, rulesetId, Game.VersionHash); @@ -27,9 +38,11 @@ namespace osu.Game.Screens.Play protected override APIRequest CreateSubmissionRequest(Score score, long token) { - Debug.Assert(Beatmap.Value.BeatmapInfo.OnlineBeatmapID != null); + var beatmap = score.ScoreInfo.Beatmap; - int beatmapId = Beatmap.Value.BeatmapInfo.OnlineBeatmapID.Value; + Debug.Assert(beatmap.OnlineBeatmapID != null); + + int beatmapId = beatmap.OnlineBeatmapID.Value; return new SubmitSoloScoreRequest(beatmapId, token, score.ScoreInfo); } diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index cab44c7473..bd861dc598 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -78,6 +78,8 @@ namespace osu.Game.Screens.Play private IClock referenceClock; + public bool UsesFixedAnchor { get; set; } + public SongProgress() { RelativeSizeAxes = Axes.X; diff --git a/osu.Game/Screens/Play/SongProgressBar.cs b/osu.Game/Screens/Play/SongProgressBar.cs index 939b5fad1f..5052b32335 100644 --- a/osu.Game/Screens/Play/SongProgressBar.cs +++ b/osu.Game/Screens/Play/SongProgressBar.cs @@ -57,8 +57,6 @@ 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; diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index a8125dfded..f662a479ec 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.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.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.Rulesets.Replays; +using osu.Game.Rulesets.Replays.Types; using osu.Game.Scoring; using osu.Game.Screens.Ranking; @@ -16,6 +16,9 @@ namespace osu.Game.Screens.Play { public class SpectatorPlayer : Player { + [Resolved] + private SpectatorClient spectatorClient { get; set; } + private readonly Score score; protected override bool CheckModsAllowFailure() => false; // todo: better support starting mid-way through beatmap @@ -25,14 +28,6 @@ namespace osu.Game.Screens.Play this.score = score; } - protected override ResultsScreen CreateResults(ScoreInfo score) - { - return new SpectatorResultsScreen(score); - } - - [Resolved] - private SpectatorClient spectatorClient { get; set; } - [BackgroundDependencyLoader] private void load() { @@ -48,25 +43,58 @@ namespace osu.Game.Screens.Play }); } + protected override void StartGameplay() + { + base.StartGameplay(); + + // Start gameplay along with the very first arrival frame (the latest one). + score.Replay.Frames.Clear(); + spectatorClient.OnNewFrames += userSentFrames; + } + + private void userSentFrames(int userId, FrameDataBundle bundle) + { + if (userId != score.ScoreInfo.User.Id) + return; + + if (!LoadedBeatmapSuccessfully) + return; + + if (!this.IsCurrentScreen()) + return; + + bool isFirstBundle = score.Replay.Frames.Count == 0; + + foreach (var frame in bundle.Frames) + { + IConvertibleReplayFrame convertibleFrame = GameplayRuleset.CreateConvertibleReplayFrame(); + convertibleFrame.FromLegacy(frame, GameplayBeatmap.PlayableBeatmap); + + var convertedFrame = (ReplayFrame)convertibleFrame; + convertedFrame.Time = frame.Time; + + score.Replay.Frames.Add(convertedFrame); + } + + if (isFirstBundle && score.Replay.Frames.Count > 0) + NonFrameStableSeek(score.Replay.Frames[0].Time); + } + + protected override Score CreateScore() => score; + + protected override ResultsScreen CreateResults(ScoreInfo score) + => new SpectatorResultsScreen(score); + 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 MasterGameplayClockContainer(beatmap, firstFrameTime.Value, true); - } - public override bool OnExiting(IScreen next) { spectatorClient.OnUserBeganPlaying -= userBeganPlaying; + spectatorClient.OnNewFrames -= userSentFrames; + return base.OnExiting(next); } @@ -85,7 +113,10 @@ namespace osu.Game.Screens.Play base.Dispose(isDisposing); if (spectatorClient != null) + { spectatorClient.OnUserBeganPlaying -= userBeganPlaying; + spectatorClient.OnNewFrames -= userSentFrames; + } } } } diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 23b9037244..5faa384d03 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -10,7 +10,7 @@ using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Online.API; using osu.Game.Online.Rooms; -using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; namespace osu.Game.Screens.Play @@ -28,6 +28,8 @@ namespace osu.Game.Screens.Play [Resolved] private IAPIProvider api { get; set; } + private TaskCompletionSource scoreSubmissionSource; + protected SubmittingPlayer(PlayerConfiguration configuration = null) : base(configuration) { @@ -44,9 +46,9 @@ namespace osu.Game.Screens.Play // Token request construction should happen post-load to allow derived classes to potentially prepare DI backings that are used to create the request. var tcs = new TaskCompletionSource(); - if (Mods.Value.Any(m => m is ModAutoplay)) + if (Mods.Value.Any(m => !m.UserPlayable)) { - handleTokenFailure(new InvalidOperationException("Autoplay loaded.")); + handleTokenFailure(new InvalidOperationException("Non-user playable mod selected.")); return false; } @@ -107,27 +109,18 @@ namespace osu.Game.Screens.Play { await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); - // token may be null if the request failed but gameplay was still allowed (see HandleTokenRetrievalFailure). - if (token == null) - return; + score.ScoreInfo.Date = DateTimeOffset.Now; - var tcs = new TaskCompletionSource(); - var request = CreateSubmissionRequest(score, token.Value); + await submitScore(score).ConfigureAwait(false); + } - request.Success += s => - { - score.ScoreInfo.OnlineScoreID = s.ID; - tcs.SetResult(true); - }; + public override bool OnExiting(IScreen next) + { + var exiting = base.OnExiting(next); - request.Failure += e => - { - Logger.Error(e, "Failed to submit score"); - tcs.SetResult(false); - }; + submitScore(Score.DeepClone()); - api.Queue(request); - await tcs.Task.ConfigureAwait(false); + return exiting; } /// @@ -144,5 +137,37 @@ namespace osu.Game.Screens.Play /// The score to be submitted. /// The submission token. protected abstract APIRequest CreateSubmissionRequest(Score score, long token); + + private Task submitScore(Score score) + { + // token may be null if the request failed but gameplay was still allowed (see HandleTokenRetrievalFailure). + if (token == null) + return Task.CompletedTask; + + if (scoreSubmissionSource != null) + return scoreSubmissionSource.Task; + + // if the user never hit anything, this score should not be counted in any way. + if (!score.ScoreInfo.Statistics.Any(s => s.Key.IsHit() && s.Value > 0)) + return Task.CompletedTask; + + scoreSubmissionSource = new TaskCompletionSource(); + var request = CreateSubmissionRequest(score, token.Value); + + request.Success += s => + { + score.ScoreInfo.OnlineScoreID = s.ID; + scoreSubmissionSource.SetResult(true); + }; + + request.Failure += e => + { + Logger.Error(e, "Failed to submit score"); + scoreSubmissionSource.SetResult(false); + }; + + api.Queue(request); + return scoreSubmissionSource.Task; + } } } diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs index 24f1116d0e..7e8dcdcfe0 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs @@ -137,7 +137,6 @@ namespace osu.Game.Screens.Ranking.Contracted Origin = Anchor.TopCentre, AutoSizeAxes = Axes.Both, ExpansionMode = ExpansionMode.AlwaysExpanded, - DisplayUnrankedText = false, Current = { Value = score.Mods }, Scale = new Vector2(0.5f), } diff --git a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs index c70b4dd35b..635be60549 100644 --- a/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs +++ b/osu.Game/Screens/Ranking/Expanded/Accuracy/AccuracyCircle.cs @@ -5,14 +5,18 @@ using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; +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.Platform; 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 @@ -79,13 +83,28 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy private Container badges; private RankText rankText; - public AccuracyCircle(ScoreInfo score) + private PoolableSkinnableSample scoreTickSound; + private PoolableSkinnableSample badgeTickSound; + private PoolableSkinnableSample badgeMaxSound; + private PoolableSkinnableSample swooshUpSound; + private PoolableSkinnableSample rankImpactSound; + private PoolableSkinnableSample rankApplauseSound; + + private readonly Bindable tickPlaybackRate = new Bindable(); + + private double lastTickPlaybackTime; + private bool isTicking; + + private readonly bool withFlair; + + public AccuracyCircle(ScoreInfo score, bool withFlair = false) { this.score = score; + this.withFlair = withFlair; } [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load(GameHost host) { InternalChildren = new Drawable[] { @@ -204,14 +223,19 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy }, rankText = new RankText(score.Rank) }; - } - private ScoreRank getRank(ScoreRank rank) - { - foreach (var mod in score.Mods.OfType()) - rank = mod.AdjustRank(rank, score.Accuracy); - - return rank; + if (withFlair) + { + AddRangeInternal(new Drawable[] + { + rankImpactSound = new PoolableSkinnableSample(new SampleInfo(impactSampleName)), + rankApplauseSound = new PoolableSkinnableSample(new SampleInfo(@"applause", applauseSampleName)), + scoreTickSound = new PoolableSkinnableSample(new SampleInfo(@"Results/score-tick")), + badgeTickSound = new PoolableSkinnableSample(new SampleInfo(@"Results/badge-dink")), + badgeMaxSound = new PoolableSkinnableSample(new SampleInfo(@"Results/badge-dink-max")), + swooshUpSound = new PoolableSkinnableSample(new SampleInfo(@"Results/swoosh-up")), + }); + } } protected override void LoadComplete() @@ -220,33 +244,170 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy this.ScaleTo(0).Then().ScaleTo(1, APPEAR_DURATION, Easing.OutQuint); - using (BeginDelayedSequence(RANK_CIRCLE_TRANSFORM_DELAY, true)) + if (withFlair) + { + const double swoosh_pre_delay = 443f; + const double swoosh_volume = 0.4f; + + this.Delay(swoosh_pre_delay).Schedule(() => + { + swooshUpSound.VolumeTo(swoosh_volume); + swooshUpSound.Play(); + }); + } + + using (BeginDelayedSequence(RANK_CIRCLE_TRANSFORM_DELAY)) innerMask.FillTo(1f, RANK_CIRCLE_TRANSFORM_DURATION, ACCURACY_TRANSFORM_EASING); - using (BeginDelayedSequence(ACCURACY_TRANSFORM_DELAY, true)) + using (BeginDelayedSequence(ACCURACY_TRANSFORM_DELAY)) { 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); + if (withFlair) + { + Schedule(() => + { + const double score_tick_debounce_rate_start = 18f; + const double score_tick_debounce_rate_end = 300f; + const double score_tick_volume_start = 0.6f; + const double score_tick_volume_end = 1.0f; + + this.TransformBindableTo(tickPlaybackRate, score_tick_debounce_rate_start); + this.TransformBindableTo(tickPlaybackRate, score_tick_debounce_rate_end, ACCURACY_TRANSFORM_DURATION, Easing.OutSine); + + scoreTickSound.FrequencyTo(1 + targetAccuracy, ACCURACY_TRANSFORM_DURATION, Easing.OutSine); + scoreTickSound.VolumeTo(score_tick_volume_start).Then().VolumeTo(score_tick_volume_end, ACCURACY_TRANSFORM_DURATION, Easing.OutSine); + + isTicking = true; + }); + } + + int badgeNum = 0; + 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)) + using (BeginDelayedSequence(inverseEasing(ACCURACY_TRANSFORM_EASING, Math.Min(1 - virtual_ss_percentage, badge.Accuracy) / targetAccuracy) * ACCURACY_TRANSFORM_DURATION)) { badge.Appear(); + + if (withFlair) + { + Schedule(() => + { + var dink = badgeNum < badges.Count - 1 ? badgeTickSound : badgeMaxSound; + + dink.FrequencyTo(1 + badgeNum++ * 0.05); + dink.Play(); + }); + } } } - using (BeginDelayedSequence(TEXT_APPEAR_DELAY, true)) + using (BeginDelayedSequence(TEXT_APPEAR_DELAY)) { rankText.Appear(); + + if (!withFlair) return; + + Schedule(() => + { + isTicking = false; + rankImpactSound.Play(); + }); + + const double applause_pre_delay = 545f; + const double applause_volume = 0.8f; + + using (BeginDelayedSequence(applause_pre_delay)) + { + Schedule(() => + { + rankApplauseSound.VolumeTo(applause_volume); + rankApplauseSound.Play(); + }); + } } } } + protected override void Update() + { + base.Update(); + + if (isTicking && Clock.CurrentTime - lastTickPlaybackTime >= tickPlaybackRate.Value) + { + scoreTickSound?.Play(); + lastTickPlaybackTime = Clock.CurrentTime; + } + } + + private string applauseSampleName + { + get + { + switch (score.Rank) + { + default: + case ScoreRank.D: + return @"Results/applause-d"; + + case ScoreRank.C: + return @"Results/applause-c"; + + case ScoreRank.B: + return @"Results/applause-b"; + + case ScoreRank.A: + return @"Results/applause-a"; + + case ScoreRank.S: + case ScoreRank.SH: + case ScoreRank.X: + case ScoreRank.XH: + return @"Results/applause-s"; + } + } + } + + private string impactSampleName + { + get + { + switch (score.Rank) + { + default: + case ScoreRank.D: + return @"Results/rank-impact-fail-d"; + + case ScoreRank.C: + case ScoreRank.B: + return @"Results/rank-impact-fail"; + + case ScoreRank.A: + case ScoreRank.S: + case ScoreRank.SH: + return @"Results/rank-impact-pass"; + + case ScoreRank.X: + case ScoreRank.XH: + return @"Results/rank-impact-pass-ss"; + } + } + } + + private ScoreRank getRank(ScoreRank rank) + { + foreach (var mod in score.Mods.OfType()) + rank = mod.AdjustRank(rank, score.Accuracy); + + return rank; + } + private double inverseEasing(Easing easing, double targetValue) { double test = 0; diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 4895240314..4d3f7a4184 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -122,7 +122,7 @@ namespace osu.Game.Screens.Ranking.Expanded Margin = new MarginPadding { Top = 40 }, RelativeSizeAxes = Axes.X, Height = 230, - Child = new AccuracyCircle(score) + Child = new AccuracyCircle(score, withFlair) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -242,7 +242,6 @@ namespace osu.Game.Screens.Ranking.Expanded { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - DisplayUnrankedText = false, ExpansionMode = ExpansionMode.AlwaysExpanded, Scale = new Vector2(0.5f), Current = { Value = score.Mods } @@ -257,7 +256,7 @@ namespace osu.Game.Screens.Ranking.Expanded // Score counter value setting must be scheduled so it isn't transferred instantaneously ScheduleAfterChildren(() => { - using (BeginDelayedSequence(AccuracyCircle.ACCURACY_TRANSFORM_DELAY, true)) + using (BeginDelayedSequence(AccuracyCircle.ACCURACY_TRANSFORM_DELAY)) { scoreCounter.FadeIn(); scoreCounter.Current = scoreManager.GetBindableTotalScore(score); @@ -266,7 +265,7 @@ namespace osu.Game.Screens.Ranking.Expanded foreach (var stat in statisticDisplays) { - using (BeginDelayedSequence(delay, true)) + using (BeginDelayedSequence(delay)) stat.Appear(); delay += 200; diff --git a/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs b/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs index 7aba699216..e59a0de316 100644 --- a/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs +++ b/osu.Game/Screens/Ranking/Expanded/StarRatingDisplay.cs @@ -4,9 +4,7 @@ using System.Globalization; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; @@ -111,12 +109,9 @@ namespace osu.Game.Screens.Ranking.Expanded var rating = Current.Value.DifficultyRating; - background.Colour = rating == DifficultyRating.ExpertPlus - ? ColourInfo.GradientVertical(Color4Extensions.FromHex("#C1C1C1"), Color4Extensions.FromHex("#595959")) - : (ColourInfo)colours.ForDifficultyRating(rating); + background.Colour = colours.ForDifficultyRating(rating, true); textFlow.Clear(); - textFlow.AddText($"{wholePart}", s => { s.Colour = Color4.Black; diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs index e13138c5a0..b92c244174 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs @@ -38,7 +38,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics if (isPerfect) { - using (BeginDelayedSequence(AccuracyCircle.ACCURACY_TRANSFORM_DURATION / 2, true)) + using (BeginDelayedSequence(AccuracyCircle.ACCURACY_TRANSFORM_DURATION / 2)) perfectText.FadeIn(50); } } diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index a0ea27b640..b458d7c17f 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -12,28 +12,20 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Screens; -using osu.Game.Audio; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Online.API; -using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Play; -using osu.Game.Screens.Ranking.Expanded.Accuracy; using osu.Game.Screens.Ranking.Statistics; -using osu.Game.Skinning; using osuTK; namespace osu.Game.Screens.Ranking { public abstract class ResultsScreen : ScreenWithBeatmapBackground, IKeyBindingHandler { - /// - /// Delay before the default applause sound should be played, in order to match the grade display timing in . - /// - public const double APPLAUSE_DELAY = AccuracyCircle.ACCURACY_TRANSFORM_DELAY + AccuracyCircle.TEXT_APPEAR_DELAY + ScorePanel.RESIZE_DURATION + ScorePanel.TOP_LAYER_EXPAND_DELAY - 1440; - protected const float BACKGROUND_BLUR = 20; private static readonly float screen_height = 768 - TwoLayerButton.SIZE_EXTENDED.Y; @@ -64,8 +56,6 @@ namespace osu.Game.Screens.Ranking private readonly bool allowRetry; private readonly bool allowWatchingReplay; - private SkinnableSound applauseSound; - protected ResultsScreen(ScoreInfo score, bool allowRetry, bool allowWatchingReplay = true) { Score = score; @@ -153,16 +143,9 @@ namespace osu.Game.Screens.Ranking if (Score != null) { // only show flair / animation when arriving after watching a play that isn't autoplay. - bool shouldFlair = player != null && !Score.Mods.Any(m => m is ModAutoplay); + bool shouldFlair = player != null && Score.Mods.All(m => m.UserPlayable); ScorePanelList.AddScore(Score, shouldFlair); - - if (shouldFlair) - { - AddInternal(applauseSound = Score.Rank >= ScoreRank.A - ? new SkinnableSound(new SampleInfo("Results/rankpass", "applause")) - : new SkinnableSound(new SampleInfo("Results/rankfail"))); - } } if (allowWatchingReplay) @@ -200,9 +183,6 @@ namespace osu.Game.Screens.Ranking api.Queue(req); statisticsPanel.State.BindValueChanged(onStatisticsStateChanged, true); - - using (BeginDelayedSequence(APPLAUSE_DELAY)) - Schedule(() => applauseSound?.Play()); } protected override void Update() @@ -257,7 +237,7 @@ namespace osu.Game.Screens.Ranking ApplyToBackground(b => { b.BlurAmount.Value = BACKGROUND_BLUR; - b.FadeTo(0.5f, 250); + b.FadeColour(OsuColour.Gray(0.5f), 250); }); bottomPanel.FadeTo(1, 250); @@ -265,9 +245,11 @@ namespace osu.Game.Screens.Ranking public override bool OnExiting(IScreen next) { - ApplyToBackground(b => b.FadeTo(1, 250)); + if (base.OnExiting(next)) + return true; - return base.OnExiting(next); + this.FadeOut(100); + return false; } public override bool OnBackButton() @@ -315,7 +297,7 @@ namespace osu.Game.Screens.Ranking ScorePanelList.HandleInput = false; // Dim background. - ApplyToBackground(b => b.FadeTo(0.1f, 150)); + ApplyToBackground(b => b.FadeColour(OsuColour.Gray(0.1f), 150)); detachedPanel = expandedPanel; } @@ -339,7 +321,7 @@ namespace osu.Game.Screens.Ranking ScorePanelList.HandleInput = true; // Un-dim background. - ApplyToBackground(b => b.FadeTo(0.5f, 150)); + ApplyToBackground(b => b.FadeColour(OsuColour.Gray(0.5f), 150)); detachedPanel = null; } diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index f66a998db6..6ddecf8297 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -234,7 +234,7 @@ namespace osu.Game.Screens.Ranking bool topLayerExpanded = topLayerContainer.Y < 0; // If the top layer was already expanded, then we don't need to wait for the resize and can instead transform immediately. This looks better when changing the panel state. - using (BeginDelayedSequence(topLayerExpanded ? 0 : RESIZE_DURATION + TOP_LAYER_EXPAND_DELAY, true)) + using (BeginDelayedSequence(topLayerExpanded ? 0 : RESIZE_DURATION + TOP_LAYER_EXPAND_DELAY)) { topLayerContainer.FadeIn(); diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index 441c9e048a..e170241ede 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -8,9 +8,11 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Scoring; using osuTK; +using osuTK.Input; namespace osu.Game.Screens.Ranking { @@ -263,6 +265,26 @@ namespace osu.Game.Screens.Ranking container.Attach(); } + protected override bool OnKeyDown(KeyDownEvent e) + { + var expandedPanelIndex = flow.GetPanelIndex(expandedPanel.Score); + + switch (e.Key) + { + case Key.Left: + if (expandedPanelIndex > 0) + SelectedScore.Value = flow.Children[expandedPanelIndex - 1].Panel.Score; + return true; + + case Key.Right: + if (expandedPanelIndex < flow.Count - 1) + SelectedScore.Value = flow.Children[expandedPanelIndex + 1].Panel.Score; + return true; + } + + return base.OnKeyDown(e); + } + private class Flow : FillFlowContainer { public override IEnumerable FlowingChildren => applySorting(AliveInternalChildren); diff --git a/osu.Game/Screens/ScreenWhiteBox.cs b/osu.Game/Screens/ScreenWhiteBox.cs index cf0c183766..8b38b67f5c 100644 --- a/osu.Game/Screens/ScreenWhiteBox.cs +++ b/osu.Game/Screens/ScreenWhiteBox.cs @@ -191,7 +191,7 @@ namespace osu.Game.Screens boxContainer.ScaleTo(0.2f); boxContainer.RotateTo(-20); - using (BeginDelayedSequence(300, true)) + using (BeginDelayedSequence(300)) { boxContainer.ScaleTo(1, transition_time, Easing.OutElastic); boxContainer.RotateTo(0, transition_time / 2, Easing.OutQuint); diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 26da4279f0..973f54c038 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -1,24 +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 osuTK; -using osuTK.Graphics; +using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using System.Linq; -using osu.Game.Online.API; using osu.Framework.Graphics.Shapes; -using osu.Framework.Extensions.Color4Extensions; -using osu.Game.Screens.Select.Details; 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.Online.API.Requests; -using osu.Game.Rulesets; using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays.BeatmapSet; +using osu.Game.Rulesets; +using osu.Game.Screens.Select.Details; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.Select { @@ -128,13 +129,11 @@ namespace osu.Game.Screens.Select AutoSizeAxes = Axes.Y, LayoutDuration = transition_duration, LayoutEasing = Easing.OutQuad, - Spacing = new Vector2(spacing * 2), - Margin = new MarginPadding { Top = spacing * 2 }, Children = new[] { - description = new MetadataSection("Description"), - source = new MetadataSection("Source"), - tags = new MetadataSection("Tags"), + description = new MetadataSection(MetadataType.Description), + source = new MetadataSection(MetadataType.Source), + tags = new MetadataSection(MetadataType.Tags), }, }, }, @@ -290,73 +289,5 @@ namespace osu.Game.Screens.Select }; } } - - private class MetadataSection : Container - { - private readonly FillFlowContainer textContainer; - private TextFlowContainer textFlow; - - public MetadataSection(string title) - { - Alpha = 0; - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - InternalChild = textContainer = new FillFlowContainer - { - Alpha = 0, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(spacing / 2), - Children = new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = new OsuSpriteText - { - Text = title, - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 14), - }, - }, - }, - }; - } - - public string Text - { - set - { - if (string.IsNullOrEmpty(value)) - { - this.FadeOut(transition_duration); - return; - } - - this.FadeIn(transition_duration); - - setTextAsync(value); - } - } - - private void setTextAsync(string text) - { - LoadComponentAsync(new OsuTextFlowContainer(s => s.Font = s.Font.With(size: 14)) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Colour = Color4.White.Opacity(0.75f), - Text = text - }, loaded => - { - textFlow?.Expire(); - textContainer.Add(textFlow = loaded); - - // fade in if we haven't yet. - textContainer.FadeIn(transition_duration); - }); - } - } } } diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index e1cf0cef4e..4a35202df2 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -431,7 +431,7 @@ namespace osu.Game.Screens.Select public class InfoLabel : Container, IHasTooltip { - public string TooltipText { get; } + public LocalisableString TooltipText { get; } public InfoLabel(BeatmapStatistic statistic) { diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 521b90202d..f95ddfee41 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -42,6 +42,7 @@ namespace osu.Game.Screens.Select.Carousel 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.OverallDifficulty.HasFilter || criteria.OverallDifficulty.IsInRange(Beatmap.BaseDifficulty.OverallDifficulty); match &= !criteria.Length.HasFilter || criteria.Length.IsInRange(Beatmap.Length); match &= !criteria.BPM.HasFilter || criteria.BPM.IsInRange(Beatmap.BPM); diff --git a/osu.Game/Screens/Select/Details/AdvancedStats.cs b/osu.Game/Screens/Select/Details/AdvancedStats.cs index b0084735b1..53e30fd9ca 100644 --- a/osu.Game/Screens/Select/Details/AdvancedStats.cs +++ b/osu.Game/Screens/Select/Details/AdvancedStats.cs @@ -186,11 +186,11 @@ namespace osu.Game.Screens.Select.Details set => name.Text = value; } - private (float baseValue, float? adjustedValue) value; + private (float baseValue, float? adjustedValue)? value; public (float baseValue, float? adjustedValue) Value { - get => value; + get => value ?? (0, null); set { if (value == this.value) diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 208048380a..b9e912df8e 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -24,6 +24,7 @@ namespace osu.Game.Screens.Select public OptionalRange ApproachRate; public OptionalRange DrainRate; public OptionalRange CircleSize; + public OptionalRange OverallDifficulty; public OptionalRange Length; public OptionalRange BPM; public OptionalRange BeatDivisor; diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index ea7f233bea..72d10019b2 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -37,6 +37,7 @@ namespace osu.Game.Screens.Select { switch (key) { + case "star": case "stars": return TryUpdateCriteriaRange(ref criteria.StarDifficulty, op, value, 0.01d / 2); @@ -50,6 +51,9 @@ namespace osu.Game.Screens.Select case "cs": return TryUpdateCriteriaRange(ref criteria.CircleSize, op, value); + case "od": + return TryUpdateCriteriaRange(ref criteria.OverallDifficulty, op, value); + case "bpm": return TryUpdateCriteriaRange(ref criteria.BPM, op, value, 0.01d / 2); diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs index afb3943a09..c3fbd767ff 100644 --- a/osu.Game/Screens/Select/FooterButton.cs +++ b/osu.Game/Screens/Select/FooterButton.cs @@ -66,7 +66,7 @@ namespace osu.Game.Screens.Select private readonly Box light; public FooterButton() - : base(HoverSampleSet.SongSelect) + : base(HoverSampleSet.Button) { AutoSizeAxes = Axes.Both; Shear = SHEAR; diff --git a/osu.Game/Screens/Select/FooterButtonMods.cs b/osu.Game/Screens/Select/FooterButtonMods.cs index b98b48a0c0..5bbca5ca1a 100644 --- a/osu.Game/Screens/Select/FooterButtonMods.cs +++ b/osu.Game/Screens/Select/FooterButtonMods.cs @@ -37,7 +37,6 @@ namespace osu.Game.Screens.Select { Anchor = Anchor.Centre, Origin = Anchor.Centre, - DisplayUnrankedText = false, Scale = new Vector2(0.8f), ExpansionMode = ExpansionMode.AlwaysContracted, }); diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 8ddae67dba..a86a614a05 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -44,6 +44,8 @@ namespace osu.Game.Screens.Select.Leaderboards private IBindable> itemRemoved; + private IBindable> itemAdded; + /// /// Whether to apply the game's currently selected mods as a filter when retrieving scores. /// @@ -85,6 +87,9 @@ namespace osu.Game.Screens.Select.Leaderboards itemRemoved = scoreManager.ItemRemoved.GetBoundCopy(); itemRemoved.BindValueChanged(onScoreRemoved); + + itemAdded = scoreManager.ItemUpdated.GetBoundCopy(); + itemAdded.BindValueChanged(onScoreAdded); } protected override void Reset() @@ -93,7 +98,25 @@ namespace osu.Game.Screens.Select.Leaderboards TopScore = null; } - private void onScoreRemoved(ValueChangedEvent> score) => Schedule(RefreshScores); + private void onScoreRemoved(ValueChangedEvent> score) => + scoreStoreChanged(score); + + private void onScoreAdded(ValueChangedEvent> score) => + scoreStoreChanged(score); + + private void scoreStoreChanged(ValueChangedEvent> score) + { + if (Scope != BeatmapLeaderboardScope.Local) + return; + + if (score.NewValue.TryGetTarget(out var scoreInfo)) + { + if (Beatmap?.ID != scoreInfo.BeatmapInfoID) + return; + } + + RefreshScores(); + } protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index dfb4b59060..418cf23ce7 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -21,7 +21,7 @@ namespace osu.Game.Screens.Select public class PlaySongSelect : SongSelect { private bool removeAutoModOnResume; - private OsuScreen player; + private OsuScreen playerLoader; [Resolved(CanBeNull = true)] private NotificationOverlay notifications { get; set; } @@ -49,7 +49,7 @@ namespace osu.Game.Screens.Select { base.OnResuming(last); - player = null; + playerLoader = null; if (removeAutoModOnResume) { @@ -79,14 +79,14 @@ namespace osu.Game.Screens.Select protected override bool OnStart() { - if (player != null) return false; + if (playerLoader != null) return false; // Ctrl+Enter should start map with autoplay enabled. if (GetContainingInputManager().CurrentState?.Keyboard.ControlPressed == true) { - var autoplayMod = getAutoplayMod(); + var autoInstance = getAutoplayMod(); - if (autoplayMod == null) + if (autoInstance == null) { notifications?.Post(new SimpleNotification { @@ -97,18 +97,26 @@ namespace osu.Game.Screens.Select var mods = Mods.Value; - if (mods.All(m => m.GetType() != autoplayMod.GetType())) + if (mods.All(m => m.GetType() != autoInstance.GetType())) { - Mods.Value = mods.Append(autoplayMod).ToArray(); + Mods.Value = mods.Append(autoInstance).ToArray(); removeAutoModOnResume = true; } } SampleConfirm?.Play(); - this.Push(player = new PlayerLoader(() => new SoloPlayer())); - + this.Push(playerLoader = new PlayerLoader(createPlayer)); return true; + + Player createPlayer() + { + var replayGeneratingMod = Mods.Value.OfType().FirstOrDefault(); + if (replayGeneratingMod != null) + return new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateReplayScore(beatmap, mods)); + + return new SoloPlayer(); + } } } } diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index 9a20bb58b8..b6eafe496f 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -15,8 +14,6 @@ using osu.Game.Database; using osu.Game.Online.Spectator; using osu.Game.Replays; using osu.Game.Rulesets; -using osu.Game.Rulesets.Replays; -using osu.Game.Rulesets.Replays.Types; using osu.Game.Scoring; using osu.Game.Users; @@ -63,16 +60,19 @@ namespace osu.Game.Screens.Spectate { base.LoadComplete(); - getAllUsers().ContinueWith(users => Schedule(() => + userLookupCache.GetUsersAsync(userIds.ToArray()).ContinueWith(users => Schedule(() => { foreach (var u in users.Result) + { + if (u == null) + continue; + userMap[u.Id] = u; + } playingUserStates.BindTo(spectatorClient.PlayingUserStates); playingUserStates.BindCollectionChanged(onPlayingUserStatesChanged, true); - spectatorClient.OnNewFrames += userSentFrames; - managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); managerUpdated.BindValueChanged(beatmapUpdated); @@ -81,24 +81,6 @@ namespace osu.Game.Screens.Spectate })); } - private Task getAllUsers() - { - var userLookupTasks = new List>(); - - foreach (var u in userIds) - { - userLookupTasks.Add(userLookupCache.GetUserAsync(u).ContinueWith(task => - { - if (!task.IsCompletedSuccessfully) - return null; - - return task.Result; - })); - } - - return Task.WhenAll(userLookupTasks); - } - private void beatmapUpdated(ValueChangedEvent> e) { if (!e.NewValue.TryGetTarget(out var beatmapSet)) @@ -197,29 +179,6 @@ namespace osu.Game.Screens.Spectate Schedule(() => StartGameplay(userId, gameplayState)); } - private void userSentFrames(int userId, FrameDataBundle bundle) - { - if (!userMap.ContainsKey(userId)) - return; - - if (!gameplayStates.TryGetValue(userId, out var gameplayState)) - return; - - // The ruleset instance should be guaranteed to be in sync with the score via ScoreLock. - Debug.Assert(gameplayState.Ruleset != null && gameplayState.Ruleset.RulesetInfo.Equals(gameplayState.Score.ScoreInfo.Ruleset)); - - foreach (var frame in bundle.Frames) - { - IConvertibleReplayFrame convertibleFrame = gameplayState.Ruleset.CreateConvertibleReplayFrame(); - convertibleFrame.FromLegacy(frame, gameplayState.Beatmap.Beatmap); - - var convertedFrame = (ReplayFrame)convertibleFrame; - convertedFrame.Time = frame.Time; - - gameplayState.Score.Replay.Frames.Add(convertedFrame); - } - } - /// /// Invoked when a spectated user's state has changed. /// @@ -260,8 +219,6 @@ namespace osu.Game.Screens.Spectate if (spectatorClient != null) { - spectatorClient.OnNewFrames -= userSentFrames; - foreach (var (userId, _) in userMap) spectatorClient.StopWatchingUser(userId); } diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index 710ed5ffc4..d3adae5c8c 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; +using osu.Game.Beatmaps.Formats; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.Screens.Play; @@ -22,6 +23,8 @@ namespace osu.Game.Skinning { public class DefaultSkin : Skin { + private readonly IStorageResourceProvider resources; + public DefaultSkin(IStorageResourceProvider resources) : this(SkinInfo.Default, resources) { @@ -31,12 +34,23 @@ namespace osu.Game.Skinning public DefaultSkin(SkinInfo skin, IStorageResourceProvider resources) : base(skin, resources) { + this.resources = resources; Configuration = new DefaultSkinConfiguration(); } public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => null; - public override ISample GetSample(ISampleInfo sampleInfo) => null; + public override ISample GetSample(ISampleInfo sampleInfo) + { + foreach (var lookup in sampleInfo.LookupNames) + { + var sample = resources.AudioManager.Samples.Get(lookup); + if (sample != null) + return sample; + } + + return null; + } public override Drawable GetDrawableComponent(ISkinComponent component) { @@ -123,10 +137,10 @@ namespace osu.Game.Skinning public override IBindable GetConfig(TLookup lookup) { + // todo: this code is pulled from LegacySkin and should not exist. + // will likely change based on how databased storage of skin configuration goes. 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) { @@ -135,9 +149,15 @@ namespace osu.Game.Skinning } break; + + case SkinComboColourLookup comboColour: + return SkinUtils.As(new Bindable(getComboColour(Configuration, comboColour.ColourIndex))); } return null; } + + private static Color4 getComboColour(IHasComboColours source, int colourIndex) + => source.ComboColours[colourIndex % source.ComboColours.Count]; } } diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs index 9cca0ba2c7..17eb88226d 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -95,8 +95,8 @@ namespace osu.Game.Skinning.Editor // scale adjust applied to each individual item should match that of the quad itself. var scaledDelta = new Vector2( - adjustedRect.Width / selectionRect.Width, - adjustedRect.Height / selectionRect.Height + MathF.Max(adjustedRect.Width / selectionRect.Width, 0), + MathF.Max(adjustedRect.Height / selectionRect.Height, 0) ); foreach (var b in SelectedBlueprints) @@ -127,6 +127,7 @@ namespace osu.Game.Skinning.Editor public override bool HandleFlip(Direction direction) { var selectionQuad = getSelectionQuad(); + Vector2 scaleFactor = direction == Direction.Horizontal ? new Vector2(-1, 1) : new Vector2(1, -1); foreach (var b in SelectedBlueprints) { @@ -136,10 +137,8 @@ namespace osu.Game.Skinning.Editor updateDrawablePosition(drawableItem, flippedPosition); - drawableItem.Scale *= new Vector2( - direction == Direction.Horizontal ? -1 : 1, - direction == Direction.Vertical ? -1 : 1 - ); + drawableItem.Scale *= scaleFactor; + drawableItem.Rotation -= drawableItem.Rotation % 180 * 2; } return true; @@ -149,13 +148,21 @@ namespace osu.Game.Skinning.Editor { foreach (var c in SelectedBlueprints) { - Drawable drawable = (Drawable)c.Item; + var item = c.Item; + Drawable drawable = (Drawable)item; + drawable.Position += drawable.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta); + + if (item.UsesFixedAnchor) continue; + + applyClosestAnchor(drawable); } return true; } + private static void applyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable)); + protected override void OnSelectionChanged() { base.OnSelectionChanged(); @@ -171,20 +178,27 @@ namespace osu.Game.Skinning.Editor protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { + var closestItem = new TernaryStateRadioMenuItem("Closest", MenuItemType.Standard, _ => applyClosestAnchors()) + { + State = { Value = GetStateFromSelection(selection, c => !c.Item.UsesFixedAnchor) } + }; + yield return new OsuMenuItem("Anchor") { - Items = createAnchorItems(d => d.Anchor, applyAnchor).ToArray() + Items = createAnchorItems((d, a) => d.UsesFixedAnchor && ((Drawable)d).Anchor == a, applyFixedAnchors) + .Prepend(closestItem) + .ToArray() }; yield return new OsuMenuItem("Origin") { - Items = createAnchorItems(d => d.Origin, applyOrigin).ToArray() + Items = createAnchorItems((d, o) => ((Drawable)d).Origin == o, applyOrigins).ToArray() }; foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; - IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) + IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) { var displayableAnchors = new[] { @@ -198,12 +212,11 @@ namespace osu.Game.Skinning.Editor Anchor.BottomCentre, Anchor.BottomRight, }; - return displayableAnchors.Select(a => { return new TernaryStateRadioMenuItem(a.ToString(), MenuItemType.Standard, _ => applyFunction(a)) { - State = { Value = GetStateFromSelection(selection, c => checkFunction((Drawable)c.Item) == a) } + State = { Value = GetStateFromSelection(selection, c => checkFunction(c.Item, a)) } }; }); } @@ -215,15 +228,21 @@ namespace osu.Game.Skinning.Editor drawable.Parent.ToLocalSpace(screenSpacePosition) - drawable.AnchorPosition; } - private void applyOrigin(Anchor anchor) + private void applyOrigins(Anchor origin) { foreach (var item in SelectedItems) { var drawable = (Drawable)item; + if (origin == drawable.Origin) continue; + var previousOrigin = drawable.OriginPosition; - drawable.Origin = anchor; + drawable.Origin = origin; drawable.Position += drawable.OriginPosition - previousOrigin; + + if (item.UsesFixedAnchor) continue; + + applyClosestAnchor(drawable); } } @@ -234,18 +253,86 @@ namespace osu.Game.Skinning.Editor private Quad getSelectionQuad() => GetSurroundingQuad(SelectedBlueprints.SelectMany(b => b.Item.ScreenSpaceDrawQuad.GetVertices().ToArray())); - private void applyAnchor(Anchor anchor) + private void applyFixedAnchors(Anchor anchor) { foreach (var item in SelectedItems) { var drawable = (Drawable)item; - var previousAnchor = drawable.AnchorPosition; - drawable.Anchor = anchor; - drawable.Position -= drawable.AnchorPosition - previousAnchor; + item.UsesFixedAnchor = true; + applyAnchor(drawable, anchor); } } + private void applyClosestAnchors() + { + foreach (var item in SelectedItems) + { + item.UsesFixedAnchor = false; + applyClosestAnchor((Drawable)item); + } + } + + private static Anchor getClosestAnchor(Drawable drawable) + { + var parent = drawable.Parent; + + if (parent == null) + return drawable.Anchor; + + var screenPosition = getScreenPosition(); + + var absolutePosition = parent.ToLocalSpace(screenPosition); + var factor = parent.RelativeToAbsoluteFactor; + + var result = default(Anchor); + + static Anchor getAnchorFromPosition(float xOrY, Anchor anchor0, Anchor anchor1, Anchor anchor2) + { + if (xOrY >= 2 / 3f) + return anchor2; + + if (xOrY >= 1 / 3f) + return anchor1; + + return anchor0; + } + + result |= getAnchorFromPosition(absolutePosition.X / factor.X, Anchor.x0, Anchor.x1, Anchor.x2); + result |= getAnchorFromPosition(absolutePosition.Y / factor.Y, Anchor.y0, Anchor.y1, Anchor.y2); + + return result; + + Vector2 getScreenPosition() + { + var quad = drawable.ScreenSpaceDrawQuad; + var origin = drawable.Origin; + + var pos = quad.TopLeft; + + if (origin.HasFlagFast(Anchor.x2)) + pos.X += quad.Width; + else if (origin.HasFlagFast(Anchor.x1)) + pos.X += quad.Width / 2f; + + if (origin.HasFlagFast(Anchor.y2)) + pos.Y += quad.Height; + else if (origin.HasFlagFast(Anchor.y1)) + pos.Y += quad.Height / 2f; + + return pos; + } + } + + private static void applyAnchor(Drawable drawable, Anchor anchor) + { + if (anchor == drawable.Anchor) return; + + var previousAnchor = drawable.AnchorPosition; + drawable.Anchor = anchor; + drawable.Position -= drawable.AnchorPosition - previousAnchor; + } + private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference) { // cancel out scale in axes we don't care about (based on which drag handle was used). diff --git a/osu.Game/Skinning/ISkinSource.cs b/osu.Game/Skinning/ISkinSource.cs index 337d2a87a4..ba3e2bf6ad 100644 --- a/osu.Game/Skinning/ISkinSource.cs +++ b/osu.Game/Skinning/ISkinSource.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using JetBrains.Annotations; namespace osu.Game.Skinning { @@ -11,5 +13,18 @@ namespace osu.Game.Skinning public interface ISkinSource : ISkin { event Action SourceChanged; + + /// + /// Find the first (if any) skin that can fulfill the lookup. + /// This should be used for cases where subsequent lookups (for related components) need to occur on the same skin. + /// + /// The skin to be used for subsequent lookups, or null if none is available. + [CanBeNull] + ISkin FindProvider(Func lookupFunction); + + /// + /// Retrieve all sources available for lookup, with highest priority source first. + /// + IEnumerable AllSources { get; } } } diff --git a/osu.Game/Skinning/ISkinnableDrawable.cs b/osu.Game/Skinning/ISkinnableDrawable.cs index d42b6f71b0..60b40982e5 100644 --- a/osu.Game/Skinning/ISkinnableDrawable.cs +++ b/osu.Game/Skinning/ISkinnableDrawable.cs @@ -14,5 +14,12 @@ namespace osu.Game.Skinning /// Whether this component should be editable by an end user. /// bool IsEditable => true; + + /// + /// In the context of the skin layout editor, whether this has a permanent anchor defined. + /// If , this 's is automatically determined by proximity, + /// If , a fixed anchor point has been defined. + /// + bool UsesFixedAnchor { get; set; } } } diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs index 16562d9571..fd5a9500d9 100644 --- a/osu.Game/Skinning/LegacyAccuracyCounter.cs +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -12,6 +12,8 @@ namespace osu.Game.Skinning { public class LegacyAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable { + public bool UsesFixedAnchor { get; set; } + public LegacyAccuracyCounter() { Anchor = Anchor.TopRight; diff --git a/osu.Game/Skinning/LegacyColourCompatibility.cs b/osu.Game/Skinning/LegacyColourCompatibility.cs index b842b50426..38e43432ce 100644 --- a/osu.Game/Skinning/LegacyColourCompatibility.cs +++ b/osu.Game/Skinning/LegacyColourCompatibility.cs @@ -7,7 +7,7 @@ using osuTK.Graphics; namespace osu.Game.Skinning { /// - /// Compatibility methods to convert osu!stable colours to osu!lazer-compatible ones. Should be used for legacy skins only. + /// Compatibility methods to apply osu!stable quirks to colours. Should be used for legacy skins only. /// public static class LegacyColourCompatibility { diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs index c601adc3a0..67280e4acd 100644 --- a/osu.Game/Skinning/LegacyHealthDisplay.cs +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -20,9 +20,6 @@ namespace osu.Game.Skinning { private const double epic_cutoff = 0.5; - [Resolved] - private ISkinSource skin { get; set; } - private LegacyHealthPiece fill; private LegacyHealthPiece marker; @@ -30,11 +27,16 @@ namespace osu.Game.Skinning private bool isNewStyle; + public bool UsesFixedAnchor { get; set; } + [BackgroundDependencyLoader] - private void load() + private void load(ISkinSource source) { AutoSizeAxes = Axes.Both; + var skin = source.FindProvider(s => getTexture(s, "bg") != null); + + // the marker lookup to decide which display style must be performed on the source of the bg, which is the most common element. isNewStyle = getTexture(skin, "marker") != null; // background implementation is the same for both versions. @@ -76,7 +78,7 @@ namespace osu.Game.Skinning protected override void Flash(JudgementResult result) => marker.Flash(result); - private static Texture getTexture(ISkinSource skin, string name) => skin.GetTexture($"scorebar-{name}"); + private static Texture getTexture(ISkin skin, string name) => skin?.GetTexture($"scorebar-{name}"); private static Color4 getFillColour(double hp) { @@ -95,7 +97,7 @@ namespace osu.Game.Skinning private readonly Texture dangerTexture; private readonly Texture superDangerTexture; - public LegacyOldStyleMarker(ISkinSource skin) + public LegacyOldStyleMarker(ISkin skin) { normalTexture = getTexture(skin, "ki"); dangerTexture = getTexture(skin, "kidanger"); @@ -126,9 +128,9 @@ namespace osu.Game.Skinning public class LegacyNewStyleMarker : LegacyMarker { - private readonly ISkinSource skin; + private readonly ISkin skin; - public LegacyNewStyleMarker(ISkinSource skin) + public LegacyNewStyleMarker(ISkin skin) { this.skin = skin; } @@ -148,9 +150,9 @@ namespace osu.Game.Skinning } } - internal class LegacyOldStyleFill : LegacyHealthPiece + internal abstract class LegacyFill : LegacyHealthPiece { - public LegacyOldStyleFill(ISkinSource skin) + protected LegacyFill(ISkin skin) { // required for sizing correctly.. var firstFrame = getTexture(skin, "colour-0"); @@ -162,27 +164,29 @@ namespace osu.Game.Skinning } else { - InternalChild = skin.GetAnimation("scorebar-colour", true, true, startAtCurrentTime: false, applyConfigFrameRate: true) ?? Drawable.Empty(); + InternalChild = skin.GetAnimation("scorebar-colour", true, true, startAtCurrentTime: false, applyConfigFrameRate: true) ?? Empty(); Size = new Vector2(firstFrame.DisplayWidth, firstFrame.DisplayHeight); } - Position = new Vector2(3, 10) * 1.6f; Masking = true; } } - internal class LegacyNewStyleFill : LegacyHealthPiece + internal class LegacyOldStyleFill : LegacyFill { - public LegacyNewStyleFill(ISkinSource skin) + public LegacyOldStyleFill(ISkin skin) + : base(skin) { - InternalChild = new Sprite - { - Texture = getTexture(skin, "colour"), - }; + Position = new Vector2(3, 10) * 1.6f; + } + } - Size = InternalChild.Size; + internal class LegacyNewStyleFill : LegacyFill + { + public LegacyNewStyleFill(ISkin skin) + : base(skin) + { Position = new Vector2(7.5f, 7.8f) * 1.6f; - Masking = true; } protected override void Update() diff --git a/osu.Game/Skinning/LegacyJudgementPieceNew.cs b/osu.Game/Skinning/LegacyJudgementPieceNew.cs index ca25efaa01..e76f251ce5 100644 --- a/osu.Game/Skinning/LegacyJudgementPieceNew.cs +++ b/osu.Game/Skinning/LegacyJudgementPieceNew.cs @@ -72,7 +72,7 @@ namespace osu.Game.Skinning if (particles != null) { // start the particles already some way into their animation to break cluster away from centre. - using (particles.BeginDelayedSequence(-100, true)) + using (particles.BeginDelayedSequence(-100)) particles.Restart(); } diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs index 64ea03d59c..a12defe87e 100644 --- a/osu.Game/Skinning/LegacyScoreCounter.cs +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -13,6 +13,8 @@ namespace osu.Game.Skinning protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; + public bool UsesFixedAnchor { get; set; } + public LegacyScoreCounter() : base(6) { diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 20d86ca25d..b09620411b 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -16,6 +16,7 @@ using osu.Framework.IO.Stores; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.IO; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; @@ -60,10 +61,17 @@ namespace osu.Game.Skinning { } - protected LegacySkin(SkinInfo skin, [CanBeNull] IResourceStore storage, [CanBeNull] IStorageResourceProvider resources, string filename) + /// + /// Construct a new legacy skin instance. + /// + /// The model for this skin. + /// A storage for looking up files within this skin using user-facing filenames. + /// Access to raw game resources. + /// The user-facing filename of the configuration file to be parsed. Can accept an .osu or skin.ini file. + protected LegacySkin(SkinInfo skin, [CanBeNull] IResourceStore storage, [CanBeNull] IStorageResourceProvider resources, string configurationFilename) : base(skin, resources) { - using (var stream = storage?.GetStream(filename)) + using (var stream = storage?.GetStream(configurationFilename)) { if (stream != null) { @@ -122,12 +130,15 @@ namespace osu.Game.Skinning break; + case SkinComboColourLookup comboColour: + return SkinUtils.As(GetComboColour(Configuration, comboColour.ColourIndex, comboColour.Combo)); + case SkinCustomColourLookup customColour: return SkinUtils.As(getCustomColour(Configuration, customColour.Lookup.ToString())); case LegacyManiaSkinConfigurationLookup maniaLookup: if (!AllowManiaSkin) - return null; + break; var result = lookupForMania(maniaLookup); if (result != null) @@ -279,6 +290,18 @@ namespace osu.Game.Skinning return null; } + /// + /// Retrieves the correct combo colour for a given colour index and information on the combo. + /// + /// The source to retrieve the combo colours from. + /// The preferred index for retrieving the combo colour with. + /// Information on the combo whose using the returned colour. + protected virtual IBindable GetComboColour(IHasComboColours source, int colourIndex, IHasComboInformation combo) + { + var colour = source.ComboColours?[colourIndex % source.ComboColours.Count]; + return colour.HasValue ? new Bindable(colour.Value) : null; + } + private IBindable getCustomColour(IHasCustomColours source, string lookup) => source.CustomColours.TryGetValue(lookup, out var col) ? new Bindable(col) : null; @@ -390,6 +413,7 @@ namespace osu.Game.Skinning return null; case GameplaySkinComponent resultComponent: + // TODO: this should be inside the judgement pieces. Func createDrawable = () => getJudgementAnimation(resultComponent.Component); // kind of wasteful that we throw this away, but should do for now. @@ -485,7 +509,9 @@ namespace osu.Game.Skinning var sample = Samples?.Get(lookup); if (sample != null) + { return sample; + } } return null; @@ -518,8 +544,7 @@ namespace osu.Game.Skinning 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; + yield return componentName.Split('/').Last(); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index d8fb1fa664..ec25268be4 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -27,6 +27,18 @@ namespace osu.Game.Skinning { Texture texture; + // find the first source which provides either the animated or non-animated version. + ISkin skin = (source as ISkinSource)?.FindProvider(s => + { + if (animatable && s.GetTexture(getFrameName(0)) != null) + return true; + + return s.GetTexture(componentName, wrapModeS, wrapModeT) != null; + }) ?? source; + + if (skin == null) + return null; + if (animatable) { var textures = getTextures().ToArray(); @@ -35,7 +47,7 @@ namespace osu.Game.Skinning { var animation = new SkinnableTextureAnimation(startAtCurrentTime) { - DefaultFrameLength = frameLength ?? getFrameLength(source, applyConfigFrameRate, textures), + DefaultFrameLength = frameLength ?? getFrameLength(skin, applyConfigFrameRate, textures), Loop = looping, }; @@ -47,7 +59,7 @@ namespace osu.Game.Skinning } // if an animation was not allowed or not found, fall back to a sprite retrieval. - if ((texture = source.GetTexture(componentName, wrapModeS, wrapModeT)) != null) + if ((texture = skin.GetTexture(componentName, wrapModeS, wrapModeT)) != null) return new Sprite { Texture = texture }; return null; @@ -56,12 +68,14 @@ namespace osu.Game.Skinning { for (int i = 0; true; i++) { - if ((texture = source.GetTexture($"{componentName}{animationSeparator}{i}", wrapModeS, wrapModeT)) == null) + if ((texture = skin.GetTexture(getFrameName(i), wrapModeS, wrapModeT)) == null) break; yield return texture; } } + + string getFrameName(int frameIndex) => $"{componentName}{animationSeparator}{frameIndex}"; } public static bool HasFont(this ISkin source, LegacyFont font) diff --git a/osu.Game/Skinning/LegacySkinTransformer.cs b/osu.Game/Skinning/LegacySkinTransformer.cs index ae8faf1a3b..92b7a04dee 100644 --- a/osu.Game/Skinning/LegacySkinTransformer.cs +++ b/osu.Game/Skinning/LegacySkinTransformer.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -18,34 +20,35 @@ namespace osu.Game.Skinning public abstract class LegacySkinTransformer : ISkin { /// - /// Source of the which is being transformed. + /// The which is being transformed. /// - protected ISkinSource Source { get; } + [NotNull] + protected ISkin Skin { get; } - protected LegacySkinTransformer(ISkinSource source) + protected LegacySkinTransformer([NotNull] ISkin skin) { - Source = source; + Skin = skin ?? throw new ArgumentNullException(nameof(skin)); } - public abstract Drawable GetDrawableComponent(ISkinComponent component); + public virtual Drawable GetDrawableComponent(ISkinComponent component) => Skin.GetDrawableComponent(component); public Texture GetTexture(string componentName) => GetTexture(componentName, default, default); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) - => Source.GetTexture(componentName, wrapModeS, wrapModeT); + => Skin.GetTexture(componentName, wrapModeS, wrapModeT); public virtual ISample GetSample(ISampleInfo sampleInfo) { if (!(sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacySample)) - return Source.GetSample(sampleInfo); + return Skin.GetSample(sampleInfo); var playLayeredHitSounds = GetConfig(LegacySetting.LayeredHitSounds); if (legacySample.IsLayered && playLayeredHitSounds?.Value == false) return new SampleVirtual(); - return Source.GetSample(sampleInfo); + return Skin.GetSample(sampleInfo); } - public abstract IBindable GetConfig(TLookup lookup); + public virtual IBindable GetConfig(TLookup lookup) => Skin.GetConfig(lookup); } } diff --git a/osu.Game/Skinning/PoolableSkinnableSample.cs b/osu.Game/Skinning/PoolableSkinnableSample.cs index b04158a58f..3fcca74fb8 100644 --- a/osu.Game/Skinning/PoolableSkinnableSample.cs +++ b/osu.Game/Skinning/PoolableSkinnableSample.cs @@ -2,6 +2,7 @@ // 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; @@ -70,33 +71,50 @@ namespace osu.Game.Skinning updateSample(); } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void LoadComplete() { - base.SkinChanged(skin, allowFallback); + base.LoadComplete(); + + CurrentSkin.SourceChanged += skinChangedImmediate; + } + + private void skinChangedImmediate() + { + // Clean up the previous sample immediately on a source change. + // This avoids a potential call to Play() of an already disposed sample (samples are disposed along with the skin, but SkinChanged is scheduled). + clearPreviousSamples(); + } + + protected override void SkinChanged(ISkinSource skin) + { + base.SkinChanged(skin); updateSample(); } + /// + /// Whether this sample was playing before a skin source change. + /// + private bool wasPlaying; + + private void clearPreviousSamples() + { + // only run if the samples aren't already cleared. + // this ensures the "wasPlaying" state is stored correctly even if multiple clear calls are executed. + if (!sampleContainer.Any()) return; + + wasPlaying = Playing; + + sampleContainer.Clear(); + Sample = null; + } + private void updateSample() { if (sampleInfo == null) return; - bool wasPlaying = Playing; - - sampleContainer.Clear(); - Sample = null; - var sample = CurrentSkin.GetSample(sampleInfo); - if (sample == null && AllowDefaultFallback) - { - foreach (var lookup in sampleInfo.LookupNames) - { - if ((sample = sampleStore.Get(lookup)) != null) - break; - } - } - if (sample == null) return; @@ -155,6 +173,14 @@ namespace osu.Game.Skinning } } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (CurrentSkin != null) + CurrentSkin.SourceChanged -= skinChangedImmediate; + } + #region Re-expose AudioContainer public BindableNumber Volume => sampleContainer.Volume; diff --git a/osu.Game/Skinning/ResourceStoreBackedSkin.cs b/osu.Game/Skinning/ResourceStoreBackedSkin.cs new file mode 100644 index 0000000000..f041b82cf4 --- /dev/null +++ b/osu.Game/Skinning/ResourceStoreBackedSkin.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. + +#nullable enable + +using System; +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.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Platform; +using osu.Game.Audio; + +namespace osu.Game.Skinning +{ + /// + /// An that uses an underlying with namespaces for resources retrieval. + /// + public class ResourceStoreBackedSkin : ISkin, IDisposable + { + private readonly TextureStore textures; + private readonly ISampleStore samples; + + public ResourceStoreBackedSkin(IResourceStore resources, GameHost host, AudioManager audio) + { + textures = new TextureStore(host.CreateTextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); + samples = audio.GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); + } + + public Drawable? GetDrawableComponent(ISkinComponent component) => null; + + public Texture? GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => textures.Get(componentName, wrapModeS, wrapModeT); + + public ISample? GetSample(ISampleInfo sampleInfo) + { + foreach (var lookup in sampleInfo.LookupNames) + { + ISample? sample = samples.Get(lookup); + if (sample != null) + return sample; + } + + return null; + } + + public IBindable? GetConfig(TLookup lookup) => null; + + public void Dispose() + { + textures.Dispose(); + samples.Dispose(); + } + } +} diff --git a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs new file mode 100644 index 0000000000..f5a7788359 --- /dev/null +++ b/osu.Game/Skinning/RulesetSkinProvidingContainer.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.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.IO.Stores; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Skinning +{ + /// + /// A type of specialized for and other gameplay-related components. + /// Providing access to parent skin sources and the beatmap skin each surrounded with the ruleset legacy skin transformer. + /// + public class RulesetSkinProvidingContainer : SkinProvidingContainer + { + protected readonly Ruleset Ruleset; + protected readonly IBeatmap Beatmap; + + /// + /// This container already re-exposes all parent sources in a ruleset-usable form. + /// Therefore disallow falling back to any parent any further. + /// + protected override bool AllowFallingBackToParent => false; + + protected override Container Content { get; } + + public RulesetSkinProvidingContainer(Ruleset ruleset, IBeatmap beatmap, [CanBeNull] ISkin beatmapSkin) + { + Ruleset = ruleset; + Beatmap = beatmap; + + InternalChild = new BeatmapSkinProvidingContainer(beatmapSkin is LegacySkin ? GetLegacyRulesetTransformedSkin(beatmapSkin) : beatmapSkin) + { + Child = Content = new Container + { + RelativeSizeAxes = Axes.Both, + } + }; + } + + private ResourceStoreBackedSkin rulesetResourcesSkin; + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + if (Ruleset.CreateResourceStore() is IResourceStore resources) + rulesetResourcesSkin = new ResourceStoreBackedSkin(resources, parent.Get(), parent.Get()); + + return base.CreateChildDependencies(parent); + } + + protected override void OnSourceChanged() + { + ResetSources(); + + // Populate a local list first so we can adjust the returned order as we go. + var sources = new List(); + + Debug.Assert(ParentSource != null); + + foreach (var skin in ParentSource.AllSources) + { + switch (skin) + { + case LegacySkin legacySkin: + sources.Add(GetLegacyRulesetTransformedSkin(legacySkin)); + break; + + default: + sources.Add(skin); + break; + } + } + + int lastDefaultSkinIndex = sources.IndexOf(sources.OfType().LastOrDefault()); + + // Ruleset resources should be given the ability to override game-wide defaults + // This is achieved by placing them before the last instance of DefaultSkin. + // Note that DefaultSkin may not be present in some test scenes. + if (lastDefaultSkinIndex >= 0) + sources.Insert(lastDefaultSkinIndex, rulesetResourcesSkin); + else + sources.Add(rulesetResourcesSkin); + + foreach (var skin in sources) + AddSource(skin); + } + + protected ISkin GetLegacyRulesetTransformedSkin(ISkin legacySkin) + { + if (legacySkin == null) + return null; + + var rulesetTransformed = Ruleset.CreateLegacySkinProvider(legacySkin, Beatmap); + if (rulesetTransformed != null) + return rulesetTransformed; + + return legacySkin; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + rulesetResourcesSkin?.Dispose(); + } + } +} diff --git a/osu.Game/Skinning/SkinComboColourLookup.cs b/osu.Game/Skinning/SkinComboColourLookup.cs new file mode 100644 index 0000000000..33e35a96fb --- /dev/null +++ b/osu.Game/Skinning/SkinComboColourLookup.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.Objects.Types; + +namespace osu.Game.Skinning +{ + public class SkinComboColourLookup + { + /// + /// The index to use for deciding the combo colour. + /// + public readonly int ColourIndex; + + /// + /// The combo information requesting the colour. + /// + public readonly IHasComboInformation Combo; + + public SkinComboColourLookup(int colourIndex, IHasComboInformation combo) + { + ColourIndex = colourIndex; + Combo = combo; + } + } +} diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index e30bc16d8b..851d71f914 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -46,7 +46,7 @@ namespace osu.Game.Skinning public static SkinInfo Default { get; } = new SkinInfo { ID = DEFAULT_SKIN, - Name = "osu!lazer", + Name = "osu! (triangles)", Creator = "team osu!", InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() }; diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 079c537066..ea55fd28c2 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -30,6 +30,13 @@ using osu.Game.IO.Archives; namespace osu.Game.Skinning { + /// + /// Handles the storage and retrieval of s. + /// + /// + /// This is also exposed and cached as to allow for any component to potentially have skinning support. + /// For gameplay components, see which adds extra legacy and toggle logic that may affect the lookup process. + /// [ExcludeFromDynamicCompile] public class SkinManager : ArchiveModelManager, ISkinSource, IStorageResourceProvider { @@ -39,7 +46,7 @@ namespace osu.Game.Skinning private readonly IResourceStore resources; - public readonly Bindable CurrentSkin = new Bindable(new DefaultSkin(null)); + public readonly Bindable CurrentSkin = new Bindable(); public readonly Bindable CurrentSkinInfo = new Bindable(SkinInfo.Default) { Default = SkinInfo.Default }; public override IEnumerable HandledExtensions => new[] { ".osk" }; @@ -48,6 +55,16 @@ namespace osu.Game.Skinning protected override string ImportFromStablePath => "Skins"; + /// + /// The default skin. + /// + public Skin DefaultSkin { get; } + + /// + /// The default legacy skin. + /// + public Skin DefaultLegacySkin { get; } + public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, IResourceStore resources, AudioManager audio) : base(storage, contextFactory, new SkinStore(contextFactory, storage), host) { @@ -55,7 +72,12 @@ namespace osu.Game.Skinning this.host = host; this.resources = resources; + DefaultLegacySkin = new DefaultLegacySkin(this); + DefaultSkin = new DefaultSkin(this); + CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue); + + CurrentSkin.Value = DefaultSkin; CurrentSkin.ValueChanged += skin => { if (skin.NewValue.SkinInfo != CurrentSkinInfo.Value) @@ -74,8 +96,8 @@ namespace osu.Game.Skinning public List GetAllUsableSkins() { var userSkins = GetAllUserSkins(); - userSkins.Insert(0, SkinInfo.Default); - userSkins.Insert(1, DefaultLegacySkin.Info); + userSkins.Insert(0, DefaultSkin.SkinInfo); + userSkins.Insert(1, DefaultLegacySkin.SkinInfo); return userSkins; } @@ -103,6 +125,8 @@ namespace osu.Game.Skinning private const string unknown_creator_string = "Unknown"; + protected override bool HasCustomHashFunction => true; + protected override string ComputeHash(SkinInfo item, ArchiveReader reader = null) { // we need to populate early to create a hash based off skin.ini contents @@ -120,16 +144,16 @@ namespace osu.Game.Skinning return base.ComputeHash(item, reader); } - protected override async Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) + protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) { - await base.Populate(model, archive, cancellationToken).ConfigureAwait(false); - var instance = GetSkin(model); model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo(); if (model.Name?.Contains(".osk", StringComparison.OrdinalIgnoreCase) == true) populateMetadata(model, instance); + + return Task.CompletedTask; } private void populateMetadata(SkinInfo item, Skin instance) @@ -204,13 +228,50 @@ namespace osu.Game.Skinning public event Action SourceChanged; - public Drawable GetDrawableComponent(ISkinComponent component) => CurrentSkin.Value.GetDrawableComponent(component); + public Drawable GetDrawableComponent(ISkinComponent component) => lookupWithFallback(s => s.GetDrawableComponent(component)); - public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => CurrentSkin.Value.GetTexture(componentName, wrapModeS, wrapModeT); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => lookupWithFallback(s => s.GetTexture(componentName, wrapModeS, wrapModeT)); - public ISample GetSample(ISampleInfo sampleInfo) => CurrentSkin.Value.GetSample(sampleInfo); + public ISample GetSample(ISampleInfo sampleInfo) => lookupWithFallback(s => s.GetSample(sampleInfo)); - public IBindable GetConfig(TLookup lookup) => CurrentSkin.Value.GetConfig(lookup); + public IBindable GetConfig(TLookup lookup) => lookupWithFallback(s => s.GetConfig(lookup)); + + public ISkin FindProvider(Func lookupFunction) + { + foreach (var source in AllSources) + { + if (lookupFunction(source)) + return source; + } + + return null; + } + + public IEnumerable AllSources + { + get + { + yield return CurrentSkin.Value; + + if (CurrentSkin.Value is LegacySkin && CurrentSkin.Value != DefaultLegacySkin) + yield return DefaultLegacySkin; + + if (CurrentSkin.Value != DefaultSkin) + yield return DefaultSkin; + } + } + + private T lookupWithFallback(Func lookupFunction) + where T : class + { + foreach (var source in AllSources) + { + if (lookupFunction(source) is T skinSourced) + return skinSourced; + } + + return null; + } #region IResourceStorageProvider diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index cf22b2e820..ada6e4b788 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; @@ -20,9 +22,13 @@ namespace osu.Game.Skinning { public event Action SourceChanged; - private readonly ISkin skin; + [CanBeNull] + protected ISkinSource ParentSource { get; private set; } - private ISkinSource fallbackSource; + /// + /// Whether falling back to parent s is allowed in this container. + /// + protected virtual bool AllowFallingBackToParent => true; protected virtual bool AllowDrawableLookup(ISkinComponent component) => true; @@ -34,80 +40,181 @@ namespace osu.Game.Skinning protected virtual bool AllowColourLookup => true; - public SkinProvidingContainer(ISkin skin) - { - this.skin = skin; + /// + /// A dictionary mapping each source to a wrapper which handles lookup allowances. + /// + private readonly List<(ISkin skin, DisableableSkinSource wrapped)> skinSources = new List<(ISkin, DisableableSkinSource)>(); - 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 ISample GetSample(ISampleInfo sampleInfo) - { - ISample sourceChannel; - if (AllowSampleLookup(sampleInfo) && (sourceChannel = skin?.GetSample(sampleInfo)) != null) - return sourceChannel; - - return fallbackSource?.GetSample(sampleInfo); - } - - public IBindable GetConfig(TLookup lookup) + /// + /// Constructs a new initialised with a single skin source. + /// + public SkinProvidingContainer([CanBeNull] ISkin skin) + : this() { if (skin != null) - { - if (lookup is GlobalSkinColours || lookup is SkinCustomColourLookup) - return lookupWithFallback(lookup, AllowColourLookup); - - return lookupWithFallback(lookup, AllowConfigurationLookup); - } - - return fallbackSource?.GetConfig(lookup); + AddSource(skin); } - private IBindable lookupWithFallback(TLookup lookup, bool canUseSkinLookup) + /// + /// Constructs a new with no sources. + /// + protected SkinProvidingContainer() { - if (canUseSkinLookup) - { - var bindable = skin.GetConfig(lookup); - if (bindable != null) - return bindable; - } - - return fallbackSource?.GetConfig(lookup); + RelativeSizeAxes = Axes.Both; } - protected virtual void TriggerSourceChanged() => SourceChanged?.Invoke(); - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - fallbackSource = dependencies.Get(); - if (fallbackSource != null) - fallbackSource.SourceChanged += TriggerSourceChanged; + ParentSource = dependencies.Get(); + if (ParentSource != null) + ParentSource.SourceChanged += TriggerSourceChanged; dependencies.CacheAs(this); + TriggerSourceChanged(); + return dependencies; } + public ISkin FindProvider(Func lookupFunction) + { + foreach (var (skin, lookupWrapper) in skinSources) + { + if (lookupFunction(lookupWrapper)) + return skin; + } + + if (!AllowFallingBackToParent) + return null; + + return ParentSource?.FindProvider(lookupFunction); + } + + public IEnumerable AllSources + { + get + { + foreach (var i in skinSources) + yield return i.skin; + + if (AllowFallingBackToParent && ParentSource != null) + { + foreach (var skin in ParentSource.AllSources) + yield return skin; + } + } + } + + public Drawable GetDrawableComponent(ISkinComponent component) + { + foreach (var (_, lookupWrapper) in skinSources) + { + Drawable sourceDrawable; + if ((sourceDrawable = lookupWrapper.GetDrawableComponent(component)) != null) + return sourceDrawable; + } + + if (!AllowFallingBackToParent) + return null; + + return ParentSource?.GetDrawableComponent(component); + } + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + foreach (var (_, lookupWrapper) in skinSources) + { + Texture sourceTexture; + if ((sourceTexture = lookupWrapper.GetTexture(componentName, wrapModeS, wrapModeT)) != null) + return sourceTexture; + } + + if (!AllowFallingBackToParent) + return null; + + return ParentSource?.GetTexture(componentName, wrapModeS, wrapModeT); + } + + public ISample GetSample(ISampleInfo sampleInfo) + { + foreach (var (_, lookupWrapper) in skinSources) + { + ISample sourceSample; + if ((sourceSample = lookupWrapper.GetSample(sampleInfo)) != null) + return sourceSample; + } + + if (!AllowFallingBackToParent) + return null; + + return ParentSource?.GetSample(sampleInfo); + } + + public IBindable GetConfig(TLookup lookup) + { + foreach (var (_, lookupWrapper) in skinSources) + { + IBindable bindable; + if ((bindable = lookupWrapper.GetConfig(lookup)) != null) + return bindable; + } + + if (!AllowFallingBackToParent) + return null; + + return ParentSource?.GetConfig(lookup); + } + + /// + /// Add a new skin to this provider. Will be added to the end of the lookup order precedence. + /// + /// The skin to add. + protected void AddSource(ISkin skin) + { + skinSources.Add((skin, new DisableableSkinSource(skin, this))); + + if (skin is ISkinSource source) + source.SourceChanged += TriggerSourceChanged; + } + + /// + /// Remove a skin from this provider. + /// + /// The skin to remove. + protected void RemoveSource(ISkin skin) + { + if (skinSources.RemoveAll(s => s.skin == skin) == 0) + return; + + if (skin is ISkinSource source) + source.SourceChanged -= TriggerSourceChanged; + } + + /// + /// Clears all skin sources. + /// + protected void ResetSources() + { + foreach (var i in skinSources.ToArray()) + RemoveSource(i.skin); + } + + /// + /// Invoked when any source has changed (either or a source registered via ). + /// This is also invoked once initially during to ensure sources are ready for children consumption. + /// + protected virtual void OnSourceChanged() { } + + protected void TriggerSourceChanged() + { + // Expose to implementations, giving them a chance to react before notifying external consumers. + OnSourceChanged(); + + SourceChanged?.Invoke(); + } + protected override void Dispose(bool isDisposing) { // Must be done before base.Dispose() @@ -115,8 +222,72 @@ namespace osu.Game.Skinning base.Dispose(isDisposing); - if (fallbackSource != null) - fallbackSource.SourceChanged -= TriggerSourceChanged; + if (ParentSource != null) + ParentSource.SourceChanged -= TriggerSourceChanged; + + foreach (var i in skinSources) + { + if (i.skin is ISkinSource source) + source.SourceChanged -= TriggerSourceChanged; + } + } + + private class DisableableSkinSource : ISkin + { + private readonly ISkin skin; + private readonly SkinProvidingContainer provider; + + public DisableableSkinSource(ISkin skin, SkinProvidingContainer provider) + { + this.skin = skin; + this.provider = provider; + } + + public Drawable GetDrawableComponent(ISkinComponent component) + { + if (provider.AllowDrawableLookup(component)) + return skin.GetDrawableComponent(component); + + return null; + } + + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) + { + if (provider.AllowTextureLookup(componentName)) + return skin.GetTexture(componentName, wrapModeS, wrapModeT); + + return null; + } + + public ISample GetSample(ISampleInfo sampleInfo) + { + if (provider.AllowSampleLookup(sampleInfo)) + return skin.GetSample(sampleInfo); + + return null; + } + + public IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case GlobalSkinColours _: + case SkinComboColourLookup _: + case SkinCustomColourLookup _: + if (provider.AllowColourLookup) + return skin.GetConfig(lookup); + + break; + + default: + if (provider.AllowConfigurationLookup) + return skin.GetConfig(lookup); + + break; + } + + return null; + } } } } diff --git a/osu.Game/Skinning/SkinReloadableDrawable.cs b/osu.Game/Skinning/SkinReloadableDrawable.cs index 50b4143375..dec546b82d 100644 --- a/osu.Game/Skinning/SkinReloadableDrawable.cs +++ b/osu.Game/Skinning/SkinReloadableDrawable.cs @@ -22,22 +22,6 @@ namespace osu.Game.Skinning /// protected ISkinSource CurrentSkin { get; private set; } - private readonly Func allowFallback; - - /// - /// Whether fallback to default skin should be allowed if the custom skin is missing this resource. - /// - protected bool AllowDefaultFallback => allowFallback == null || allowFallback.Invoke(CurrentSkin); - - /// - /// Create a new - /// - /// A conditional to decide whether to allow fallback to the default implementation if a skinned element is not present. - protected SkinReloadableDrawable(Func allowFallback = null) - { - this.allowFallback = allowFallback; - } - [BackgroundDependencyLoader] private void load(ISkinSource source) { @@ -58,7 +42,7 @@ namespace osu.Game.Skinning private void skinChanged() { - SkinChanged(CurrentSkin, AllowDefaultFallback); + SkinChanged(CurrentSkin); OnSkinChanged?.Invoke(); } @@ -66,8 +50,7 @@ namespace osu.Game.Skinning /// Called when a change is made to the skin. /// /// The new skin. - /// Whether fallback to default skin should be allowed if the custom skin is missing this resource. - protected virtual void SkinChanged(ISkinSource skin, bool allowFallback) + protected virtual void SkinChanged(ISkinSource skin) { } diff --git a/osu.Game/Skinning/SkinnableDrawable.cs b/osu.Game/Skinning/SkinnableDrawable.cs index fc2730ca44..72f64e2e12 100644 --- a/osu.Game/Skinning/SkinnableDrawable.cs +++ b/osu.Game/Skinning/SkinnableDrawable.cs @@ -40,17 +40,14 @@ namespace osu.Game.Skinning /// /// The namespace-complete resource name for this skinnable element. /// A function to create the default skin implementation of this element. - /// A conditional to decide whether to allow fallback to the default implementation if a skinned element is not present. /// How (if at all) the should be resize to fit within our own bounds. - public SkinnableDrawable(ISkinComponent component, Func defaultImplementation = null, Func allowFallback = null, - ConfineMode confineMode = ConfineMode.NoScaling) - : this(component, allowFallback, confineMode) + public SkinnableDrawable(ISkinComponent component, Func defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) + : this(component, confineMode) { createDefault = defaultImplementation; } - protected SkinnableDrawable(ISkinComponent component, Func allowFallback = null, ConfineMode confineMode = ConfineMode.NoScaling) - : base(allowFallback) + protected SkinnableDrawable(ISkinComponent component, ConfineMode confineMode = ConfineMode.NoScaling) { this.component = component; this.confineMode = confineMode; @@ -76,13 +73,13 @@ namespace osu.Game.Skinning /// protected virtual bool ApplySizeRestrictionsToDefault => false; - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void SkinChanged(ISkinSource skin) { Drawable = skin.GetDrawableComponent(component); isDefault = false; - if (Drawable == null && allowFallback) + if (Drawable == null) { Drawable = CreateDefault(component); isDefault = true; diff --git a/osu.Game/Skinning/SkinnableSprite.cs b/osu.Game/Skinning/SkinnableSprite.cs index 1340d1474c..56e576d081 100644 --- a/osu.Game/Skinning/SkinnableSprite.cs +++ b/osu.Game/Skinning/SkinnableSprite.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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; @@ -19,8 +18,8 @@ namespace osu.Game.Skinning [Resolved] private TextureStore textures { get; set; } - public SkinnableSprite(string textureName, Func allowFallback = null, ConfineMode confineMode = ConfineMode.NoScaling) - : base(new SpriteComponent(textureName), allowFallback, confineMode) + public SkinnableSprite(string textureName, ConfineMode confineMode = ConfineMode.NoScaling) + : base(new SpriteComponent(textureName), confineMode) { } diff --git a/osu.Game/Skinning/SkinnableSpriteText.cs b/osu.Game/Skinning/SkinnableSpriteText.cs index 06461127b1..2bde3c4180 100644 --- a/osu.Game/Skinning/SkinnableSpriteText.cs +++ b/osu.Game/Skinning/SkinnableSpriteText.cs @@ -9,14 +9,14 @@ namespace osu.Game.Skinning { public class SkinnableSpriteText : SkinnableDrawable, IHasText { - public SkinnableSpriteText(ISkinComponent component, Func defaultImplementation, Func allowFallback = null, ConfineMode confineMode = ConfineMode.NoScaling) - : base(component, defaultImplementation, allowFallback, confineMode) + public SkinnableSpriteText(ISkinComponent component, Func defaultImplementation, ConfineMode confineMode = ConfineMode.NoScaling) + : base(component, defaultImplementation, confineMode) { } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void SkinChanged(ISkinSource skin) { - base.SkinChanged(skin, allowFallback); + base.SkinChanged(skin); if (Drawable is IHasText textDrawable) textDrawable.Text = Text; diff --git a/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs b/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs index 2107ca7a8b..67114de948 100644 --- a/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs +++ b/osu.Game/Skinning/SkinnableTargetComponentsContainer.cs @@ -17,6 +17,8 @@ namespace osu.Game.Skinning { public bool IsEditable => false; + public bool UsesFixedAnchor { get; set; } + private readonly Action applyDefaults; /// diff --git a/osu.Game/Skinning/SkinnableTargetContainer.cs b/osu.Game/Skinning/SkinnableTargetContainer.cs index d454e199dc..53b142f09a 100644 --- a/osu.Game/Skinning/SkinnableTargetContainer.cs +++ b/osu.Game/Skinning/SkinnableTargetContainer.cs @@ -18,6 +18,8 @@ namespace osu.Game.Skinning private readonly BindableList components = new BindableList(); + public bool ComponentsLoaded { get; private set; } + public SkinnableTargetContainer(SkinnableTarget target) { Target = target; @@ -30,6 +32,7 @@ namespace osu.Game.Skinning { ClearInternal(); components.Clear(); + ComponentsLoaded = false; content = CurrentSkin.GetDrawableComponent(new SkinnableTargetComponent(Target)) as SkinnableTargetComponentsContainer; @@ -39,8 +42,11 @@ namespace osu.Game.Skinning { AddInternal(wrapper); components.AddRange(wrapper.Children.OfType()); + ComponentsLoaded = true; }); } + else + ComponentsLoaded = true; } /// @@ -73,9 +79,9 @@ namespace osu.Game.Skinning components.Remove(component); } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void SkinChanged(ISkinSource skin) { - base.SkinChanged(skin, allowFallback); + base.SkinChanged(skin); Reload(); } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index ca041da801..8a31e4576a 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -25,7 +25,7 @@ namespace osu.Game.Storyboards.Drawables /// public IBindable HasStoryboardEnded => hasStoryboardEnded; - private readonly BindableBool hasStoryboardEnded = new BindableBool(); + private readonly BindableBool hasStoryboardEnded = new BindableBool(true); protected override Container Content { get; } diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs index fbdd27e762..672274a2ad 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboardSample.cs @@ -31,9 +31,9 @@ namespace osu.Game.Storyboards.Drawables [Resolved] private IBindable> mods { get; set; } - protected override void SkinChanged(ISkinSource skin, bool allowFallback) + protected override void SkinChanged(ISkinSource skin) { - base.SkinChanged(skin, allowFallback); + base.SkinChanged(skin); foreach (var mod in mods.Value.OfType()) { diff --git a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs index 0a7fb1483d..e0c2965fa0 100644 --- a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs @@ -158,6 +158,10 @@ namespace osu.Game.Tests.Beatmaps add { } remove { } } + + public ISkin FindProvider(Func lookupFunction) => lookupFunction(this) ? this : null; + + public IEnumerable AllSources => new[] { this }; } } } diff --git a/osu.Game/Tests/CleanRunHeadlessGameHost.cs b/osu.Game/Tests/CleanRunHeadlessGameHost.cs index baa7b27d28..03ab94d1da 100644 --- a/osu.Game/Tests/CleanRunHeadlessGameHost.cs +++ b/osu.Game/Tests/CleanRunHeadlessGameHost.cs @@ -25,8 +25,11 @@ namespace osu.Game.Tests protected override void SetupForRun() { - base.SetupForRun(); Storage.DeleteDirectory(string.Empty); + + // base call needs to be run *after* storage is emptied, as it updates the (static) logger's storage and may start writing + // log entries from another source if a unit test host is shared over multiple tests, causing a file access denied exception. + base.SetupForRun(); } } } diff --git a/osu.Game/Tests/Visual/DependencyProvidingContainer.cs b/osu.Game/Tests/Visual/DependencyProvidingContainer.cs new file mode 100644 index 0000000000..c799cad61a --- /dev/null +++ b/osu.Game/Tests/Visual/DependencyProvidingContainer.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 osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Tests.Visual +{ + /// + /// A which providing ad-hoc dependencies to the child drawables. + /// + /// + /// The must be set while this is not loaded. + /// + public class DependencyProvidingContainer : Container + { + /// + /// The dependencies provided to the children. + /// + // TODO: should be an init-only property when C# 9 + public (Type, object)[] CachedDependencies { get; set; } = Array.Empty<(Type, object)>(); + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencyContainer = new DependencyContainer(base.CreateChildDependencies(parent)); + + foreach (var (type, value) in CachedDependencies) + dependencyContainer.CacheAs(type, value); + + return dependencyContainer; + } + } +} diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs index d7b02ef797..a393802309 100644 --- a/osu.Game/Tests/Visual/EditorTestScene.cs +++ b/osu.Game/Tests/Visual/EditorTestScene.cs @@ -94,7 +94,7 @@ namespace osu.Game.Tests.Visual private readonly WorkingBeatmap testBeatmap; public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, IResourceStore resources, GameHost host, WorkingBeatmap defaultBeatmap, WorkingBeatmap testBeatmap) - : base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap, false) + : base(storage, contextFactory, rulesets, api, audioManager, resources, host, defaultBeatmap) { this.testBeatmap = testBeatmap; } diff --git a/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs b/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs index b810bbf6ae..d74be70df8 100644 --- a/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs +++ b/osu.Game/Tests/Visual/LegacySkinPlayerTestScene.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; @@ -47,6 +48,8 @@ namespace osu.Game.Tests.Visual LegacySkin.ResetDrawableTarget(t); t.Reload(); })); + + AddUntilStep("wait for components to load", () => this.ChildrenOfType().All(t => t.ComponentsLoaded)); } public class SkinProvidingPlayer : TestPlayer diff --git a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs new file mode 100644 index 0000000000..204c189591 --- /dev/null +++ b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.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.Database; +using osu.Game.Online.Multiplayer; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Tests.Visual.OnlinePlay; +using osu.Game.Tests.Visual.Spectator; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + /// + /// Interface that defines the dependencies required for multiplayer test scenes. + /// + public interface IMultiplayerTestSceneDependencies : IOnlinePlayTestSceneDependencies + { + /// + /// The cached . + /// + TestMultiplayerClient Client { get; } + + /// + /// The cached . + /// + new TestMultiplayerRoomManager RoomManager { get; } + + /// + /// The cached . + /// + TestUserLookupCache LookupCache { get; } + + /// + /// The cached . + /// + TestSpectatorClient SpectatorClient { get; } + } +} diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index c76d1053b2..b7d3793ab1 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -2,66 +2,55 @@ // 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.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay; -using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Visual.OnlinePlay; +using osu.Game.Tests.Visual.Spectator; namespace osu.Game.Tests.Visual.Multiplayer { - public abstract class MultiplayerTestScene : RoomTestScene + /// + /// The base test scene for all multiplayer components and screens. + /// + public abstract class MultiplayerTestScene : OnlinePlayTestScene, IMultiplayerTestSceneDependencies { public const int PLAYER_1_ID = 55; public const int PLAYER_2_ID = 56; - [Cached(typeof(MultiplayerClient))] - public TestMultiplayerClient Client { get; } + public TestMultiplayerClient Client => OnlinePlayDependencies.Client; + public new TestMultiplayerRoomManager RoomManager => OnlinePlayDependencies.RoomManager; + public TestUserLookupCache LookupCache => OnlinePlayDependencies?.LookupCache; + public TestSpectatorClient SpectatorClient => OnlinePlayDependencies?.SpectatorClient; - [Cached(typeof(IRoomManager))] - public TestMultiplayerRoomManager RoomManager { get; } - - [Cached] - public Bindable Filter { get; } - - [Cached] - public OngoingOperationTracker OngoingOperationTracker { get; } - - protected override Container Content => content; - private readonly TestMultiplayerRoomContainer content; + protected new MultiplayerTestSceneDependencies OnlinePlayDependencies => (MultiplayerTestSceneDependencies)base.OnlinePlayDependencies; private readonly bool joinRoom; protected MultiplayerTestScene(bool joinRoom = true) { this.joinRoom = joinRoom; - base.Content.Add(content = new TestMultiplayerRoomContainer { RelativeSizeAxes = Axes.Both }); - - Client = content.Client; - RoomManager = content.RoomManager; - Filter = content.Filter; - OngoingOperationTracker = content.OngoingOperationTracker; } [SetUp] public new void Setup() => Schedule(() => { - RoomManager.Schedule(() => RoomManager.PartRoom()); - if (joinRoom) { - Room.Name.Value = "test name"; - Room.Playlist.Add(new PlaylistItem + var room = new Room { - Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, - Ruleset = { Value = Ruleset.Value } - }); + Name = { Value = "test name" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, + Ruleset = { Value = Ruleset.Value } + } + } + }; - RoomManager.Schedule(() => RoomManager.CreateRoom(Room)); + RoomManager.CreateRoom(room); + SelectedRoom.Value = room; } }); @@ -72,5 +61,7 @@ namespace osu.Game.Tests.Visual.Multiplayer if (joinRoom) AddUntilStep("wait for room join", () => Client.Room != null); } + + protected override OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new MultiplayerTestSceneDependencies(); } } diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs new file mode 100644 index 0000000000..a2b0b066a7 --- /dev/null +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Database; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Spectator; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Tests.Visual.OnlinePlay; +using osu.Game.Tests.Visual.Spectator; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + /// + /// Contains the basic dependencies of multiplayer test scenes. + /// + public class MultiplayerTestSceneDependencies : OnlinePlayTestSceneDependencies, IMultiplayerTestSceneDependencies + { + public TestMultiplayerClient Client { get; } + public TestUserLookupCache LookupCache { get; } + public TestSpectatorClient SpectatorClient { get; } + public new TestMultiplayerRoomManager RoomManager => (TestMultiplayerRoomManager)base.RoomManager; + + public MultiplayerTestSceneDependencies() + { + Client = new TestMultiplayerClient(RoomManager); + LookupCache = new TestUserLookupCache(); + SpectatorClient = CreateSpectatorClient(); + + CacheAs(Client); + CacheAs(LookupCache); + CacheAs(SpectatorClient); + } + + protected override IRoomManager CreateRoomManager() => new TestMultiplayerRoomManager(); + + protected virtual TestSpectatorClient CreateSpectatorClient() => new TestSpectatorClient(); + } +} diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index b12bd8091d..1528ed0bc8 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -20,12 +20,15 @@ using osu.Game.Users; namespace osu.Game.Tests.Visual.Multiplayer { + /// + /// A for use in multiplayer test scenes. Should generally not be used by itself outside of a . + /// public class TestMultiplayerClient : MultiplayerClient { public override IBindable IsConnected => isConnected; private readonly Bindable isConnected = new Bindable(true); - public Room? APIRoom { get; private set; } + public new Room? APIRoom => base.APIRoom; public Action? RoomSetupAction; @@ -112,10 +115,13 @@ namespace osu.Game.Tests.Visual.Multiplayer ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged(userId, newBeatmapAvailability); } - protected override Task JoinRoom(long roomId) + protected override Task JoinRoom(long roomId, string? password = null) { var apiRoom = roomManager.Rooms.Single(r => r.RoomID.Value == roomId); + if (password != apiRoom.Password.Value) + throw new InvalidOperationException("Invalid password."); + var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id) { User = api.LocalUser.Value @@ -131,7 +137,9 @@ namespace osu.Game.Tests.Visual.Multiplayer BeatmapChecksum = apiRoom.Playlist.Last().Beatmap.Value.MD5Hash, RequiredMods = apiRoom.Playlist.Last().RequiredMods.Select(m => new APIMod(m)).ToArray(), AllowedMods = apiRoom.Playlist.Last().AllowedMods.Select(m => new APIMod(m)).ToArray(), - PlaylistItemId = apiRoom.Playlist.Last().ID + PlaylistItemId = apiRoom.Playlist.Last().ID, + // ReSharper disable once ConstantNullCoalescingCondition Incorrect inspection due to lack of nullable in Room.cs. + Password = password ?? string.Empty, }, Users = { localUser }, Host = localUser @@ -140,16 +148,10 @@ namespace osu.Game.Tests.Visual.Multiplayer RoomSetupAction?.Invoke(room); RoomSetupAction = null; - APIRoom = apiRoom; - return Task.FromResult(room); } - protected override Task LeaveRoomInternal() - { - APIRoom = null; - return Task.CompletedTask; - } + protected override Task LeaveRoomInternal() => Task.CompletedTask; public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs deleted file mode 100644 index 1abf4d8f5d..0000000000 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomContainer.cs +++ /dev/null @@ -1,48 +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; -using osu.Framework.Graphics.Containers; -using osu.Game.Online.Multiplayer; -using osu.Game.Screens.OnlinePlay; -using osu.Game.Screens.OnlinePlay.Lounge.Components; - -namespace osu.Game.Tests.Visual.Multiplayer -{ - public class TestMultiplayerRoomContainer : Container - { - protected override Container Content => content; - private readonly Container content; - - [Cached(typeof(MultiplayerClient))] - public readonly TestMultiplayerClient Client; - - [Cached(typeof(IRoomManager))] - public readonly TestMultiplayerRoomManager RoomManager; - - [Cached] - public readonly Bindable Filter = new Bindable(new FilterCriteria()); - - [Cached] - public readonly OngoingOperationTracker OngoingOperationTracker; - - public TestMultiplayerRoomContainer() - { - RelativeSizeAxes = Axes.Both; - - RoomManager = new TestMultiplayerRoomManager(); - Client = new TestMultiplayerClient(RoomManager); - OngoingOperationTracker = new OngoingOperationTracker(); - - AddRangeInternal(new Drawable[] - { - Client, - RoomManager, - OngoingOperationTracker, - content = new Container { RelativeSizeAxes = Axes.Both } - }); - } - } -} diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs index 315be510a3..59679f3d66 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs @@ -11,11 +11,15 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.OnlinePlay.Multiplayer; namespace osu.Game.Tests.Visual.Multiplayer { + /// + /// A for use in multiplayer test scenes. Should generally not be used by itself outside of a . + /// public class TestMultiplayerRoomManager : MultiplayerRoomManager { [Resolved] @@ -29,10 +33,9 @@ namespace osu.Game.Tests.Visual.Multiplayer public new readonly List Rooms = new List(); - protected override void LoadComplete() + [BackgroundDependencyLoader] + private void load() { - base.LoadComplete(); - int currentScoreId = 0; int currentRoomId = 0; int currentPlaylistItemId = 0; @@ -42,21 +45,38 @@ namespace osu.Game.Tests.Visual.Multiplayer switch (req) { case CreateRoomRequest createRoomRequest: - var createdRoom = new APICreatedRoom(); + var apiRoom = new Room(); - createdRoom.CopyFrom(createRoomRequest.Room); - createdRoom.RoomID.Value ??= currentRoomId++; + apiRoom.CopyFrom(createRoomRequest.Room); + apiRoom.RoomID.Value ??= currentRoomId++; - for (int i = 0; i < createdRoom.Playlist.Count; i++) - createdRoom.Playlist[i].ID = currentPlaylistItemId++; + // Passwords are explicitly not copied between rooms. + apiRoom.HasPassword.Value = !string.IsNullOrEmpty(createRoomRequest.Room.Password.Value); + apiRoom.Password.Value = createRoomRequest.Room.Password.Value; - Rooms.Add(createdRoom); - createRoomRequest.TriggerSuccess(createdRoom); + for (int i = 0; i < apiRoom.Playlist.Count; i++) + apiRoom.Playlist[i].ID = currentPlaylistItemId++; + + var responseRoom = new APICreatedRoom(); + responseRoom.CopyFrom(createResponseRoom(apiRoom, false)); + + Rooms.Add(apiRoom); + createRoomRequest.TriggerSuccess(responseRoom); return true; case JoinRoomRequest joinRoomRequest: + { + var room = Rooms.Single(r => r.RoomID.Value == joinRoomRequest.Room.RoomID.Value); + + if (joinRoomRequest.Password != room.Password.Value) + { + joinRoomRequest.TriggerFailure(new InvalidOperationException("Invalid password.")); + return true; + } + joinRoomRequest.TriggerSuccess(); return true; + } case PartRoomRequest partRoomRequest: partRoomRequest.TriggerSuccess(); @@ -66,20 +86,13 @@ namespace osu.Game.Tests.Visual.Multiplayer var roomsWithoutParticipants = new List(); foreach (var r in Rooms) - { - var newRoom = new Room(); - - newRoom.CopyFrom(r); - newRoom.RecentParticipants.Clear(); - - roomsWithoutParticipants.Add(newRoom); - } + roomsWithoutParticipants.Add(createResponseRoom(r, false)); getRoomsRequest.TriggerSuccess(roomsWithoutParticipants); return true; case GetRoomRequest getRoomRequest: - getRoomRequest.TriggerSuccess(Rooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId)); + getRoomRequest.TriggerSuccess(createResponseRoom(Rooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId), true)); return true; case GetBeatmapSetRequest getBeatmapSetRequest: @@ -115,6 +128,16 @@ namespace osu.Game.Tests.Visual.Multiplayer }; } + private Room createResponseRoom(Room room, bool withParticipants) + { + var responseRoom = new Room(); + responseRoom.CopyFrom(room); + responseRoom.Password.Value = null; + if (!withParticipants) + responseRoom.RecentParticipants.Clear(); + return responseRoom; + } + public new void ClearRooms() => base.ClearRooms(); public new void Schedule(Action action) => base.Schedule(action); diff --git a/osu.Game/Tests/Visual/OnlinePlay/BasicTestRoomManager.cs b/osu.Game/Tests/Visual/OnlinePlay/BasicTestRoomManager.cs new file mode 100644 index 0000000000..82c7266598 --- /dev/null +++ b/osu.Game/Tests/Visual/OnlinePlay/BasicTestRoomManager.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 osu.Framework.Bindables; +using osu.Game.Beatmaps; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Components; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.OnlinePlay +{ + /// + /// A very simple for use in online play test scenes. + /// + public class BasicTestRoomManager : IRoomManager + { + public event Action RoomsUpdated + { + add { } + remove { } + } + + public readonly BindableList Rooms = new BindableList(); + + public Action JoinRoomRequested; + + public IBindable InitialRoomsReceived { get; } = new Bindable(true); + + IBindableList IRoomManager.Rooms => Rooms; + + public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) + { + room.RoomID.Value ??= Rooms.Select(r => r.RoomID.Value).Where(id => id != null).Select(id => id.Value).DefaultIfEmpty().Max() + 1; + Rooms.Add(room); + onSuccess?.Invoke(room); + } + + public void JoinRoom(Room room, string password, Action onSuccess = null, Action onError = null) + { + JoinRoomRequested?.Invoke(room, password); + onSuccess?.Invoke(room); + } + + public void PartRoom() + { + } + + public void AddRooms(int count, RulesetInfo ruleset = null, bool withPassword = false) + { + 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 }, + Password = { Value = withPassword ? "password" : string.Empty } + }; + + if (ruleset != null) + { + room.Playlist.Add(new PlaylistItem + { + Ruleset = { Value = ruleset }, + Beatmap = + { + Value = new BeatmapInfo + { + Metadata = new BeatmapMetadata() + } + } + }); + } + + CreateRoom(room); + } + } + } +} diff --git a/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.cs new file mode 100644 index 0000000000..6e1e831d9b --- /dev/null +++ b/osu.Game/Tests/Visual/OnlinePlay/IOnlinePlayTestSceneDependencies.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.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Lounge.Components; + +namespace osu.Game.Tests.Visual.OnlinePlay +{ + /// + /// Interface that defines the dependencies required for online play test scenes. + /// + public interface IOnlinePlayTestSceneDependencies + { + /// + /// The cached . + /// + Bindable SelectedRoom { get; } + + /// + /// The cached + /// + IRoomManager RoomManager { get; } + + /// + /// The cached . + /// + Bindable Filter { get; } + + /// + /// The cached . + /// + OngoingOperationTracker OngoingOperationTracker { get; } + + /// + /// The cached . + /// + OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker { get; } + } +} diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs new file mode 100644 index 0000000000..997c910dd4 --- /dev/null +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.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; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Lounge.Components; + +namespace osu.Game.Tests.Visual.OnlinePlay +{ + /// + /// A base test scene for all online play components and screens. + /// + public abstract class OnlinePlayTestScene : ScreenTestScene, IOnlinePlayTestSceneDependencies + { + public Bindable SelectedRoom => OnlinePlayDependencies?.SelectedRoom; + public IRoomManager RoomManager => OnlinePlayDependencies?.RoomManager; + public Bindable Filter => OnlinePlayDependencies?.Filter; + public OngoingOperationTracker OngoingOperationTracker => OnlinePlayDependencies?.OngoingOperationTracker; + public OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker => OnlinePlayDependencies?.AvailabilityTracker; + + /// + /// All dependencies required for online play components and screens. + /// + protected OnlinePlayTestSceneDependencies OnlinePlayDependencies => dependencies?.OnlinePlayDependencies; + + private DelegatedDependencyContainer dependencies; + + protected override Container Content => content; + private readonly Container content; + private readonly Container drawableDependenciesContainer; + + protected OnlinePlayTestScene() + { + base.Content.AddRange(new Drawable[] + { + drawableDependenciesContainer = new Container { RelativeSizeAxes = Axes.Both }, + content = new Container { RelativeSizeAxes = Axes.Both }, + }); + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + dependencies = new DelegatedDependencyContainer(base.CreateChildDependencies(parent)); + return dependencies; + } + + [SetUp] + public void Setup() => Schedule(() => + { + // Reset the room dependencies to a fresh state. + drawableDependenciesContainer.Clear(); + dependencies.OnlinePlayDependencies = CreateOnlinePlayDependencies(); + drawableDependenciesContainer.AddRange(OnlinePlayDependencies.DrawableComponents); + }); + + /// + /// Creates the room dependencies. Called every . + /// + /// + /// Any custom dependencies required for online play sub-classes should be added here. + /// + protected virtual OnlinePlayTestSceneDependencies CreateOnlinePlayDependencies() => new OnlinePlayTestSceneDependencies(); + + /// + /// A providing a mutable lookup source for online play dependencies. + /// + private class DelegatedDependencyContainer : IReadOnlyDependencyContainer + { + /// + /// The online play dependencies. + /// + public OnlinePlayTestSceneDependencies OnlinePlayDependencies { get; set; } + + private readonly IReadOnlyDependencyContainer parent; + private readonly DependencyContainer injectableDependencies; + + /// + /// Creates a new . + /// + /// The fallback to use when cannot satisfy a dependency. + public DelegatedDependencyContainer(IReadOnlyDependencyContainer parent) + { + this.parent = parent; + injectableDependencies = new DependencyContainer(this); + } + + public object Get(Type type) + => OnlinePlayDependencies?.Get(type) ?? parent.Get(type); + + public object Get(Type type, CacheInfo info) + => OnlinePlayDependencies?.Get(type, info) ?? parent.Get(type, info); + + public void Inject(T instance) + where T : class + => injectableDependencies.Inject(instance); + } + } +} diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.cs new file mode 100644 index 0000000000..ddbbfe501b --- /dev/null +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestSceneDependencies.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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Lounge.Components; + +namespace osu.Game.Tests.Visual.OnlinePlay +{ + /// + /// Contains the basic dependencies of online play test scenes. + /// + public class OnlinePlayTestSceneDependencies : IReadOnlyDependencyContainer, IOnlinePlayTestSceneDependencies + { + public Bindable SelectedRoom { get; } + public IRoomManager RoomManager { get; } + public Bindable Filter { get; } + public OngoingOperationTracker OngoingOperationTracker { get; } + public OnlinePlayBeatmapAvailabilityTracker AvailabilityTracker { get; } + + /// + /// All cached dependencies which are also components. + /// + public IReadOnlyList DrawableComponents => drawableComponents; + + private readonly List drawableComponents = new List(); + private readonly DependencyContainer dependencies; + + public OnlinePlayTestSceneDependencies() + { + SelectedRoom = new Bindable(); + RoomManager = CreateRoomManager(); + Filter = new Bindable(new FilterCriteria()); + OngoingOperationTracker = new OngoingOperationTracker(); + AvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); + + dependencies = new DependencyContainer(new CachedModelDependencyContainer(null) { Model = { BindTarget = SelectedRoom } }); + + CacheAs(SelectedRoom); + CacheAs(RoomManager); + CacheAs(Filter); + CacheAs(OngoingOperationTracker); + CacheAs(AvailabilityTracker); + } + + public object Get(Type type) + => dependencies.Get(type); + + public object Get(Type type, CacheInfo info) + => dependencies.Get(type, info); + + public void Inject(T instance) + where T : class + => dependencies.Inject(instance); + + protected void Cache(object instance) + { + dependencies.Cache(instance); + if (instance is Drawable drawable) + drawableComponents.Add(drawable); + } + + protected void CacheAs(T instance) + where T : class + { + dependencies.CacheAs(instance); + if (instance is Drawable drawable) + drawableComponents.Add(drawable); + } + + protected virtual IRoomManager CreateRoomManager() => new BasicTestRoomManager(); + } +} diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index 01dd7a25c8..c5e2e67eaf 100644 --- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing.Input; using osu.Game.Graphics.Cursor; @@ -34,9 +35,16 @@ namespace osu.Game.Tests.Visual { MenuCursorContainer cursorContainer; - CompositeDrawable mainContent = - (cursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }) - .WithChild(content = new OsuTooltipContainer(cursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }); + CompositeDrawable mainContent = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = cursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both, } + }; + + cursorContainer.Child = content = new OsuTooltipContainer(cursorContainer.Cursor) + { + RelativeSizeAxes = Axes.Both + }; if (CreateNestedActionContainer) { diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index be9a015ab2..57e400a77e 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -123,7 +123,7 @@ namespace osu.Game.Tests.Visual { dummyAPI = new DummyAPIAccess(); Dependencies.CacheAs(dummyAPI); - Add(dummyAPI); + base.Content.Add(dummyAPI); } return Dependencies; @@ -176,7 +176,7 @@ namespace osu.Game.Tests.Visual protected virtual IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset); protected WorkingBeatmap CreateWorkingBeatmap(RulesetInfo ruleset) => - CreateWorkingBeatmap(CreateBeatmap(ruleset), null); + CreateWorkingBeatmap(CreateBeatmap(ruleset)); protected virtual WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) => new ClockBackedTestWorkingBeatmap(beatmap, storyboard, Clock, Audio); @@ -350,7 +350,7 @@ namespace osu.Game.Tests.Visual if (CurrentTime >= Length) { Stop(); - RaiseCompleted(); + // `RaiseCompleted` is not called here to prevent transitioning to the next song. } } } diff --git a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs index 2dc77fa72a..42cf826bd4 100644 --- a/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/PlacementBlueprintTestScene.cs @@ -17,11 +17,11 @@ namespace osu.Game.Tests.Visual public abstract class PlacementBlueprintTestScene : OsuManualInputManagerTestScene, IPlacementHandler { protected readonly Container HitObjectContainer; - private PlacementBlueprint currentBlueprint; + protected PlacementBlueprint CurrentBlueprint { get; private set; } protected PlacementBlueprintTestScene() { - Add(HitObjectContainer = CreateHitObjectContainer().With(c => c.Clock = new FramedClock(new StopwatchClock()))); + base.Content.Add(HitObjectContainer = CreateHitObjectContainer().With(c => c.Clock = new FramedClock(new StopwatchClock()))); } [BackgroundDependencyLoader] @@ -63,9 +63,9 @@ namespace osu.Game.Tests.Visual protected void ResetPlacement() { - if (currentBlueprint != null) - Remove(currentBlueprint); - Add(currentBlueprint = CreateBlueprint()); + if (CurrentBlueprint != null) + Remove(CurrentBlueprint); + Add(CurrentBlueprint = CreateBlueprint()); } public void Delete(HitObject hitObject) @@ -76,7 +76,7 @@ namespace osu.Game.Tests.Visual { base.Update(); - currentBlueprint.UpdateTimeAndPosition(SnapForBlueprint(currentBlueprint)); + CurrentBlueprint.UpdateTimeAndPosition(SnapForBlueprint(CurrentBlueprint)); } protected virtual SnapResult SnapForBlueprint(PlacementBlueprint blueprint) => diff --git a/osu.Game/Tests/Visual/PlayerTestScene.cs b/osu.Game/Tests/Visual/PlayerTestScene.cs index 088e997de9..93491c800f 100644 --- a/osu.Game/Tests/Visual/PlayerTestScene.cs +++ b/osu.Game/Tests/Visual/PlayerTestScene.cs @@ -57,7 +57,9 @@ namespace osu.Game.Tests.Visual protected void LoadPlayer() { - var ruleset = Ruleset.Value.CreateInstance(); + var ruleset = CreatePlayerRuleset(); + Ruleset.Value = ruleset.RulesetInfo; + var beatmap = CreateBeatmap(ruleset.RulesetInfo); Beatmap.Value = CreateWorkingBeatmap(beatmap); diff --git a/osu.Game/Tests/Visual/RoomTestScene.cs b/osu.Game/Tests/Visual/RoomTestScene.cs deleted file mode 100644 index aaf5c7624f..0000000000 --- a/osu.Game/Tests/Visual/RoomTestScene.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 NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Online.Rooms; - -namespace osu.Game.Tests.Visual -{ - public abstract class RoomTestScene : ScreenTestScene - { - [Cached] - private readonly Bindable currentRoom = new Bindable(); - - protected Room Room => currentRoom.Value; - - private CachedModelDependencyContainer dependencies; - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - dependencies = new CachedModelDependencyContainer(base.CreateChildDependencies(parent)); - dependencies.Model.BindTo(currentRoom); - return dependencies; - } - - [SetUp] - public void Setup() => Schedule(() => - { - currentRoom.Value = new Room(); - }); - } -} diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index 33cc00e748..b30be05ac4 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Overlays; using osu.Game.Screens; namespace osu.Game.Tests.Visual @@ -19,12 +21,16 @@ namespace osu.Game.Tests.Visual protected override Container Content => content; + [Cached] + protected DialogOverlay DialogOverlay { get; private set; } + protected ScreenTestScene() { base.Content.AddRange(new Drawable[] { Stack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, - content = new Container { RelativeSizeAxes = Axes.Both } + content = new Container { RelativeSizeAxes = Axes.Both }, + DialogOverlay = new DialogOverlay() }); } diff --git a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs index dc12a4999d..c3fb3bfc17 100644 --- a/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs +++ b/osu.Game/Tests/Visual/SelectionBlueprintTestScene.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; @@ -23,7 +24,7 @@ namespace osu.Game.Tests.Visual }); } - protected void AddBlueprint(HitObjectSelectionBlueprint blueprint, DrawableHitObject drawableObject) + protected void AddBlueprint(HitObjectSelectionBlueprint blueprint, [CanBeNull] DrawableHitObject drawableObject = null) { Add(blueprint.With(d => { diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index c7aa43b377..f206d4f8b0 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -20,9 +20,15 @@ namespace osu.Game.Tests.Visual.Spectator { public class TestSpectatorClient : SpectatorClient { + /// + /// Maximum number of frames sent per bundle via . + /// + public const int FRAME_BUNDLE_SIZE = 10; + public override IBindable IsConnected { get; } = new Bindable(true); private readonly Dictionary userBeatmapDictionary = new Dictionary(); + private readonly Dictionary userNextFrameDictionary = new Dictionary(); [Resolved] private IAPIProvider api { get; set; } = null!; @@ -35,6 +41,7 @@ namespace osu.Game.Tests.Visual.Spectator public void StartPlay(int userId, int beatmapId) { userBeatmapDictionary[userId] = beatmapId; + userNextFrameDictionary[userId] = 0; sendPlayingState(userId); } @@ -57,24 +64,41 @@ namespace osu.Game.Tests.Visual.Spectator public new void Schedule(Action action) => base.Schedule(action); /// - /// Sends frames for an arbitrary user. + /// Sends frames for an arbitrary user, in bundles containing 10 frames each. /// /// The user to send frames for. - /// The frame index. - /// The number of frames to send. - public void SendFrames(int userId, int index, int count) + /// The total number of frames to send. + public void SendFrames(int userId, int count) { var frames = new List(); - for (int i = index; i < index + count; i++) - { - var buttonState = i == index + count - 1 ? ReplayButtonState.None : ReplayButtonState.Left1; + int currentFrameIndex = userNextFrameDictionary[userId]; + int lastFrameIndex = currentFrameIndex + count - 1; - frames.Add(new LegacyReplayFrame(i * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState)); + for (; currentFrameIndex <= lastFrameIndex; currentFrameIndex++) + { + // This is done in the next frame so that currentFrameIndex is updated to the correct value. + if (frames.Count == FRAME_BUNDLE_SIZE) + flush(); + + var buttonState = currentFrameIndex == lastFrameIndex ? ReplayButtonState.None : ReplayButtonState.Left1; + frames.Add(new LegacyReplayFrame(currentFrameIndex * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState)); } - var bundle = new FrameDataBundle(new ScoreInfo { Combo = index + count }, frames); - ((ISpectatorClient)this).UserSentFrames(userId, bundle); + flush(); + + userNextFrameDictionary[userId] = currentFrameIndex; + + void flush() + { + if (frames.Count == 0) + return; + + var bundle = new FrameDataBundle(new ScoreInfo { Combo = currentFrameIndex }, frames.ToArray()); + ((ISpectatorClient)this).UserSentFrames(userId, bundle); + + frames.Clear(); + } } protected override Task BeginPlayingInternal(SpectatorState state) diff --git a/osu.Game/Tests/Visual/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs index 0addc9de75..5e5f20b307 100644 --- a/osu.Game/Tests/Visual/TestPlayer.cs +++ b/osu.Game/Tests/Visual/TestPlayer.cs @@ -1,13 +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 osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; +using osu.Game.Scoring; using osu.Game.Screens.Play; namespace osu.Game.Tests.Visual @@ -15,7 +20,7 @@ namespace osu.Game.Tests.Visual /// /// A player that exposes many components that would otherwise not be available, for testing purposes. /// - public class TestPlayer : Player + public class TestPlayer : SoloPlayer { protected override bool PauseOnFocusLost { get; } @@ -34,6 +39,10 @@ namespace osu.Game.Tests.Visual public new HealthProcessor HealthProcessor => base.HealthProcessor; + public bool TokenCreationRequested { get; private set; } + + public Score SubmittedScore { get; private set; } + public new bool PauseCooldownActive => base.PauseCooldownActive; public readonly List Results = new List(); @@ -48,6 +57,40 @@ namespace osu.Game.Tests.Visual PauseOnFocusLost = pauseOnFocusLost; } + protected override bool HandleTokenRetrievalFailure(Exception exception) => false; + + protected override APIRequest CreateTokenRequest() + { + TokenCreationRequested = true; + return base.CreateTokenRequest(); + } + + protected override APIRequest CreateSubmissionRequest(Score score, long token) + { + SubmittedScore = score; + return base.CreateSubmissionRequest(score, token); + } + + protected override void PrepareReplay() + { + // Generally, replay generation is handled by whatever is constructing the player. + // This is implemented locally here to ease migration of test scenes that have some executions + // running with autoplay and some not, but are not written in a way that lends to instantiating + // different `Player` types. + // + // Eventually we will want to remove this and update all test usages which rely on autoplay to use + // a `TestReplayPlayer`. + var autoplayMod = Mods.Value.OfType().FirstOrDefault(); + + if (autoplayMod != null) + { + DrawableRuleset?.SetReplayScore(autoplayMod.CreateReplayScore(GameplayBeatmap.PlayableBeatmap, Mods.Value)); + return; + } + + base.PrepareReplay(); + } + [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Tests/Visual/TestReplayPlayer.cs b/osu.Game/Tests/Visual/TestReplayPlayer.cs new file mode 100644 index 0000000000..da302d018d --- /dev/null +++ b/osu.Game/Tests/Visual/TestReplayPlayer.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 System.Linq; +using osu.Framework.Bindables; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual +{ + /// + /// A player that exposes many components that would otherwise not be available, for testing purposes. + /// + public class TestReplayPlayer : ReplayPlayer + { + protected override bool PauseOnFocusLost { get; } + + public new DrawableRuleset DrawableRuleset => base.DrawableRuleset; + + /// + /// Mods from *player* (not OsuScreen). + /// + public new Bindable> 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 new bool PauseCooldownActive => base.PauseCooldownActive; + + /// + /// Instantiate a replay player that renders an autoplay mod. + /// + public TestReplayPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) + : base((beatmap, mods) => mods.OfType().First().CreateReplayScore(beatmap, mods), new PlayerConfiguration + { + AllowPause = allowPause, + ShowResults = showResults + }) + { + PauseOnFocusLost = pauseOnFocusLost; + } + + /// + /// Instantiate a replay player that renders the provided replay. + /// + public TestReplayPlayer(Score score, bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) + : base(score, new PlayerConfiguration + { + AllowPause = allowPause, + ShowResults = showResults + }) + { + PauseOnFocusLost = pauseOnFocusLost; + } + } +} diff --git a/osu.Game/Tests/Visual/TestUserLookupCache.cs b/osu.Game/Tests/Visual/TestUserLookupCache.cs new file mode 100644 index 0000000000..d2941b5bd5 --- /dev/null +++ b/osu.Game/Tests/Visual/TestUserLookupCache.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Database; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual +{ + public class TestUserLookupCache : UserLookupCache + { + protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) => Task.FromResult(new User + { + Id = lookup, + Username = $"User {lookup}" + }); + } +} diff --git a/osu.Game/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs index 50572a7867..e0409e34df 100644 --- a/osu.Game/Updater/SimpleUpdateManager.cs +++ b/osu.Game/Updater/SimpleUpdateManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json; using osu.Framework; @@ -41,11 +42,16 @@ namespace osu.Game.Updater var latest = releases.ResponseObject; - if (latest.TagName != version) + // avoid any discrepancies due to build suffixes for now. + // eventually we will want to support release streams and consider these. + version = version.Split('-').First(); + var latestTagName = latest.TagName.Split('-').First(); + + if (latestTagName != version) { Notifications.Post(new SimpleNotification { - Text = $"A newer release of osu! has been found ({version} → {latest.TagName}).\n\n" + Text = $"A newer release of osu! has been found ({version} → {latestTagName}).\n\n" + "Click here to download the new version, which can be installed over the top of your existing installation", Icon = FontAwesome.Solid.Upload, Activated = () => diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index 1c72f3ebe2..98ce2cb46c 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -90,7 +90,7 @@ namespace osu.Game.Updater public UpdateCompleteNotification(string version) { this.version = version; - Text = $"You are now running osu!lazer {version}.\nClick to see what's new!"; + Text = $"You are now running osu! {version}.\nClick to see what's new!"; } [BackgroundDependencyLoader] diff --git a/osu.Game/Users/Drawables/ClickableAvatar.cs b/osu.Game/Users/Drawables/ClickableAvatar.cs index 0fca9c7c9b..f73489ac61 100644 --- a/osu.Game/Users/Drawables/ClickableAvatar.cs +++ b/osu.Game/Users/Drawables/ClickableAvatar.cs @@ -2,27 +2,43 @@ // 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.Textures; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; namespace osu.Game.Users.Drawables { public class ClickableAvatar : Container { + private const string default_tooltip_text = "view profile"; + /// /// Whether to open the user's profile when clicked. /// - public readonly BindableBool OpenOnClick = new BindableBool(true); + public bool OpenOnClick + { + set => clickableArea.Enabled.Value = value; + } + + /// + /// By default, the tooltip will show "view profile" as avatars are usually displayed next to a username. + /// Setting this to true exposes the username via tooltip for special cases where this is not true. + /// + public bool ShowUsernameTooltip + { + set => clickableArea.TooltipText = value ? (user?.Username ?? string.Empty) : default_tooltip_text; + } private readonly User user; [Resolved(CanBeNull = true)] private OsuGame game { get; set; } + private readonly ClickableArea clickableArea; + /// /// A clickable avatar for the specified user, with UI sounds included. /// If is true, clicking will open the user's profile. @@ -31,35 +47,35 @@ namespace osu.Game.Users.Drawables public ClickableAvatar(User user = null) { this.user = user; - } - [BackgroundDependencyLoader] - private void load(LargeTextureStore textures) - { - ClickableArea clickableArea; Add(clickableArea = new ClickableArea { RelativeSizeAxes = Axes.Both, Action = openProfile }); + } + [BackgroundDependencyLoader] + private void load(LargeTextureStore textures) + { LoadComponentAsync(new DrawableAvatar(user), clickableArea.Add); - - clickableArea.Enabled.BindTo(OpenOnClick); } private void openProfile() { - if (!OpenOnClick.Value) - return; - if (user?.Id > 1) game?.ShowUser(user.Id); } private class ClickableArea : OsuClickableContainer { - public override string TooltipText => Enabled.Value ? @"view profile" : null; + private LocalisableString tooltip = default_tooltip_text; + + public override LocalisableString TooltipText + { + get => Enabled.Value ? tooltip : default; + set => tooltip = value; + } protected override bool OnClick(ClickEvent e) { diff --git a/osu.Game/Users/Drawables/DrawableAvatar.cs b/osu.Game/Users/Drawables/DrawableAvatar.cs index 3dae3afe3f..87860bd149 100644 --- a/osu.Game/Users/Drawables/DrawableAvatar.cs +++ b/osu.Game/Users/Drawables/DrawableAvatar.cs @@ -31,7 +31,9 @@ namespace osu.Game.Users.Drawables private void load(LargeTextureStore textures) { if (user != null && user.Id > 1) - Texture = textures.Get($@"https://a.ppy.sh/{user.Id}"); + // TODO: The fallback here should not need to exist. Users should be looked up and populated via UserLookupCache or otherwise + // in remaining cases where this is required (chat tabs, local leaderboard), at which point this should be removed. + Texture = textures.Get(user.AvatarUrl ?? $@"https://a.ppy.sh/{user.Id}"); Texture ??= textures.Get(@"Online/avatar-guest"); } diff --git a/osu.Game/Users/Drawables/DrawableFlag.cs b/osu.Game/Users/Drawables/DrawableFlag.cs index 1d648e46b6..aea40a01ae 100644 --- a/osu.Game/Users/Drawables/DrawableFlag.cs +++ b/osu.Game/Users/Drawables/DrawableFlag.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; namespace osu.Game.Users.Drawables { @@ -13,7 +14,7 @@ namespace osu.Game.Users.Drawables { private readonly Country country; - public string TooltipText => country?.FullName; + public LocalisableString TooltipText => country?.FullName; public DrawableFlag(Country country) { diff --git a/osu.Game/Users/Drawables/UpdateableAvatar.cs b/osu.Game/Users/Drawables/UpdateableAvatar.cs index 927e48cb56..df724404e9 100644 --- a/osu.Game/Users/Drawables/UpdateableAvatar.cs +++ b/osu.Game/Users/Drawables/UpdateableAvatar.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 osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; @@ -45,33 +44,38 @@ namespace osu.Game.Users.Drawables protected override double LoadDelay => 200; - /// - /// Whether to show a default guest representation on null user (as opposed to nothing). - /// - public bool ShowGuestOnNull = true; + private readonly bool openOnClick; + private readonly bool showUsernameTooltip; + private readonly bool showGuestOnNull; /// - /// Whether to open the user's profile when clicked. + /// Construct a new UpdateableAvatar. /// - public readonly BindableBool OpenOnClick = new BindableBool(true); - - public UpdateableAvatar(User user = null) + /// The initial user to display. + /// Whether to open the user's profile when clicked. + /// Whether to show the username rather than "view profile" on the tooltip. + /// Whether to show a default guest representation on null user (as opposed to nothing). + public UpdateableAvatar(User user = null, bool openOnClick = true, bool showUsernameTooltip = false, bool showGuestOnNull = true) { + this.openOnClick = openOnClick; + this.showUsernameTooltip = showUsernameTooltip; + this.showGuestOnNull = showGuestOnNull; + User = user; } protected override Drawable CreateDrawable(User user) { - if (user == null && !ShowGuestOnNull) + if (user == null && !showGuestOnNull) return null; var avatar = new ClickableAvatar(user) { + OpenOnClick = openOnClick, + ShowUsernameTooltip = showUsernameTooltip, RelativeSizeAxes = Axes.Both, }; - avatar.OpenOnClick.BindTo(OpenOnClick); - return avatar; } } diff --git a/osu.Game/Users/ExtendedUserPanel.cs b/osu.Game/Users/ExtendedUserPanel.cs index 2604815751..24317e6069 100644 --- a/osu.Game/Users/ExtendedUserPanel.cs +++ b/osu.Game/Users/ExtendedUserPanel.cs @@ -48,11 +48,7 @@ namespace osu.Game.Users statusIcon.FinishTransforms(); } - protected UpdateableAvatar CreateAvatar() => new UpdateableAvatar - { - User = User, - OpenOnClick = { Value = false } - }; + protected UpdateableAvatar CreateAvatar() => new UpdateableAvatar(User, false); protected UpdateableFlag CreateFlag() => new UpdateableFlag(User.Country) { diff --git a/osu.Game/Utils/IDeepCloneable.cs b/osu.Game/Utils/IDeepCloneable.cs new file mode 100644 index 0000000000..6877f346c4 --- /dev/null +++ b/osu.Game/Utils/IDeepCloneable.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.Utils +{ + /// A generic interface for a deeply cloneable type. + /// The type of object to clone. + public interface IDeepCloneable where T : class + { + /// + /// Creates a new that is a deep copy of the current instance. + /// + /// The . + T DeepClone(); + } +} diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index 1c3558fc90..7485950f47 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -51,15 +51,35 @@ namespace osu.Game.Utils /// Whether all s in the combination are compatible with each-other. public static bool CheckCompatibleSet(IEnumerable combination, [NotNullWhen(false)] out List? invalidMods) { - combination = FlattenMods(combination).ToArray(); + var mods = FlattenMods(combination).ToArray(); invalidMods = null; - foreach (var mod in combination) + // ensure there are no duplicate mod definitions. + for (int i = 0; i < mods.Length; i++) + { + var candidate = mods[i]; + + for (int j = i + 1; j < mods.Length; j++) + { + var m = mods[j]; + + if (candidate.Equals(m)) + { + invalidMods ??= new List(); + invalidMods.Add(m); + } + } + } + + foreach (var mod in mods) { foreach (var type in mod.IncompatibleMods) { - foreach (var invalid in combination.Where(m => type.IsInstanceOfType(m))) + foreach (var invalid in mods.Where(m => type.IsInstanceOfType(m))) { + if (invalid == mod) + continue; + invalidMods ??= new List(); invalidMods.Add(invalid); } diff --git a/osu.Game/Utils/StatelessRNG.cs b/osu.Game/Utils/StatelessRNG.cs index 118b08fe30..cd169229e3 100644 --- a/osu.Game/Utils/StatelessRNG.cs +++ b/osu.Game/Utils/StatelessRNG.cs @@ -75,5 +75,10 @@ namespace osu.Game.Utils /// public static float NextSingle(int seed, int series = 0) => (float)(NextULong(seed, series) & ((1 << 24) - 1)) / (1 << 24); // float has 24-bit precision + + /// + /// Compute a random floating point value between and from given seed and series number. + /// + public static float NextSingle(float min, float max, int seed, int series = 0) => min + NextSingle(seed, series) * (max - min); } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 49b86ad56e..152ba55e08 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -18,26 +18,28 @@ + - - + + - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + + diff --git a/osu.iOS.props b/osu.iOS.props index cbb6a21fd1..dc15df6ea6 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,8 +70,8 @@ - - + + @@ -89,15 +89,16 @@ - + - - + + + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 62751cebb1..7284ca1a9a 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -130,7 +130,7 @@ HINT WARNING WARNING - HINT + WARNING WARNING WARNING WARNING @@ -308,6 +308,7 @@ GL GLSL HID + HSV HTML HUD ID